Compare commits
17 Commits
af91df4986
...
bdf81e7c68
Author | SHA1 | Date |
---|---|---|
|
bdf81e7c68 | |
|
c10535492b | |
![]() |
e7567d0f08 | |
![]() |
8b95ec3e38 | |
![]() |
869d974f71 | |
![]() |
0bee74ab5b | |
|
cb9022d8bd | |
|
3b56c7e831 | |
|
e6f097d35c | |
|
b1c3f80afb | |
|
4eb97f27d8 | |
|
6b6ca1d85d | |
|
92fd9948eb | |
|
de1f442082 | |
|
ba5ed6c182 | |
|
484f435ff2 | |
|
eee9540bdc |
|
@ -0,0 +1,5 @@
|
|||
config/config.json
|
||||
config/about_page.htmlbody
|
||||
|
||||
# nano
|
||||
.swp
|
|
@ -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
|
|
@ -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'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't on a LostCave domain, then this site belongs to some lazy admin who forgot to change the about page.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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=
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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, ×tampStr, &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, ×tampStr, &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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
ID int
|
||||
UserID int
|
||||
Type string
|
||||
RelatedID int
|
||||
CreatedAt time.Time
|
||||
Read bool
|
||||
}
|
||||
|
||||
func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) {
|
||||
query := "SELECT id, user_id, type, related_id, read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC"
|
||||
rows, err := db.Query(query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notifications []Notification
|
||||
for rows.Next() {
|
||||
notification := Notification{}
|
||||
err := rows.Scan(¬ification.ID, ¬ification.UserID, ¬ification.Type, ¬ification.RelatedID, ¬ification.Read, ¬ification.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// Stubbed for future implementation
|
||||
func CreateNotification(db *sql.DB, notification Notification) error {
|
||||
query := "INSERT INTO notifications (user_id, type, related_id, read, created_at) VALUES (?, ?, ?, ?, NOW())"
|
||||
_, err := db.Exec(query, notification.UserID, notification.Type, notification.RelatedID, notification.Read)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stubbed for future implementation
|
||||
func MarkNotificationAsRead(db *sql.DB, id int) error {
|
||||
query := "UPDATE notifications SET read = true WHERE id = ?"
|
||||
_, err := db.Exec(query, id)
|
||||
return err
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 93 KiB |
|
@ -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 |
|
@ -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 |
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
Loading…
Reference in New Issue