Compare commits

...

17 Commits

Author SHA1 Message Date
Joca bdf81e7c68 Ask admin credentials on initialize instead of defining them on config.json 2025-06-15 02:48:43 +02:00
Joca c10535492b The initialize function now fails if any board already exists. 2025-06-15 02:48:43 +02:00
BodgeMaster e7567d0f08 README: rip out all the leftovers that are no longer relevant to the new implementation 2025-06-15 02:47:16 +02:00
BodgeMaster 8b95ec3e38 gitignore: add gitignore 2025-06-15 02:47:16 +02:00
BodgeMaster 869d974f71 config: rename files to better hint at what they’re used for 2025-06-15 02:47:16 +02:00
BodgeMaster 0bee74ab5b about page: move all page content into config/about.template, also rewrite most of said content 2025-06-15 02:47:16 +02:00
Joca cb9022d8bd Fixing that reply interface. 2025-06-15 02:47:16 +02:00
Joca 3b56c7e831 Initial implementation of the chat feature. 2025-06-15 02:47:16 +02:00
Joca e6f097d35c Add cli flag for initializing tables instead of creating them at startup 2025-06-15 02:44:03 +02:00
Joca b1c3f80afb Implemented features for creating and deleting boards and threads, removed thread types, enhanced CSS for boards and comments 2025-06-15 02:44:03 +02:00
Joca 4eb97f27d8 Create admin user, admin can edit news blotter 2025-06-15 02:41:36 +02:00
Joca 6b6ca1d85d Fix cookie banner 2025-06-15 02:41:19 +02:00
Joca 92fd9948eb Add config skeleton 2025-06-15 02:41:03 +02:00
Joca de1f442082 Refactor signup and login handlers, add auto table creation 2025-06-15 02:40:40 +02:00
Joca ba5ed6c182 Rewrite README.md 2025-06-15 02:39:56 +02:00
Joca 484f435ff2 Fix up user register 2025-06-15 02:39:35 +02:00
Joca eee9540bdc Initial Commit 2025-06-15 02:37:02 +02:00
55 changed files with 4306 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
config/config.json
config/about_page.htmlbody
# nano
.swp

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# Welcome to ThreadR Rewritten
This is the source code for the ThreadR Forum Engine, rewritten in Go. ThreadR is a free and open-source forum engine designed to allow users to host their own forum instances on personal web servers.
## Project Overview
ThreadR was originally started as a school project in 2019 with the aim of creating a hybrid between a forum and a social media platform. It was built with PHP and (back then still) MySQL.
After we finished school, it was temporarily abandoned. An attempt was made to revive it in 2020, open-sourcing the code and making some things configurable, but not much else happened.
Here we are now, with a full rewrite in Go started in 2025.
## Project Setup
This is for development only. Currently, ThreadR is not ready for production use.
### Prerequisites
- UNIX-like OS
- Go (golang)
- Mariadb
### Setup Steps
1. Create a mariadb user and database for ThreadR (the names can be changed):
```sql
CREATE USER threadr IDENTIFIED BY 'super secure password';
CREATE DATABASE `threadr`;
GRANT ALL PRIVILEGES ON `threadr`.* TO 'threadr';
```
2. Create a config file: In the `config` subdirectory, `cp config.json.sample config.json` and edit it to suit your needs.
3. Create an about page: Also in the `config` subdirectory, `cp about_page.htmlbody.sample about_page.htmlbody` and edit it to suit your needs.
## Running the Application
After configuration, run the following command once to initialize the DB:
```
go run main.go --initialize
```
To start the ThreadR server, run this:
```
go run main.go
```
The server will start on port 8080 by default.
## Contributing
We welcome contributions! Please join our Discord server to get in touch: [discord.gg/r3w3zSkEUE](https://discord.gg/r3w3zSkEUE).
## License
ThreadR is licensed under the Apache 2.0 License. See [LICENSE.md](./LICENSE.md) for details.
**Authors:** BodgeMaster, Jocadbz

View File

@ -0,0 +1,31 @@
<main>
<header>
<h2>About ThreadR</h2>
</header>
<section>
<p>
This is a ThreadR development instance. Beep beep. Boop boop.
</p>
<p>
If you see this message in a production environment (aka. a Forum that is actually being used), kindly tell the admin that they forgot to change the about page. :)
</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.
It originated in 2019 as a school project and has died twice since.
Currently, the project is being rewritten in Go with (hopefully) less ugly hacks.
</p>
<h2>
Who dis?
</h2>
<p>
Depends.<br />
If this site is hosted on a LostCave domain, then it&apos;s probably us, the developers.
For now, you can find us on Discord: <a href="https://discord.gg/r3w3zSkEUE">discord.gg/r3w3zSkEUE</a><br />
If it isn&apos;t on a LostCave domain, then this site belongs to some lazy admin who forgot to change the about page.
</p>
</section>
</main>

View File

@ -0,0 +1,8 @@
{
"domain_name": "localhost",
"threadr_dir": "/threadr",
"db_username": "threadr_user",
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306"
}

16
go.mod Normal file
View File

@ -0,0 +1,16 @@
module threadr
go 1.24.0
require (
github.com/go-sql-driver/mysql v1.9.0
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
)

16
go.sum Normal file
View File

@ -0,0 +1,16 @@
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=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=

46
handlers/about.go Normal file
View File

@ -0,0 +1,46 @@
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
cookie, _ := r.Cookie("threadr_cookie_banner")
aboutContent, err := ioutil.ReadFile("config/about_page.htmlbody")
if err != nil {
log.Printf("Error reading about_page.htmlbody: %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: cookie == nil || cookie.Value != "accepted",
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)
}
}

78
handlers/app.go Normal file
View File

@ -0,0 +1,78 @@
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")
session.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days
HttpOnly: true,
}
}
if _, ok := session.Values["user_id"].(int); ok {
// Skip IP and User-Agent check for WebSocket connections
if r.URL.Query().Get("ws") != "true" {
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)
}
}

126
handlers/board.go Normal file
View File

@ -0,0 +1,126 @@
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)
cookie, _ := r.Cookie("threadr_cookie_banner")
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")
if title == "" {
http.Error(w, "Thread title is required", 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,
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: cookie == nil || cookie.Value != "accepted",
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
}
}
}

106
handlers/boards.go Normal file
View File

@ -0,0 +1,106 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
)
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
cookie, _ := r.Cookie("threadr_cookie_banner")
userID, _ := session.Values["user_id"].(int)
isAdmin := false
if loggedIn {
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user: %v", err)
} else if user != nil {
isAdmin = models.HasGlobalPermission(user, models.PermCreateBoard)
}
}
if r.Method == http.MethodPost && loggedIn && isAdmin {
name := r.FormValue("name")
description := r.FormValue("description")
if name == "" {
http.Error(w, "Board name is required", http.StatusBadRequest)
return
}
board := models.Board{
Name: name,
Description: description,
Private: false,
PublicVisible: true,
}
query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)"
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible)
if err != nil {
log.Printf("Error creating board: %v", err)
http.Error(w, "Failed to create board", http.StatusInternalServerError)
return
}
boardID, _ := result.LastInsertId()
http.Redirect(w, r, app.Config.ThreadrDir+"/board/?id="+strconv.FormatInt(boardID, 10), http.StatusFound)
return
}
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
IsAdmin bool
}{
PageData: PageData{
Title: "ThreadR - Boards",
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
PublicBoards: publicBoards,
PrivateBoards: privateBoards,
IsAdmin: isAdmin,
}
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
}
}
}

188
handlers/chat.go Normal file
View File

@ -0,0 +1,188 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"sync"
"threadr/models"
"github.com/gorilla/sessions"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for now; restrict in production
},
}
// ChatHub manages WebSocket connections and broadcasts messages
type ChatHub struct {
clients map[*websocket.Conn]int // Map of connections to user IDs
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
mutex sync.Mutex
}
func NewChatHub() *ChatHub {
return &ChatHub{
clients: make(map[*websocket.Conn]int),
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
}
}
func (h *ChatHub) Run() {
for {
select {
case client := <-h.register:
h.mutex.Lock()
h.clients[client] = 0 // UserID set later
h.mutex.Unlock()
case client := <-h.unregister:
h.mutex.Lock()
delete(h.clients, client)
h.mutex.Unlock()
client.Close()
case message := <-h.broadcast:
h.mutex.Lock()
for client := range h.clients {
err := client.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("Error broadcasting message: %v", err)
client.Close()
delete(h.clients, client)
}
}
h.mutex.Unlock()
}
}
}
var hub = NewChatHub()
func init() {
go hub.Run()
}
func ChatHandler(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
}
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.URL.Query().Get("ws") == "true" {
// Handle WebSocket connection
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Error upgrading to WebSocket: %v", err)
return
}
hub.register <- ws
hub.mutex.Lock()
hub.clients[ws] = userID
hub.mutex.Unlock()
defer func() {
hub.unregister <- ws
}()
for {
_, msg, err := ws.ReadMessage()
if err != nil {
log.Printf("Error reading WebSocket message: %v", err)
break
}
var chatMsg struct {
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
}
if err := json.Unmarshal(msg, &chatMsg); err != nil {
log.Printf("Error unmarshaling message: %v", err)
continue
}
if chatMsg.Type == "message" {
msgObj := models.ChatMessage{
UserID: userID,
Content: chatMsg.Content,
ReplyTo: chatMsg.ReplyTo,
}
if err := models.CreateChatMessage(app.DB, msgObj); err != nil {
log.Printf("Error saving chat message: %v", err)
continue
}
// Fetch the saved message with timestamp and user details
var msgID int
app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
if err != nil {
log.Printf("Error fetching saved message: %v", err)
continue
}
response, _ := json.Marshal(savedMsg)
hub.broadcast <- response
}
}
return
}
if r.URL.Query().Get("autocomplete") == "true" {
// Handle autocomplete for mentions
prefix := r.URL.Query().Get("prefix")
usernames, err := models.GetUsernamesMatching(app.DB, prefix)
if err != nil {
log.Printf("Error fetching usernames for autocomplete: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
response, _ := json.Marshal(usernames)
w.Header().Set("Content-Type", "application/json")
w.Write(response)
return
}
// Render chat page
messages, err := models.GetRecentChatMessages(app.DB, 50)
if err != nil {
log.Printf("Error fetching chat messages: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Reverse messages to show oldest first
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
data := struct {
PageData
Messages []models.ChatMessage
}{
PageData: PageData{
Title: "ThreadR - Chat",
Navbar: "chat",
LoggedIn: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Messages: messages,
}
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
log.Printf("Error executing template in ChatHandler: %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 != "accepted",
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"))
}
}

68
handlers/login.go Normal file
View File

@ -0,0 +1,68 @@
package handlers
import (
"database/sql"
"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 && err != sql.ErrNoRows {
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()
session.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days
HttpOnly: true,
}
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)
}
}

98
handlers/news.go Normal file
View File

@ -0,0 +1,98 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"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
cookie, _ := r.Cookie("threadr_cookie_banner")
userID, _ := session.Values["user_id"].(int)
isAdmin := false
if loggedIn {
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user: %v", err)
} else if user != nil {
isAdmin = models.HasGlobalPermission(user, models.PermManageUsers)
}
}
if r.Method == http.MethodPost && loggedIn && isAdmin {
if action := r.URL.Query().Get("action"); action == "delete" {
newsIDStr := r.URL.Query().Get("id")
newsID, err := strconv.Atoi(newsIDStr)
if err != nil {
http.Error(w, "Invalid news ID", http.StatusBadRequest)
return
}
err = models.DeleteNews(app.DB, newsID)
if err != nil {
log.Printf("Error deleting news item: %v", err)
http.Error(w, "Failed to delete news item", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/news/", http.StatusFound)
return
} else {
title := r.FormValue("title")
content := r.FormValue("content")
if title != "" && content != "" {
news := models.News{
Title: title,
Content: content,
PostedBy: userID,
}
err := models.CreateNews(app.DB, news)
if err != nil {
log.Printf("Error creating news item: %v", err)
http.Error(w, "Failed to create news item", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/news/", http.StatusFound)
return
} else {
http.Error(w, "Title and content are required", http.StatusBadRequest)
return
}
}
}
newsItems, err := models.GetAllNews(app.DB)
if err != nil {
log.Printf("Error fetching news items: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
PageData
News []models.News
IsAdmin bool
}{
PageData: PageData{
Title: "ThreadR - News",
Navbar: "news",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
News: newsItems,
IsAdmin: isAdmin,
}
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
}
}
}

66
handlers/signup.go Normal file
View File

@ -0,0 +1,66 @@
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)
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost {
username := r.FormValue("username")
password := r.FormValue("password")
err := models.CreateUser(app.DB, username, password)
if err != nil {
log.Printf("Error creating user: %v", err)
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: false,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "An error occurred during sign up. Please try again.",
}
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
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: session.Values["user_id"] != nil,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "",
}
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
}
}
}

135
handlers/thread.go Normal file
View File

@ -0,0 +1,135 @@
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)
cookie, _ := r.Cookie("threadr_cookie_banner")
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 := -1
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
}
data := struct {
PageData
Thread models.Thread
Posts []models.Post
}{
PageData: PageData{
Title: "ThreadR - " + thread.Title,
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
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
}
}
}

366
main.go Normal file
View File

@ -0,0 +1,366 @@
package main
import (
"bufio"
"database/sql"
"encoding/json"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"threadr/handlers"
"threadr/models"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/sessions"
"golang.org/x/term"
)
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 createTablesIfNotExist(db *sql.DB) error {
// Create boards table
_, err := db.Exec(`
CREATE TABLE boards (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
private BOOLEAN DEFAULT FALSE,
public_visible BOOLEAN DEFAULT TRUE,
pinned_threads TEXT,
custom_landing_page TEXT,
color_scheme VARCHAR(255)
)`)
if err != nil {
return fmt.Errorf("error creating boards table: %v", err)
}
// Create users table
_, err = db.Exec(`
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
pfp_url VARCHAR(255),
bio TEXT,
authentication_string VARCHAR(128) NOT NULL,
authentication_salt VARCHAR(255) NOT NULL,
authentication_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
permissions BIGINT DEFAULT 0
)`)
if err != nil {
return fmt.Errorf("error creating users table: %v", err)
}
// Create threads table (without type field)
_, err = db.Exec(`
CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by_user_id INT NOT NULL,
accepted_answer_post_id INT,
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating threads table: %v", err)
}
// Create posts table
_, err = db.Exec(`
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
user_id INT NOT NULL,
post_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
edit_time TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
content TEXT,
attachment_hash BIGINT,
attachment_name VARCHAR(255),
title VARCHAR(255),
reply_to INT DEFAULT -1,
FOREIGN KEY (thread_id) REFERENCES threads(id)
)`)
if err != nil {
return fmt.Errorf("error creating posts table: %v", err)
}
// Create likes table
_, err = db.Exec(`
CREATE TABLE likes (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
user_id INT NOT NULL,
type VARCHAR(20) NOT NULL,
UNIQUE KEY unique_like (post_id, user_id),
FOREIGN KEY (post_id) REFERENCES posts(id)
)`)
if err != nil {
return fmt.Errorf("error creating likes table: %v", err)
}
// Create board_permissions table
_, err = db.Exec(`
CREATE TABLE board_permissions (
user_id INT NOT NULL,
board_id INT NOT NULL,
permissions BIGINT DEFAULT 0,
PRIMARY KEY (user_id, board_id),
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating board_permissions table: %v", err)
}
// Create notifications table
_, err = db.Exec(`
CREATE TABLE notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type VARCHAR(50) NOT NULL,
related_id INT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating notifications table: %v", err)
}
// Create reactions table
_, err = db.Exec(`
CREATE TABLE reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
user_id INT NOT NULL,
emoji VARCHAR(10) NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(id)
)`)
if err != nil {
return fmt.Errorf("error creating reactions table: %v", err)
}
// Create reposts table
_, err = db.Exec(`
CREATE TABLE reposts (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
board_id INT NOT NULL,
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (thread_id) REFERENCES threads(id),
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating reposts table: %v", err)
}
// Create news table
_, err = db.Exec(`
CREATE TABLE news (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
posted_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating news table: %v", err)
}
// Create chat_messages table
_, err = db.Exec(`
CREATE TABLE chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
content TEXT NOT NULL,
reply_to INT DEFAULT -1,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating chat_messages table: %v", err)
}
log.Println("Database tables created.")
return nil
}
func ensureAdminUser(db *sql.DB) error {
reader := bufio.NewReader(os.Stdin)
// Get username
fmt.Print("Enter admin username: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
// Check if user already exists
existingUser, err := models.GetUserByUsername(db, username)
if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("error checking for admin user: %v", err)
}
if existingUser != nil {
return fmt.Errorf("user '%s' already exists", username)
}
// Get password
fmt.Print("Enter admin password: ")
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("error reading password: %v", err)
}
password := string(bytePassword)
fmt.Println() // Newline after password input
// Confirm password
fmt.Print("Confirm admin password: ")
bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("error reading password confirmation: %v", err)
}
passwordConfirm := string(bytePasswordConfirm)
fmt.Println()
if password != passwordConfirm {
return fmt.Errorf("passwords do not match")
}
// Create user
log.Printf("Creating admin user: %s", username)
err = models.CreateUser(db, username, password)
if err != nil {
return fmt.Errorf("error creating admin user: %v", err)
}
// Get the newly created admin user to update permissions
adminUser, err := models.GetUserByUsername(db, username)
if err != nil {
return fmt.Errorf("error fetching new admin user: %v", err)
}
// Set admin permissions (all permissions)
_, err = db.Exec("UPDATE users SET permissions = ? WHERE id = ?",
models.PermCreateBoard|models.PermManageUsers, adminUser.ID)
if err != nil {
return fmt.Errorf("error setting admin permissions: %v", err)
}
log.Println("Admin user created successfully with full permissions")
return nil
}
func main() {
// Define command-line flag for initialization
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
flag.BoolVar(initialize, "i", false, "Short for --initialize")
flag.Parse()
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()
// Perform initialization if the flag is set
if *initialize {
log.Println("Initializing database...")
err = createTablesIfNotExist(db)
if err != nil {
log.Fatal("Error creating database tables:", err)
}
err = ensureAdminUser(db)
if err != nil {
log.Fatal("Error ensuring admin user:", err)
}
log.Println("Initialization completed successfully. Exiting.")
return
}
// Normal startup (without automatic table creation)
log.Println("Starting ThreadR server...")
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"),
filepath.Join(dir, "templates/pages/chat.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)))
http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app))))
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

101
models/board.go Normal file
View File

@ -0,0 +1,101 @@
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 sql.NullString
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if description.Valid {
board.Description = description.String
} else {
board.Description = ""
}
if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" {
err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads)
if err != nil {
return nil, err
}
}
if customLandingPage.Valid {
board.CustomLandingPage = customLandingPage.String
} else {
board.CustomLandingPage = ""
}
if colorScheme.Valid {
board.ColorScheme = colorScheme.String
} else {
board.ColorScheme = ""
}
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 sql.NullString
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
if err != nil {
return nil, err
}
if description.Valid {
board.Description = description.String
} else {
board.Description = ""
}
if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" {
err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads)
if err != nil {
return nil, err
}
}
if customLandingPage.Valid {
board.CustomLandingPage = customLandingPage.String
} else {
board.CustomLandingPage = ""
}
if colorScheme.Valid {
board.ColorScheme = colorScheme.String
} else {
board.ColorScheme = ""
}
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
}

132
models/chat.go Normal file
View File

@ -0,0 +1,132 @@
package models
import (
"database/sql"
"time"
)
type ChatMessage struct {
ID int
UserID int
Content string
ReplyTo int // -1 if not a reply
Timestamp time.Time
Username string // For display, fetched from user
PfpURL string // For display, fetched from user
Mentions []string // List of mentioned usernames
}
func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())"
_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo)
return err
}
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
FROM chat_messages cm
JOIN users u ON cm.user_id = u.id
ORDER BY cm.timestamp DESC
LIMIT ?`
rows, err := db.Query(query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []ChatMessage
for rows.Next() {
var msg ChatMessage
var timestampStr string
var pfpURL sql.NullString
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL)
if err != nil {
return nil, err
}
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
if err != nil {
msg.Timestamp = time.Time{}
}
if pfpURL.Valid {
msg.PfpURL = pfpURL.String
}
// Parse mentions from content (simple @username detection)
msg.Mentions = extractMentions(msg.Content)
messages = append(messages, msg)
}
return messages, nil
}
func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
FROM chat_messages cm
JOIN users u ON cm.user_id = u.id
WHERE cm.id = ?`
row := db.QueryRow(query, id)
var msg ChatMessage
var timestampStr string
var pfpURL sql.NullString
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &pfpURL)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
if err != nil {
msg.Timestamp = time.Time{}
}
if pfpURL.Valid {
msg.PfpURL = pfpURL.String
}
msg.Mentions = extractMentions(msg.Content)
return &msg, nil
}
func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10"
rows, err := db.Query(query, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
return usernames, nil
}
// Simple utility to extract mentions from content
func extractMentions(content string) []string {
var mentions []string
var currentMention string
inMention := false
for _, char := range content {
if char == '@' {
inMention = true
currentMention = "@"
} else if inMention && (char == ' ' || char == '\n' || char == '\t') {
if len(currentMention) > 1 {
mentions = append(mentions, currentMention)
}
inMention = false
currentMention = ""
} else if inMention {
currentMention += string(char)
}
}
if inMention && len(currentMention) > 1 {
mentions = append(mentions, currentMention)
}
return mentions
}

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
}

53
models/news.go Normal file
View File

@ -0,0 +1,53 @@
package models
import (
"database/sql"
"time"
)
type News struct {
ID int
Title string
Content string
PostedBy int
CreatedAt time.Time
}
func GetAllNews(db *sql.DB) ([]News, error) {
query := "SELECT id, title, content, posted_by, created_at FROM news ORDER BY created_at DESC"
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var newsItems []News
for rows.Next() {
news := News{}
var createdAtStr string
err := rows.Scan(&news.ID, &news.Title, &news.Content, &news.PostedBy, &createdAtStr)
if err != nil {
return nil, err
}
// Parse the timestamp string into time.Time
news.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr)
if err != nil {
// Fallback to a default time if parsing fails
news.CreatedAt = time.Time{}
}
newsItems = append(newsItems, news)
}
return newsItems, nil
}
func CreateNews(db *sql.DB, news News) error {
query := "INSERT INTO news (title, content, posted_by, created_at) VALUES (?, ?, ?, NOW())"
_, err := db.Exec(query, news.Title, news.Content, news.PostedBy)
return err
}
func DeleteNews(db *sql.DB, id int) error {
query := "DELETE FROM news WHERE id = ?"
_, err := db.Exec(query, id)
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
}

61
models/post.go Normal file
View File

@ -0,0 +1,61 @@
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{}
var postTimeStr string
var editTimeStr sql.NullString
err := rows.Scan(&post.ID, &post.ThreadID, &post.UserID, &postTimeStr, &editTimeStr, &post.Content, &post.AttachmentHash, &post.AttachmentName, &post.Title, &post.ReplyTo)
if err != nil {
return nil, err
}
post.PostTime, err = time.Parse("2006-01-02 15:04:05", postTimeStr)
if err != nil {
post.PostTime = time.Time{}
}
if editTimeStr.Valid {
editTime, err := time.Parse("2006-01-02 15:04:05", editTimeStr.String)
if err != nil {
post.EditTime = nil
} else {
post.EditTime = &editTime
}
} else {
post.EditTime = nil
}
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
}

76
models/thread.go Normal file
View File

@ -0,0 +1,76 @@
package models
import (
"database/sql"
"time"
)
type Thread struct {
ID int
BoardID int
Title string
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, created_at, updated_at, created_by_user_id, accepted_answer_post_id FROM threads WHERE id = ?"
row := db.QueryRow(query, id)
thread := &Thread{}
var createdAtStr string
var updatedAtStr string
err := row.Scan(&thread.ID, &thread.BoardID, &thread.Title, &createdAtStr, &updatedAtStr, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
thread.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr)
if err != nil {
thread.CreatedAt = time.Time{}
}
thread.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtStr)
if err != nil {
thread.UpdatedAt = time.Time{}
}
return thread, nil
}
func GetThreadsByBoardID(db *sql.DB, boardID int) ([]Thread, error) {
query := "SELECT id, board_id, title, 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{}
var createdAtStr string
var updatedAtStr string
err := rows.Scan(&thread.ID, &thread.BoardID, &thread.Title, &createdAtStr, &updatedAtStr, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID)
if err != nil {
return nil, err
}
thread.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr)
if err != nil {
thread.CreatedAt = time.Time{}
}
thread.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtStr)
if err != nil {
thread.UpdatedAt = time.Time{}
}
threads = append(threads, thread)
}
return threads, nil
}
func CreateThread(db *sql.DB, thread Thread) error {
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at, type) VALUES (?, ?, ?, NOW(), NOW(), 'classic')"
_, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID)
return err
}

161
models/user.go Normal file
View File

@ -0,0 +1,161 @@
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{}
var displayName sql.NullString
var pfpURL sql.NullString
var bio sql.NullString
var createdAtString sql.NullString
var updatedAtString sql.NullString
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if displayName.Valid {
user.DisplayName = displayName.String
} else {
user.DisplayName = ""
}
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid {
user.Bio = bio.String
} else {
user.Bio = ""
}
if createdAtString.Valid {
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing created_at: %v", err)
}
} else {
user.CreatedAt = time.Time{}
}
if updatedAtString.Valid {
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing updated_at: %v", err)
}
} else {
user.UpdatedAt = time.Time{}
}
return user, nil
}
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
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{}
var displayName sql.NullString
var pfpURL sql.NullString
var bio sql.NullString
var createdAtString sql.NullString
var updatedAtString sql.NullString
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
if err != nil {
return nil, err
}
if displayName.Valid {
user.DisplayName = displayName.String
} else {
user.DisplayName = ""
}
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid {
user.Bio = bio.String
} else {
user.Bio = ""
}
if createdAtString.Valid {
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing created_at: %v", err)
}
} else {
user.CreatedAt = time.Time{}
}
if updatedAtString.Valid {
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing updated_at: %v", err)
}
} else {
user.UpdatedAt = time.Time{}
}
return user, nil
}
func CheckPassword(password, salt, algorithm, hash string) bool {
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

371
static/style.css Normal file
View File

@ -0,0 +1,371 @@
body {
font-family: monospace;
margin: 0;
padding: 0;
background-color: #fef6e4; /* beige */
color: #001858; /* blue */
}
main {
display: flex;
flex-direction: column;
align-items: center;
padding: 25px;
}
main > header {
text-align: center;
margin-bottom: 1em;
}
main > section {
margin: 1em;
padding: 14px 20px;
border: 1px solid #001858;
border-radius: 5px;
background-color: #f3d2c1; /* orange */
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
width: 80%;
max-width: 800px;
}
main > div {
width: 80%;
max-width: 800px;
}
main > div > article {
border: 1px solid #001858;
padding: 14px 20px;
margin-bottom: 1em;
background-color: #fef6e4;
border-radius: 5px;
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
main > div > article:hover {
transform: translateY(-2px);
box-shadow: 0px 4px 12px 0px rgba(0,0,0,0.15);
}
article > header {
border-bottom: 1px solid #001858;
background-color: #001858;
color: #fef6e4;
padding: 0.5em;
margin: -14px -20px 1em -20px;
border-radius: 5px 5px 0 0;
box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.2);
}
ul.topnav {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #001858;
position: fixed;
top: 0;
left: 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: 1.2em 1.3em;
text-decoration: none;
font-family: monospace;
font-size: 1em;
}
ul.topnav li a:hover:not(.active) {
background-color: #8bd3dd; /* cyan */
}
ul.topnav li a.active {
background-color: #f582ae; /* pink */
}
div.topnav {
height: 3em;
}
div.banner {
position: fixed;
bottom: 0;
left: 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: 8px 0;
padding: 14px 20px;
border: 1px solid #001858;
border-radius: 4px;
background-color: #fef6e4;
color: #001858;
font-family: monospace;
box-sizing: border-box;
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
}
input[type="submit"] {
background-color: #001858;
color: #fef6e4;
border: none;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #8bd3dd;
}
button {
margin: 8px 0;
padding: 14px 20px;
border: none;
border-radius: 4px;
background-color: #001858;
color: #fef6e4;
cursor: pointer;
font-family: monospace;
width: 100%;
}
button:hover {
background-color: #8bd3dd;
}
img {
max-width: 100%;
object-fit: contain;
}
h1, h2, h3, h4, h5, h6 {
font-family: monospace;
color: #001858;
}
p, a, li {
font-family: monospace;
color: #001858;
}
/* Enhanced styles for boards */
ul.board-list {
list-style-type: none;
padding: 0;
margin: 0;
}
li.board-item {
margin-bottom: 1em;
padding: 1em;
background-color: #fef6e4;
border: 1px solid #001858;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
li.board-item:hover {
transform: translateY(-3px);
box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15);
}
li.board-item a {
color: #001858;
font-weight: bold;
text-decoration: none;
font-size: 1.2em;
}
li.board-item a:hover {
color: #f582ae;
text-decoration: underline;
}
p.board-desc {
margin: 0.5em 0 0 0;
color: #001858;
font-size: 0.9em;
}
/* Enhanced styles for thread posts */
.thread-posts {
width: 80%;
max-width: 800px;
}
.post-item {
background-color: #fef6e4;
border: 1px solid #001858;
border-radius: 8px;
margin-bottom: 1.5em;
padding: 1em;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.post-item:hover {
transform: translateY(-3px);
box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15);
}
.post-item header {
background-color: #001858;
color: #fef6e4;
padding: 0.5em;
margin: -1em -1em 1em -1em;
border-radius: 6px 6px 0 0;
border-bottom: 1px solid #001858;
}
.post-item header h3 {
margin: 0;
font-size: 1.1em;
}
.post-item header p {
margin: 0.3em 0 0 0;
font-size: 0.85em;
opacity: 0.9;
}
.post-content {
margin: 0;
padding: 0.5em;
line-height: 1.5;
font-size: 0.95em;
}
.post-actions {
margin-top: 0.8em;
display: flex;
gap: 0.5em;
align-items: center;
}
.post-actions a {
color: #001858;
text-decoration: none;
font-size: 0.9em;
padding: 0.3em 0.6em;
border: 1px solid #001858;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.post-actions a:hover {
background-color: #8bd3dd;
color: #fef6e4;
}
@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;
}
li.board-item {
background-color: #444;
border-color: #fef6e4;
}
li.board-item a {
color: #fef6e4;
}
li.board-item a:hover {
color: #f582ae;
}
p.board-desc {
color: #fef6e4;
}
.post-item {
background-color: #444;
border-color: #fef6e4;
}
.post-content {
color: #fef6e4;
}
.post-actions a {
color: #fef6e4;
border-color: #fef6e4;
}
.post-actions a:hover {
background-color: #8bd3dd;
color: #001858;
}
h1, h2, h3, h4, h5, h6 {
color: #fef6e4;
}
p, a, li {
color: #fef6e4;
}
}
@media (max-width: 600px) {
ul.topnav li {
float: none;
width: 100%;
}
main {
padding: 10px;
}
main > section {
margin: 0.5em;
padding: 0.5em;
width: 95%;
}
main > div {
width: 95%;
}
.thread-posts {
width: 95%;
}
}

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,14 @@
{{define "about"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
{{.AboutContent}}
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,41 @@
{{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>
<h3>Threads</h3>
{{if .Threads}}
<ul>
{{range .Threads}}
<li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</li>
{{end}}
</ul>
{{else}}
<p>No threads available in this board yet.</p>
{{end}}
</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>
<input type="submit" value="Create Thread">
</form>
</section>
{{end}}
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,62 @@
{{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>
{{if .PublicBoards}}
<ul class="board-list">
{{range .PublicBoards}}
<li class="board-item">
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
</ul>
{{else}}
<p>No public boards available at the moment.</p>
{{end}}
</section>
{{if .LoggedIn}}
<section>
<h3>Private Boards</h3>
{{if .PrivateBoards}}
<ul class="board-list">
{{range .PrivateBoards}}
<li class="board-item">
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
</ul>
{{else}}
<p>No private boards available to you at the moment.</p>
{{end}}
</section>
{{end}}
{{if .IsAdmin}}
<section>
<h3>Create New Public Board</h3>
<form method="post" action="{{.BasePath}}/boards/">
<label for="name">Board Name:</label>
<input type="text" id="name" name="name" required><br>
<label for="description">Description:</label>
<textarea id="description" name="description"></textarea><br>
<input type="submit" value="Create Board">
</form>
</section>
{{end}}
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

435
templates/pages/chat.html Normal file
View File

@ -0,0 +1,435 @@
{{define "chat"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
main {
padding: 0;
margin-top: 3em; /* Space for navbar */
height: calc(100vh - 3em);
display: flex;
flex-direction: column;
align-items: center;
}
.chat-container {
width: 100%;
height: calc(100% - 2em); /* Adjust for header */
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background-color: #fef6e4;
box-shadow: none;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
}
.chat-message {
margin-bottom: 8px;
max-width: 90%;
position: relative;
}
.chat-message-header {
display: flex;
align-items: center;
margin-bottom: 3px;
}
.chat-message-pfp {
width: 30px;
height: 30px;
border-radius: 50%;
margin-right: 8px;
}
.chat-message-username {
font-weight: bold;
color: #001858;
font-size: 0.9em;
}
.chat-message-timestamp {
font-size: 0.7em;
color: #666;
margin-left: 8px;
}
.chat-message-content {
background-color: #f3d2c1;
padding: 6px 10px;
border-radius: 5px;
line-height: 1.3;
font-size: 0.9em;
}
.chat-message-reply {
background-color: rgba(0,0,0,0.1);
padding: 4px 8px;
border-radius: 5px;
margin-bottom: 3px;
font-size: 0.8em;
cursor: pointer;
}
.chat-message-mention {
color: #f582ae;
font-weight: bold;
}
.chat-input {
padding: 8px;
border-top: 1px solid #001858;
display: flex;
flex-direction: column;
}
.chat-input textarea {
resize: none;
height: 50px;
margin-bottom: 8px;
font-size: 0.9em;
}
.chat-input button {
align-self: flex-end;
width: auto;
padding: 6px 12px;
font-size: 0.9em;
}
.post-actions {
position: absolute;
top: 5px;
right: 5px;
opacity: 0;
transition: opacity 0.2s ease;
}
.chat-message:hover .post-actions {
opacity: 1;
}
.post-actions a {
color: #001858;
text-decoration: none;
font-size: 0.8em;
padding: 2px 5px;
border: 1px solid #001858;
border-radius: 3px;
}
.post-actions a:hover {
background-color: #8bd3dd;
color: #fef6e4;
}
.autocomplete-popup {
position: absolute;
background-color: #fff;
border: 1px solid #001858;
border-radius: 5px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
z-index: 1000;
display: none;
}
.autocomplete-item {
padding: 6px 10px;
cursor: pointer;
font-size: 0.9em;
}
.autocomplete-item:hover {
background-color: #f3d2c1;
}
.reply-indicator {
background-color: #001858;
color: #fef6e4;
padding: 5px 10px;
border-radius: 5px;
margin-bottom: 8px;
display: none;
align-items: center;
justify-content: space-between;
}
.reply-indicator span {
font-size: 0.9em;
}
.reply-indicator button {
background: none;
border: none;
color: #fef6e4;
cursor: pointer;
font-size: 0.9em;
padding: 0 5px;
margin: 0;
width: auto;
}
.reply-indicator button:hover {
background: none;
color: #f582ae;
}
@media (prefers-color-scheme: dark) {
.chat-container {
background-color: #444;
border-color: #fef6e4;
}
.chat-message-username {
color: #fef6e4;
}
.chat-message-timestamp {
color: #aaa;
}
.chat-message-content {
background-color: #555;
}
.chat-input {
border-color: #fef6e4;
}
.autocomplete-popup {
background-color: #444;
border-color: #fef6e4;
color: #fef6e4;
}
.autocomplete-item:hover {
background-color: #555;
}
.post-actions a {
color: #fef6e4;
border-color: #fef6e4;
}
.post-actions a:hover {
background-color: #8bd3dd;
color: #001858;
}
.reply-indicator {
background-color: #222;
color: #fef6e4;
}
.reply-indicator button {
color: #fef6e4;
}
.reply-indicator button:hover {
color: #f582ae;
}
}
</style>
</head>
<body>
{{template "navbar" .}}
<main>
<header style="display: none;">
<h2>General Chat</h2>
</header>
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
{{range .Messages}}
<div class="chat-message" id="msg-{{.ID}}">
<div class="chat-message-header">
{{if .PfpURL}}
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp">
{{else}}
<div class="chat-message-pfp" style="background-color: #001858;"></div>
{{end}}
<span class="chat-message-username">{{.Username}}</span>
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
</div>
{{if gt .ReplyTo 0}}
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div>
{{end}}
<div class="chat-message-content">{{.Content | html}}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a>
</div>
</div>
{{end}}
</div>
<div class="chat-input">
<div id="reply-indicator" class="reply-indicator">
<span id="reply-username">Replying to </span>
<button onclick="cancelReply()">X</button>
</div>
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
<button onclick="sendMessage()">Send</button>
</div>
</div>
<div id="autocomplete-popup" class="autocomplete-popup"></div>
</main>
{{template "cookie_banner" .}}
<script>
let ws;
let autocompleteActive = false;
let autocompletePrefix = '';
let replyToId = -1;
let replyUsername = '';
function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' });
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
appendMessage(msg);
};
ws.onclose = function() {
console.log("WebSocket closed, reconnecting...");
setTimeout(connectWebSocket, 5000); // Reconnect after 5s
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
};
}
function sendMessage() {
const input = document.getElementById('chat-input-text');
const content = input.value.trim();
if (content === '') return;
const msg = {
type: 'message',
content: content,
replyTo: replyToId
};
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
input.value = '';
cancelReply(); // Reset reply state after sending
} else {
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
}
}
function appendMessage(msg) {
const messages = document.getElementById('chat-messages');
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-message';
msgDiv.id = 'msg-' + msg.ID;
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP" class="chat-message-pfp">` : `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : '';
// Process content for mentions
let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
msgDiv.innerHTML = `
<div class="chat-message-header">
${pfpHTML}
<span class="chat-message-username">${msg.Username}</span>
<span class="chat-message-timestamp">${new Date(msg.Timestamp).toLocaleString()}</span>
</div>
${replyHTML}
<div class="chat-message-content">${content}</div>
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a>
</div>
`;
messages.appendChild(msgDiv);
messages.scrollTop = messages.scrollHeight;
}
function replyToMessage(id, username) {
replyToId = id;
replyUsername = username;
const replyIndicator = document.getElementById('reply-indicator');
const replyUsernameSpan = document.getElementById('reply-username');
replyUsernameSpan.textContent = `Replying to ${username}`;
replyIndicator.style.display = 'flex';
document.getElementById('chat-input-text').focus();
}
function cancelReply() {
replyToId = -1;
replyUsername = '';
const replyIndicator = document.getElementById('reply-indicator');
replyIndicator.style.display = 'none';
}
function scrollToMessage(id) {
const msgElement = document.getElementById('msg-' + id);
if (msgElement) {
msgElement.scrollIntoView({ behavior: 'smooth' });
}
}
function showAutocompletePopup(usernames, x, y) {
const popup = document.getElementById('autocomplete-popup');
popup.innerHTML = '';
popup.style.left = x + 'px';
popup.style.top = y + 'px';
popup.style.display = 'block';
autocompleteActive = true;
usernames.forEach(username => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = username;
item.onclick = () => {
completeMention(username);
popup.style.display = 'none';
autocompleteActive = false;
};
popup.appendChild(item);
});
}
function completeMention(username) {
const input = document.getElementById('chat-input-text');
const text = input.value;
const atIndex = text.lastIndexOf('@', input.selectionStart - 1);
if (atIndex !== -1) {
const before = text.substring(0, atIndex);
const after = text.substring(input.selectionStart);
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after;
input.focus();
}
}
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
const text = e.target.value;
const caretPos = e.target.selectionStart;
const atIndex = text.lastIndexOf('@', caretPos - 1);
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) {
const prefix = text.substring(atIndex + 1, caretPos);
autocompletePrefix = prefix;
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
const usernames = await response.json();
if (usernames.length > 0) {
const rect = e.target.getBoundingClientRect();
// Approximate caret position (this is a rough estimate)
const charWidth = 8; // Rough estimate of character width in pixels
const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth;
showAutocompletePopup(usernames, caretX, rect.top - 10);
} else {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
} else {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
});
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
if (autocompleteActive) {
const popup = document.getElementById('autocomplete-popup');
const items = popup.getElementsByClassName('autocomplete-item');
if (e.key === 'Enter' && items.length > 0) {
items[0].click();
e.preventDefault();
} else if (e.key === 'ArrowDown' && items.length > 0) {
items[0].focus();
e.preventDefault();
}
} else if (e.key === 'Enter' && !e.shiftKey) {
sendMessage();
e.preventDefault();
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
});
// Connect WebSocket on page load
window.onload = function() {
connectWebSocket();
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
};
</script>
</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}}

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

@ -0,0 +1,48 @@
{{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>
{{if .News}}
<ul>
{{range .News}}
<li><strong>{{.Title}}</strong> - Posted on {{.CreatedAt.Format "02/01/2006 - 15:04"}}
<p>{{.Content}}</p>
{{if $.IsAdmin}}
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
<button type="submit" onclick="return confirm('Are you sure you want to delete this news item?')">Delete</button>
</form>
{{end}}
</li>
{{end}}
</ul>
{{else}}
<p>No news items available at the moment.</p>
{{end}}
</section>
{{if .IsAdmin}}
<section>
<h3>Post New Announcement</h3>
<form method="post" action="{{.BasePath}}/news/">
<label for="title">Title:</label>
<input type="text" id="title" name="title" required><br>
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br>
<input type="submit" value="Post News">
</form>
</section>
{{end}}
</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,57 @@
{{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>
</header>
<div class="thread-posts">
{{range .Posts}}
<article id="{{.ID}}" class="post-item" style="margin-left: {{if gt .ReplyTo 0}}20px{{else}}0px{{end}};">
<header>
<h3>{{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}</h3>
<p>Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p>
{{if gt .ReplyTo 0}}
<p>Reply to post <a href="#{{.ReplyTo}}">{{.ReplyTo}}</a></p>
{{end}}
</header>
<div class="post-content">{{.Content}}</div>
{{if $.LoggedIn}}
<div class="post-actions">
<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>
</div>
{{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,18 @@
{{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 {{if eq .Navbar "chat"}}class="active"{{end}} href="{{.BasePath}}/chat/">Chat</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}}