Compare commits

...

29 Commits

Author SHA1 Message Date
Joca bdf81e7c68 Ask admin credentials on initialize instead of defining them on config.json 2025-06-15 02:48:43 +02:00
Joca c10535492b The initialize function now fails if any board already exists. 2025-06-15 02:48:43 +02:00
BodgeMaster e7567d0f08 README: rip out all the leftovers that are no longer relevant to the new implementation 2025-06-15 02:47:16 +02:00
BodgeMaster 8b95ec3e38 gitignore: add gitignore 2025-06-15 02:47:16 +02:00
BodgeMaster 869d974f71 config: rename files to better hint at what they’re used for 2025-06-15 02:47:16 +02:00
BodgeMaster 0bee74ab5b about page: move all page content into config/about.template, also rewrite most of said content 2025-06-15 02:47:16 +02:00
Joca cb9022d8bd Fixing that reply interface. 2025-06-15 02:47:16 +02:00
Joca 3b56c7e831 Initial implementation of the chat feature. 2025-06-15 02:47:16 +02:00
Joca e6f097d35c Add cli flag for initializing tables instead of creating them at startup 2025-06-15 02:44:03 +02:00
Joca b1c3f80afb Implemented features for creating and deleting boards and threads, removed thread types, enhanced CSS for boards and comments 2025-06-15 02:44:03 +02:00
Joca 4eb97f27d8 Create admin user, admin can edit news blotter 2025-06-15 02:41:36 +02:00
Joca 6b6ca1d85d Fix cookie banner 2025-06-15 02:41:19 +02:00
Joca 92fd9948eb Add config skeleton 2025-06-15 02:41:03 +02:00
Joca de1f442082 Refactor signup and login handlers, add auto table creation 2025-06-15 02:40:40 +02:00
Joca ba5ed6c182 Rewrite README.md 2025-06-15 02:39:56 +02:00
Joca 484f435ff2 Fix up user register 2025-06-15 02:39:35 +02:00
Joca eee9540bdc Initial Commit 2025-06-15 02:37:02 +02:00
BodgeMaster af91df4986 everything: Delete everything, full project reset. Keep a copy of variable_grabbler in case we ever need it. 2025-06-15 02:25:56 +02:00
BodgeMaster 8cc33a9727 deployment script: Remove READMEs from copied tree, add placeholder back 2023-01-07 01:07:35 +01:00
BodgeMaster b193cd00bc Stop removing READMEs when building
IIRC, this has been unnecessary since the project switched over to using the build directory

Also got rid of the useless README in /build so it doesn’t appear in the built page
2023-01-07 00:59:21 +01:00
BodgeMaster 3bc870bbe0 Mark executable 2023-01-07 00:55:46 +01:00
BodgeMaster b9aa1baf9c Exclude build directory 2023-01-07 00:50:50 +01:00
BodgeMaster a13d3e1242 Make README more readable on a terminal (also fix it up in some places) 2023-01-07 00:46:41 +01:00
BodgeMaster 98596e5695 fixed #31 2021-10-30 19:19:44 +02:00
BodgeMaster 4052787790 added entries for individual files 2021-10-29 06:57:31 +02:00
BodgeMaster e1cac20414 forgot to escape things 2021-10-29 05:27:12 +02:00
BodgeMaster aec8caa11a moved favicon to a macro, fixed broken formatting wherever I found it 2021-10-29 05:05:38 +02:00
BodgeMaster 2f6b6d09de fixed indent 2021-10-29 04:54:48 +02:00
BodgeMaster be97370acb added favicon macro 2021-10-29 04:54:12 +02:00
108 changed files with 3772 additions and 1777 deletions

6
.gitignore vendored
View File

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

View File

@ -1,179 +0,0 @@
# Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
## TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

15
NOTICE
View File

@ -1,15 +0,0 @@
Copyright 2021 ThreadR Team (Jan Danielzick "BodgeMaster", [team, add your names here])
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
A copy is also provided with this software in LICENSE.md.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

156
README.md
View File

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

View File

@ -1,6 +0,0 @@
# Placeholder
This directory is here as a placeholder for the build process.
# Files
### [README.md](./README.md)
this file

View File

@ -1,22 +0,0 @@
# Files
### [about.template](./about.template)
The customizable part of the about page, HTML code for now, will eventually be replaced with a markdown file
### [instance.conf](./instance.conf)
configuration for a specific instance
- domain_name=<public facing domain name of the instance>
- threadr_dir=<directory on the webhost\*>
- db_username=<mysql user>
- db_password=<mysql password>
- db_database=<name of the database>
- db_svr_host=<mysql server address>
\* ThreadR does not support an installation on the webroot directly. See installation instructions for how to work around that.
I know some of these option names are silly but they all have the same length. -BodgeMaster
The format is strictly `<option>=<value>` because the mechanism used to load the config values is very simple. Dont add additional whitespace for fancy formatting. Things *will* break. You have been warned.
The config is loaded once on deployment by the "variable grabbler" macro processor with `["exec","echo -n \"$(sed --quiet \"/<option>=/s/.*=//p\" config/instance.conf)\""]`.
### [README.md](./README.md)
this file

View File

@ -1,25 +0,0 @@
<p>
Hello there! This is the official ThreadR instance provided by the ThreadR development team.
</p>
<h2>
What is ThreadR?
</h2>
<p>
ThreadR is a free and open-source forum engine. That means you can download
it and host an instance of ThreadR on your own web server to run your own forum.
</p>
<p>
The project originated as a school project in 2019 with the goal of building
a forum. When we finished school, the project was abandoned and eventually taken down.
A year later, we decided to revive it and started working on it again. Now that school
is over and we don&apos;t necessarily have a a reason to run our own forum anymore,
we shifted the project goal to building a FOSS forum engine.
</p>
<h2>
Who are we?
</h2>
<p>
We are a small group of (hobby) developers working on ThreadR in our free time.
To get in touch, ... uhh ... There will be a way once ThreadR is fully functional.
For now, you can find us on Discord: <a href="https://discord.gg/r3w3zSkEUE"> discord.gg/r3w3zSkEUE </a>
</p>

View File

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

View File

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

View File

@ -1,6 +0,0 @@
domain_name=threadr.lostcave.ddnss.de
threadr_dir=/threadr
db_username=webstuff
db_password=Schei// auf Pa$$w0rter!
db_database=web
db_svr_host=localhost

View File

@ -1,19 +0,0 @@
#!/bin/bash
echo "Deployment script for repository \"web-deployment\"
`date`
=============================================================================="
# activate ** globs
if [ -n "`shopt globstar | grep off`" ]; then shopt -s globstar; fi
# remove READMEs
rm ./src/**/README.md
# prepare build directory tree and static files
rm -r ./build
cp -r ./src ./build
rm ./build/**/*.{php,html,css,svg}
# run the macro handler
echo "`cd src; find -name "*.php" -or -name "*.html" -or -name "*.css" -or -name "*.svg" | sed 's/^/file=/;s/$/\; python3 variable_grabbler.py macros\/pass0\*.json src\/$file | python3 variable_grabbler.py macros\/pass1\*.json - | python3 variable_grabbler.py macros\/pass2\*.json - | python3 variable_grabbler.py macros\/pass3\*.json - > build\/$file/'`" | bash -
echo "==============================================================================
Done."

16
go.mod Normal file
View File

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

16
go.sum Normal file
View File

@ -0,0 +1,16 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=

46
handlers/about.go Normal file
View File

@ -0,0 +1,46 @@
package handlers
import (
"io/ioutil"
"log"
"net/http"
"github.com/gorilla/sessions"
"html/template"
)
func AboutHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
cookie, _ := r.Cookie("threadr_cookie_banner")
aboutContent, err := ioutil.ReadFile("config/about_page.htmlbody")
if err != nil {
log.Printf("Error reading about_page.htmlbody: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
PageData
AboutContent template.HTML
}{
PageData: PageData{
Title: "ThreadR - About",
Navbar: "about",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
AboutContent: template.HTML(aboutContent),
}
if err := app.Tmpl.ExecuteTemplate(w, "about", data); err != nil {
log.Printf("Error executing template in AboutHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

22
handlers/accept_cookie.go Normal file
View File

@ -0,0 +1,22 @@
package handlers
import (
"net/http"
"time"
)
func AcceptCookieHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "threadr_cookie_banner",
Value: "accepted",
Path: "/",
Expires: time.Now().Add(30 * 24 * time.Hour),
})
from := r.URL.Query().Get("from")
if from == "" {
from = app.Config.ThreadrDir + "/"
}
http.Redirect(w, r, from, http.StatusFound)
}
}

78
handlers/app.go Normal file
View File

@ -0,0 +1,78 @@
package handlers
import (
"context"
"database/sql"
"html/template"
"net/http"
"github.com/gorilla/sessions"
)
type PageData struct {
Title string
Navbar string
LoggedIn bool
ShowCookieBanner bool
BasePath string
StaticPath string
CurrentURL string
}
type Config struct {
DomainName string `json:"domain_name"`
ThreadrDir string `json:"threadr_dir"`
DBUsername string `json:"db_username"`
DBPassword string `json:"db_password"`
DBDatabase string `json:"db_database"`
DBServerHost string `json:"db_svr_host"`
}
type App struct {
DB *sql.DB
Store *sessions.CookieStore
Config *Config
Tmpl *template.Template
}
func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := app.Store.Get(r, "session-name")
if err != nil {
session = sessions.NewSession(app.Store, "session-name")
session.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days
HttpOnly: true,
}
}
if _, ok := session.Values["user_id"].(int); ok {
// Skip IP and User-Agent check for WebSocket connections
if r.URL.Query().Get("ws") != "true" {
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
return
}
}
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
} else {
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
}
next(w, r)
}
}
func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
if _, ok := session.Values["user_id"].(int); !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
return
}
next(w, r)
}
}

126
handlers/board.go Normal file
View File

@ -0,0 +1,126 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
)
func BoardHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
userID, _ := session.Values["user_id"].(int)
cookie, _ := r.Cookie("threadr_cookie_banner")
boardIDStr := r.URL.Query().Get("id")
boardID, err := strconv.Atoi(boardIDStr)
if err != nil {
http.Error(w, "Invalid board ID", http.StatusBadRequest)
return
}
board, err := models.GetBoardByID(app.DB, boardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board == nil {
http.Error(w, "Board not found", http.StatusNotFound)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermViewBoard)
if err != nil {
log.Printf("Error checking permission: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !hasPerm {
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
return
}
}
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "create_thread" {
title := r.FormValue("title")
if title == "" {
http.Error(w, "Thread title is required", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermPostInBoard)
if err != nil {
log.Printf("Error checking permission: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !hasPerm {
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
return
}
}
thread := models.Thread{
BoardID: boardID,
Title: title,
CreatedByUserID: userID,
}
err = models.CreateThread(app.DB, thread)
if err != nil {
log.Printf("Error creating thread: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
var threadID int
err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&threadID)
if err != nil {
log.Printf("Error getting last insert id: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+strconv.Itoa(threadID), http.StatusFound)
return
}
}
threads, err := models.GetThreadsByBoardID(app.DB, boardID)
if err != nil {
log.Printf("Error fetching threads: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
PageData
Board models.Board
Threads []models.Thread
}{
PageData: PageData{
Title: "ThreadR - " + board.Name,
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Board: *board,
Threads: threads,
}
if err := app.Tmpl.ExecuteTemplate(w, "board", data); err != nil {
log.Printf("Error executing template in BoardHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

106
handlers/boards.go Normal file
View File

@ -0,0 +1,106 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
)
func BoardsHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
cookie, _ := r.Cookie("threadr_cookie_banner")
userID, _ := session.Values["user_id"].(int)
isAdmin := false
if loggedIn {
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user: %v", err)
} else if user != nil {
isAdmin = models.HasGlobalPermission(user, models.PermCreateBoard)
}
}
if r.Method == http.MethodPost && loggedIn && isAdmin {
name := r.FormValue("name")
description := r.FormValue("description")
if name == "" {
http.Error(w, "Board name is required", http.StatusBadRequest)
return
}
board := models.Board{
Name: name,
Description: description,
Private: false,
PublicVisible: true,
}
query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)"
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible)
if err != nil {
log.Printf("Error creating board: %v", err)
http.Error(w, "Failed to create board", http.StatusInternalServerError)
return
}
boardID, _ := result.LastInsertId()
http.Redirect(w, r, app.Config.ThreadrDir+"/board/?id="+strconv.FormatInt(boardID, 10), http.StatusFound)
return
}
publicBoards, err := models.GetAllBoards(app.DB, false)
if err != nil {
log.Printf("Error fetching public boards: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
var privateBoards []models.Board
if loggedIn {
privateBoards, err = models.GetAllBoards(app.DB, true)
if err != nil {
log.Printf("Error fetching private boards: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
var accessiblePrivateBoards []models.Board
for _, board := range privateBoards {
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
if err != nil {
log.Printf("Error checking permission: %v", err)
continue
}
if hasPerm {
accessiblePrivateBoards = append(accessiblePrivateBoards, board)
}
}
privateBoards = accessiblePrivateBoards
}
data := struct {
PageData
PublicBoards []models.Board
PrivateBoards []models.Board
IsAdmin bool
}{
PageData: PageData{
Title: "ThreadR - Boards",
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
PublicBoards: publicBoards,
PrivateBoards: privateBoards,
IsAdmin: isAdmin,
}
if err := app.Tmpl.ExecuteTemplate(w, "boards", data); err != nil {
log.Printf("Error executing template in BoardsHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

188
handlers/chat.go Normal file
View File

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

34
handlers/home.go Normal file
View File

@ -0,0 +1,34 @@
package handlers
import (
"log"
"net/http"
"path/filepath"
"github.com/gorilla/sessions"
)
func HomeHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
cookie, _ := r.Cookie("threadr_cookie_banner")
data := struct {
PageData
}{
PageData: PageData{
Title: "ThreadR - Home",
Navbar: "home",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: filepath.Join(app.Config.ThreadrDir, "static"),
CurrentURL: r.URL.String(),
},
}
if err := app.Tmpl.ExecuteTemplate(w, "home", data); err != nil {
log.Printf("Error executing template in HomeHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

78
handlers/like.go Normal file
View File

@ -0,0 +1,78 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
)
func LikeHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
postIDStr := r.FormValue("post_id")
postID, err := strconv.Atoi(postIDStr)
if err != nil {
http.Error(w, "Invalid post ID", http.StatusBadRequest)
return
}
likeType := r.FormValue("type")
if likeType != "like" && likeType != "dislike" {
http.Error(w, "Invalid like type", http.StatusBadRequest)
return
}
existingLike, err := models.GetLikeByPostAndUser(app.DB, postID, userID)
if err != nil {
log.Printf("Error checking existing like: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if existingLike != nil {
if existingLike.Type == likeType {
err = models.DeleteLike(app.DB, postID, userID)
if err != nil {
log.Printf("Error deleting like: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
} else {
err = models.UpdateLikeType(app.DB, postID, userID, likeType)
if err != nil {
log.Printf("Error updating like: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
} else {
like := models.Like{
PostID: postID,
UserID: userID,
Type: likeType,
}
err = models.CreateLike(app.DB, like)
if err != nil {
log.Printf("Error creating like: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
}

68
handlers/login.go Normal file
View File

@ -0,0 +1,68 @@
package handlers
import (
"database/sql"
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
func LoginHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
if r.Method == http.MethodPost {
username := r.FormValue("username")
password := r.FormValue("password")
user, err := models.GetUserByUsername(app.DB, username)
if err != nil && err != sql.ErrNoRows {
log.Printf("Error fetching user in LoginHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound)
return
}
session.Values["user_id"] = user.ID
session.Values["user_ip"] = r.RemoteAddr
session.Values["user_agent"] = r.UserAgent()
session.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days
HttpOnly: true,
}
if err := session.Save(r, w); err != nil {
log.Printf("Error saving session: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound)
return
}
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Login",
Navbar: "login",
LoggedIn: false,
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "",
}
if r.URL.Query().Get("error") == "invalid" {
data.Error = "Invalid username or password"
}
if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil {
log.Printf("Error executing template in LoginHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

21
handlers/logout.go Normal file
View File

@ -0,0 +1,21 @@
package handlers
import (
"log"
"net/http"
"github.com/gorilla/sessions"
)
func LogoutHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1
if err := session.Save(r, w); err != nil {
log.Printf("Error saving session in LogoutHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/", http.StatusFound)
}
}

98
handlers/news.go Normal file
View File

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

55
handlers/profile.go Normal file
View File

@ -0,0 +1,55 @@
package handlers
import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
func ProfileHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user in ProfileHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
displayName := user.DisplayName
if displayName == "" {
displayName = user.Username
}
data := struct {
PageData
User models.User
DisplayName string
}{
PageData: PageData{
Title: "ThreadR - Profile",
Navbar: "profile",
LoggedIn: true,
ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
User: *user,
DisplayName: displayName,
}
if err := app.Tmpl.ExecuteTemplate(w, "profile", data); err != nil {
log.Printf("Error executing template in ProfileHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

65
handlers/profile_edit.go Normal file
View File

@ -0,0 +1,65 @@
package handlers
import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
func ProfileEditHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
if r.Method == http.MethodPost {
displayName := r.FormValue("display_name")
pfpURL := r.FormValue("pfp_url")
bio := r.FormValue("bio")
err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, bio)
if err != nil {
log.Printf("Error updating profile: %v", err)
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound)
return
}
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
data := struct {
PageData
User models.User
}{
PageData: PageData{
Title: "ThreadR - Edit Profile",
Navbar: "profile",
LoggedIn: true,
ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
User: *user,
}
if err := app.Tmpl.ExecuteTemplate(w, "profile_edit", data); err != nil {
log.Printf("Error executing template in ProfileEditHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

66
handlers/signup.go Normal file
View File

@ -0,0 +1,66 @@
package handlers
import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
func SignupHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost {
username := r.FormValue("username")
password := r.FormValue("password")
err := models.CreateUser(app.DB, username, password)
if err != nil {
log.Printf("Error creating user: %v", err)
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: false,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "An error occurred during sign up. Please try again.",
}
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
log.Printf("Error executing template in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
data := struct {
PageData
Error string
}{
PageData: PageData{
Title: "ThreadR - Sign Up",
Navbar: "signup",
LoggedIn: session.Values["user_id"] != nil,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Error: "",
}
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
log.Printf("Error executing template in SignupHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

135
handlers/thread.go Normal file
View File

@ -0,0 +1,135 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
)
func ThreadHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
userID, _ := session.Values["user_id"].(int)
cookie, _ := r.Cookie("threadr_cookie_banner")
threadIDStr := r.URL.Query().Get("id")
threadID, err := strconv.Atoi(threadIDStr)
if err != nil {
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
return
}
thread, err := models.GetThreadByID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching thread: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if thread == nil {
http.Error(w, "Thread not found", http.StatusNotFound)
return
}
board, err := models.GetBoardByID(app.DB, thread.BoardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
if err != nil {
log.Printf("Error checking permission: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !hasPerm {
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
return
}
}
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "submit" {
content := r.FormValue("content")
replyToStr := r.URL.Query().Get("to")
replyTo := -1
if replyToStr != "" {
replyTo, err = strconv.Atoi(replyToStr)
if err != nil {
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
return
}
}
if content == "" {
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
if err != nil {
log.Printf("Error checking permission: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !hasPerm {
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
return
}
}
post := models.Post{
ThreadID: threadID,
UserID: userID,
Content: content,
ReplyTo: replyTo,
}
err = models.CreatePost(app.DB, post)
if err != nil {
log.Printf("Error creating post: %v", err)
http.Error(w, "Failed to create post", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
return
}
}
posts, err := models.GetPostsByThreadID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching posts: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
PageData
Thread models.Thread
Posts []models.Post
}{
PageData: PageData{
Title: "ThreadR - " + thread.Title,
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Thread: *thread,
Posts: posts,
}
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
log.Printf("Error executing template in ThreadHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

49
handlers/userhome.go Normal file
View File

@ -0,0 +1,49 @@
package handlers
import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
func UserHomeHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
user, err := models.GetUserByID(app.DB, userID)
if err != nil {
log.Printf("Error fetching user in UserHomeHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
data := struct {
PageData
Username string
}{
PageData: PageData{
Title: "ThreadR - User Home",
Navbar: "userhome",
LoggedIn: true,
ShowCookieBanner: false,
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Username: user.Username,
}
if err := app.Tmpl.ExecuteTemplate(w, "userhome", data); err != nil {
log.Printf("Error executing template in UserHomeHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

3
legacy/README.md Normal file
View File

@ -0,0 +1,3 @@
# Legacy
I just wanna keep the macro handler "variable grabbler" from the old ThreadR before the rewrite. It has proven quite useful in other contexts, might as well keep it in case we need it. -BodgeMaster

View File

@ -1,29 +0,0 @@
# Files
### [[DIR] templates](./templates)
contains the templates used by the macro processor
### [pass0_templates.json](./pass0_templates.json)
Macros that insert templates
- `%NAVBAR%` the navigation bar at the top of the website (requires PHP)
- `%BANNER_COOKIES%` the cookie banner (requires PHP)
### [pass1_misc.json](./pass1_misc.json)
Miscellaneous macros
- `%STYLESHEET%` links stylesheet (HTML <head> section)
- `%PLEAZE_NO_CACHE%` tell the browser not to cache this page (PHP, before any data is sent to the client)
- `%COLOR_BEIGE%`, `%COLOR_ORANGE%`, `%COLOR_BLUE%`, `%COLOR_PINK%`, `%COLOR_CYAN%` color codes, format: #XXXXXX
### [pass2_session.json](./pass2_session.json)
Session handling macros
- `%REQUIRE_LOGIN%` go to login page if not logged in
- `%NO_CHEAP_SESSION_STEALING%` make sure that IP and user agent stay the same, otherwise end session
- `%SET_LOGIN_VARIABLE%` sets the variable $login based on whether the user is logged in
- `%FORCE_LOGOUT%` end session right here and now
### [pass3_install-config.json](./pass3_install-config.json)
Macros that grab the configuration values and inject them into the instance
- `%DOMAIN_NAME%` the domain name of the instance
- `%CONTENT_DIR%` the directory of the ThreadR home page on the webhost
- `%DB_PASSWORD%` password for the MySQL server
- `%DB_USERNAME%` username for the MySQL server
- `%DB_NAME%` name of the database to use
- `%DB_SERVER%` address of the mysql server
- `%ABOUT_PAGE%` the template for the about page
### [README.md](./README.md)
this file

View File

@ -1,4 +0,0 @@
{
"NAVBAR":["file","macros/templates/navbar.template"],
"BANNER_COOKIES":["file","macros/templates/banner_cookies.template"]
}

View File

@ -1,5 +0,0 @@
{
"STYLESHEET":"<link rel=\"stylesheet\" type=\"text\/css\" href=\"%CONTENT_DIR%\/style.css\">",
"PLEAZE_NO_CACHE":"header('Cache-Control: no-cache, no-store, must-revalidate');header('Pragma: no-cache');header('Expires: 0');",
"COLOR_BEIGE":"#fef6e4", "COLOR_ORANGE":"#f3d2c1", "COLOR_BLUE":"#001858", "COLOR_PINK":"#f582ae", "COLOR_CYAN":"#8bd3dd"
}

View File

@ -1,6 +0,0 @@
{
"REQUIRE_LOGIN":"if (!$login) { header(\"Location: https:\/\/%DOMAIN_NAME%%CONTENT_DIR%\/login\/\\?error=session\"); die(); }",
"NO_CHEAP_SESSION_STEALING":"if (isset($_SESSION['user_id'])) {if ($_SESSION['user_ip']!=$_SERVER['REMOTE_ADDR'] || $_SESSION['user_http_user_agent']!=$_SERVER['HTTP_USER_AGENT']){ $_SESSION = array(); if (ini_get(\"session.use_cookies\")){ $params = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $params[\"path\"], $params[\"domain\"], $params[\"secure\"], $params[\"httponly\"]); } session_destroy(); header(\"Location: https://%DOMAIN_NAME%%CONTENT_DIR%/login/\\?error=session\"); die();}}",
"SET_LOGIN_VARIABLE":"if (isset($_SESSION['user_id'])) { $login = true; } else { $login = false; }",
"FORCE_LOGOUT":"$_SESSION = array(); if (ini_get('session.use_cookies')) { $params = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);} session_destroy();"
}

View File

@ -1,9 +0,0 @@
{
"DOMAIN_NAME":["exec","echo -n \"$(sed --quiet \"/domain_name=/s/.*=//p\" config/instance.conf)\""],
"CONTENT_DIR":["exec","echo -n \"$(sed --quiet \"/threadr_dir=/s/.*=//p\" config/instance.conf)\""],
"DB_PASSWORD":["exec","echo -n \"$(sed --quiet \"/db_password=/s/.*=//p\" config/instance.conf)\""],
"DB_USERNAME":["exec","echo -n \"$(sed --quiet \"/db_username=/s/.*=//p\" config/instance.conf)\""],
"DB_NAME" :["exec","echo -n \"$(sed --quiet \"/db_database=/s/.*=//p\" config/instance.conf)\""],
"DB_SERVER" :["exec","echo -n \"$(sed --quiet \"/db_svr_host=/s/.*=//p\" config/instance.conf)\""],
"ABOUT_PAGE":["file","config/about.template"]
}

View File

@ -1,7 +0,0 @@
# Files
### [navbar.template](./navbar.template)
contains the navbar, depends on a variable in PHP to determine which field on the navbar should be highlighted
### [banner_cookies.template](./banner_cookies.template)
contains the cookie banner
### [README.md](./README.md)
this file

View File

@ -1,9 +0,0 @@
<?php
if (!isset($_COOKIE['threadr_cookie_banner'])) {
if (!$login){
echo "<div class='banner'><p class='beige'>We neeeeed Cookies. They taste sooo good. *crunch*</p><p class='beige'>Seriously, this site is in development. You should not be here. If you want to stay anyways, you will have to accept our cookies.</p><a href='%CONTENT_DIR%/accept_cookie.php?from=" . urlencode($_SERVER['REQUEST_URI']) . "'><button> OK </button></a></div>";
} else {
// assume that the user knows our cookie/privacy policy, force set cookie in this case
}
}
?>

View File

@ -1,77 +0,0 @@
<?php
if (!isset($login)){
$login=false;
%FORCE_LOGOUT%
}
?>
<ul class="topnav">
<li>
<?php
echo "<a class=\"icon ";
if ($login){
if ($navbar == "home"){
echo "active\" href=\"%CONTENT_DIR%/userhome/\"><img src=\"%CONTENT_DIR%/img/ThreadR_Home.svg\" alt=\"My Feed\" title=\"My Feed\"";
} else {
echo "\" href=\"%CONTENT_DIR%/userhome/\"><img src=\"%CONTENT_DIR%/img/ThreadR_Home.svg\" alt=\"My Feed\" title=\"My Feed\"";
}
} else {
if ($navbar == "home"){
echo "active\" href=\"%CONTENT_DIR%/\"><img src=\"%CONTENT_DIR%/img/ThreadR_Home.svg\" alt=\"Home\" title=\"Home\"";
} else {
echo "\" href=\"%CONTENT_DIR%/\"><img src=\"%CONTENT_DIR%/img/ThreadR_Home.svg\" alt=\"Home\" title=\"Home\"";
}
}
echo "/></a>";
?>
</li>
<li>
<?php
if ($navbar == "news"){
echo "<a class=\"active\" href=\"%CONTENT_DIR%/news/\">News</a>";
} else {
echo "<a href=\"%CONTENT_DIR%/news/\">News</a>";
}
?>
</li>
<li>
<?php
if ($navbar == "boards"){
echo "<a class=\"active\" href=\"%CONTENT_DIR%/boards/\">Boards</a>";
} else {
echo "<a href=\"%CONTENT_DIR%/boards/\">Boards</a>";
}
?>
</li>
<li>
<?php
if ($navbar == "about"){
echo "<a class=\"active\" href=\"%CONTENT_DIR%/about/\">About</a>";
} else {
echo "<a href=\"%CONTENT_DIR%/about/\">About</a>";
}
?>
</li>
<?php
if ($login) {
echo "<li class=\"dropdown right\"><button class=\"dropbtn\"> Put user avatar here </button><div class=\"dropdown-content\"><a href=\"%CONTENT_DIR%/logout/\">Log out</a>";
if ($navbar == "profile") {
echo "<a class=\"active\" href=\"%CONTENT_DIR%/profile/\">Profile</a>";
} else {
echo "<a href=\"%CONTENT_DIR%/profile/\">Profile</a>";
}
echo "</div></li>";
} else {
echo "<li class=\"right\">";
if ($navbar == "login") {
echo "<a class=\"active\" href=\"%CONTENT_DIR%/login/\">Log in</a>";
} else {
echo "<a href=\"%CONTENT_DIR%/login/\">Log in</a>";
}
}
?>
</ul>
<div class="topnav"></div>

366
main.go Normal file
View File

@ -0,0 +1,366 @@
package main
import (
"bufio"
"database/sql"
"encoding/json"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"threadr/handlers"
"threadr/models"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/sessions"
"golang.org/x/term"
)
func loadConfig(filename string) (*handlers.Config, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
var config handlers.Config
err = json.NewDecoder(file).Decode(&config)
return &config, err
}
func createTablesIfNotExist(db *sql.DB) error {
// Create boards table
_, err := db.Exec(`
CREATE TABLE boards (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
private BOOLEAN DEFAULT FALSE,
public_visible BOOLEAN DEFAULT TRUE,
pinned_threads TEXT,
custom_landing_page TEXT,
color_scheme VARCHAR(255)
)`)
if err != nil {
return fmt.Errorf("error creating boards table: %v", err)
}
// Create users table
_, err = db.Exec(`
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
pfp_url VARCHAR(255),
bio TEXT,
authentication_string VARCHAR(128) NOT NULL,
authentication_salt VARCHAR(255) NOT NULL,
authentication_algorithm VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
permissions BIGINT DEFAULT 0
)`)
if err != nil {
return fmt.Errorf("error creating users table: %v", err)
}
// Create threads table (without type field)
_, err = db.Exec(`
CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by_user_id INT NOT NULL,
accepted_answer_post_id INT,
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating threads table: %v", err)
}
// Create posts table
_, err = db.Exec(`
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
user_id INT NOT NULL,
post_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
edit_time TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
content TEXT,
attachment_hash BIGINT,
attachment_name VARCHAR(255),
title VARCHAR(255),
reply_to INT DEFAULT -1,
FOREIGN KEY (thread_id) REFERENCES threads(id)
)`)
if err != nil {
return fmt.Errorf("error creating posts table: %v", err)
}
// Create likes table
_, err = db.Exec(`
CREATE TABLE likes (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
user_id INT NOT NULL,
type VARCHAR(20) NOT NULL,
UNIQUE KEY unique_like (post_id, user_id),
FOREIGN KEY (post_id) REFERENCES posts(id)
)`)
if err != nil {
return fmt.Errorf("error creating likes table: %v", err)
}
// Create board_permissions table
_, err = db.Exec(`
CREATE TABLE board_permissions (
user_id INT NOT NULL,
board_id INT NOT NULL,
permissions BIGINT DEFAULT 0,
PRIMARY KEY (user_id, board_id),
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating board_permissions table: %v", err)
}
// Create notifications table
_, err = db.Exec(`
CREATE TABLE notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type VARCHAR(50) NOT NULL,
related_id INT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating notifications table: %v", err)
}
// Create reactions table
_, err = db.Exec(`
CREATE TABLE reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
user_id INT NOT NULL,
emoji VARCHAR(10) NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(id)
)`)
if err != nil {
return fmt.Errorf("error creating reactions table: %v", err)
}
// Create reposts table
_, err = db.Exec(`
CREATE TABLE reposts (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
board_id INT NOT NULL,
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (thread_id) REFERENCES threads(id),
FOREIGN KEY (board_id) REFERENCES boards(id)
)`)
if err != nil {
return fmt.Errorf("error creating reposts table: %v", err)
}
// Create news table
_, err = db.Exec(`
CREATE TABLE news (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
posted_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating news table: %v", err)
}
// Create chat_messages table
_, err = db.Exec(`
CREATE TABLE chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
content TEXT NOT NULL,
reply_to INT DEFAULT -1,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("error creating chat_messages table: %v", err)
}
log.Println("Database tables created.")
return nil
}
func ensureAdminUser(db *sql.DB) error {
reader := bufio.NewReader(os.Stdin)
// Get username
fmt.Print("Enter admin username: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
// Check if user already exists
existingUser, err := models.GetUserByUsername(db, username)
if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("error checking for admin user: %v", err)
}
if existingUser != nil {
return fmt.Errorf("user '%s' already exists", username)
}
// Get password
fmt.Print("Enter admin password: ")
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("error reading password: %v", err)
}
password := string(bytePassword)
fmt.Println() // Newline after password input
// Confirm password
fmt.Print("Confirm admin password: ")
bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("error reading password confirmation: %v", err)
}
passwordConfirm := string(bytePasswordConfirm)
fmt.Println()
if password != passwordConfirm {
return fmt.Errorf("passwords do not match")
}
// Create user
log.Printf("Creating admin user: %s", username)
err = models.CreateUser(db, username, password)
if err != nil {
return fmt.Errorf("error creating admin user: %v", err)
}
// Get the newly created admin user to update permissions
adminUser, err := models.GetUserByUsername(db, username)
if err != nil {
return fmt.Errorf("error fetching new admin user: %v", err)
}
// Set admin permissions (all permissions)
_, err = db.Exec("UPDATE users SET permissions = ? WHERE id = ?",
models.PermCreateBoard|models.PermManageUsers, adminUser.ID)
if err != nil {
return fmt.Errorf("error setting admin permissions: %v", err)
}
log.Println("Admin user created successfully with full permissions")
return nil
}
func main() {
// Define command-line flag for initialization
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
flag.BoolVar(initialize, "i", false, "Short for --initialize")
flag.Parse()
config, err := loadConfig("config/config.json")
if err != nil {
log.Fatal("Error loading config:", err)
}
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Error connecting to database:", err)
}
defer db.Close()
// Perform initialization if the flag is set
if *initialize {
log.Println("Initializing database...")
err = createTablesIfNotExist(db)
if err != nil {
log.Fatal("Error creating database tables:", err)
}
err = ensureAdminUser(db)
if err != nil {
log.Fatal("Error ensuring admin user:", err)
}
log.Println("Initialization completed successfully. Exiting.")
return
}
// Normal startup (without automatic table creation)
log.Println("Starting ThreadR server...")
dir, err := os.Getwd()
if err != nil {
log.Fatal("Error getting working directory:", err)
}
// Parse partial templates
tmpl := template.Must(template.ParseFiles(
filepath.Join(dir, "templates/partials/navbar.html"),
filepath.Join(dir, "templates/partials/cookie_banner.html"),
))
// Parse page-specific templates with unique names
tmpl, err = tmpl.ParseFiles(
filepath.Join(dir, "templates/pages/about.html"),
filepath.Join(dir, "templates/pages/board.html"),
filepath.Join(dir, "templates/pages/boards.html"),
filepath.Join(dir, "templates/pages/home.html"),
filepath.Join(dir, "templates/pages/login.html"),
filepath.Join(dir, "templates/pages/news.html"),
filepath.Join(dir, "templates/pages/profile.html"),
filepath.Join(dir, "templates/pages/profile_edit.html"),
filepath.Join(dir, "templates/pages/signup.html"),
filepath.Join(dir, "templates/pages/thread.html"),
filepath.Join(dir, "templates/pages/userhome.html"),
filepath.Join(dir, "templates/pages/chat.html"),
)
if err != nil {
log.Fatal("Error parsing page templates:", err)
}
store := sessions.NewCookieStore([]byte("secret-key")) // Replace with secure key in production
app := &handlers.App{
DB: db,
Store: store,
Config: config,
Tmpl: tmpl,
}
fs := http.FileServer(http.Dir("static"))
http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs))
http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app)))
http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app)))
http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app)))
http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app))))
http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app)))
http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app)))
http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app)))
http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app)))
http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app))))
http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app))))
http.HandleFunc(config.ThreadrDir+"/like/", app.SessionMW(app.RequireLoginMW(handlers.LikeHandler(app))))
http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(app)))
http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app)))
http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app)))
http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app))))
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

101
models/board.go Normal file
View File

@ -0,0 +1,101 @@
package models
import (
"database/sql"
"encoding/json"
)
type Board struct {
ID int
Name string
Description string
Private bool
PublicVisible bool
PinnedThreads []int // Stored as JSON
CustomLandingPage string
ColorScheme string
}
func GetBoardByID(db *sql.DB, id int) (*Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?"
row := db.QueryRow(query, id)
board := &Board{}
var pinnedThreadsJSON sql.NullString
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if description.Valid {
board.Description = description.String
} else {
board.Description = ""
}
if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" {
err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads)
if err != nil {
return nil, err
}
}
if customLandingPage.Valid {
board.CustomLandingPage = customLandingPage.String
} else {
board.CustomLandingPage = ""
}
if colorScheme.Valid {
board.ColorScheme = colorScheme.String
} else {
board.ColorScheme = ""
}
return board, nil
}
func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC"
rows, err := db.Query(query, private)
if err != nil {
return nil, err
}
defer rows.Close()
var boards []Board
for rows.Next() {
board := Board{}
var pinnedThreadsJSON sql.NullString
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
if err != nil {
return nil, err
}
if description.Valid {
board.Description = description.String
} else {
board.Description = ""
}
if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" {
err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads)
if err != nil {
return nil, err
}
}
if customLandingPage.Valid {
board.CustomLandingPage = customLandingPage.String
} else {
board.CustomLandingPage = ""
}
if colorScheme.Valid {
board.ColorScheme = colorScheme.String
} else {
board.ColorScheme = ""
}
boards = append(boards, board)
}
return boards, nil
}

View File

@ -0,0 +1,46 @@
package models
import "database/sql"
type BoardPermission struct {
UserID int
BoardID int
Permissions int64
}
func GetBoardPermission(db *sql.DB, userID, boardID int) (*BoardPermission, error) {
query := "SELECT user_id, board_id, permissions FROM board_permissions WHERE user_id = ? AND board_id = ?"
row := db.QueryRow(query, userID, boardID)
bp := &BoardPermission{}
err := row.Scan(&bp.UserID, &bp.BoardID, &bp.Permissions)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return bp, nil
}
func SetBoardPermission(db *sql.DB, bp BoardPermission) error {
query := "INSERT INTO board_permissions (user_id, board_id, permissions) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE permissions = ?"
_, err := db.Exec(query, bp.UserID, bp.BoardID, bp.Permissions, bp.Permissions)
return err
}
const (
PermPostInBoard int64 = 1 << 0
PermModerateBoard int64 = 1 << 1
PermViewBoard int64 = 1 << 2
)
func HasBoardPermission(db *sql.DB, userID, boardID int, perm int64) (bool, error) {
bp, err := GetBoardPermission(db, userID, boardID)
if err != nil {
return false, err
}
if bp == nil {
return false, nil
}
return bp.Permissions&perm != 0, nil
}

132
models/chat.go Normal file
View File

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

62
models/like.go Normal file
View File

@ -0,0 +1,62 @@
package models
import "database/sql"
type Like struct {
ID int
PostID int
UserID int
Type string // "like" or "dislike"
}
func GetLikesByPostID(db *sql.DB, postID int) ([]Like, error) {
query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ?"
rows, err := db.Query(query, postID)
if err != nil {
return nil, err
}
defer rows.Close()
var likes []Like
for rows.Next() {
like := Like{}
err := rows.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
if err != nil {
return nil, err
}
likes = append(likes, like)
}
return likes, nil
}
func GetLikeByPostAndUser(db *sql.DB, postID, userID int) (*Like, error) {
query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ? AND user_id = ?"
row := db.QueryRow(query, postID, userID)
like := &Like{}
err := row.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return like, nil
}
func CreateLike(db *sql.DB, like Like) error {
query := "INSERT INTO likes (post_id, user_id, type) VALUES (?, ?, ?)"
_, err := db.Exec(query, like.PostID, like.UserID, like.Type)
return err
}
func UpdateLikeType(db *sql.DB, postID, userID int, likeType string) error {
query := "UPDATE likes SET type = ? WHERE post_id = ? AND user_id = ?"
_, err := db.Exec(query, likeType, postID, userID)
return err
}
func DeleteLike(db *sql.DB, postID, userID int) error {
query := "DELETE FROM likes WHERE post_id = ? AND user_id = ?"
_, err := db.Exec(query, postID, userID)
return err
}

53
models/news.go Normal file
View File

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

49
models/notification.go Normal file
View File

@ -0,0 +1,49 @@
package models
import (
"database/sql"
"time"
)
type Notification struct {
ID int
UserID int
Type string
RelatedID int
CreatedAt time.Time
Read bool
}
func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) {
query := "SELECT id, user_id, type, related_id, read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC"
rows, err := db.Query(query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var notifications []Notification
for rows.Next() {
notification := Notification{}
err := rows.Scan(&notification.ID, &notification.UserID, &notification.Type, &notification.RelatedID, &notification.Read, &notification.CreatedAt)
if err != nil {
return nil, err
}
notifications = append(notifications, notification)
}
return notifications, nil
}
// Stubbed for future implementation
func CreateNotification(db *sql.DB, notification Notification) error {
query := "INSERT INTO notifications (user_id, type, related_id, read, created_at) VALUES (?, ?, ?, ?, NOW())"
_, err := db.Exec(query, notification.UserID, notification.Type, notification.RelatedID, notification.Read)
return err
}
// Stubbed for future implementation
func MarkNotificationAsRead(db *sql.DB, id int) error {
query := "UPDATE notifications SET read = true WHERE id = ?"
_, err := db.Exec(query, id)
return err
}

61
models/post.go Normal file
View File

@ -0,0 +1,61 @@
package models
import (
"database/sql"
"time"
)
type Post struct {
ID int
ThreadID int
UserID int
PostTime time.Time
EditTime *time.Time
Content string
AttachmentHash *int64
AttachmentName *string
Title string
ReplyTo int
}
func GetPostsByThreadID(db *sql.DB, threadID int) ([]Post, error) {
query := "SELECT id, thread_id, user_id, post_time, edit_time, content, attachment_hash, attachment_name, title, reply_to FROM posts WHERE thread_id = ? ORDER BY post_time ASC"
rows, err := db.Query(query, threadID)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []Post
for rows.Next() {
post := Post{}
var postTimeStr string
var editTimeStr sql.NullString
err := rows.Scan(&post.ID, &post.ThreadID, &post.UserID, &postTimeStr, &editTimeStr, &post.Content, &post.AttachmentHash, &post.AttachmentName, &post.Title, &post.ReplyTo)
if err != nil {
return nil, err
}
post.PostTime, err = time.Parse("2006-01-02 15:04:05", postTimeStr)
if err != nil {
post.PostTime = time.Time{}
}
if editTimeStr.Valid {
editTime, err := time.Parse("2006-01-02 15:04:05", editTimeStr.String)
if err != nil {
post.EditTime = nil
} else {
post.EditTime = &editTime
}
} else {
post.EditTime = nil
}
posts = append(posts, post)
}
return posts, nil
}
func CreatePost(db *sql.DB, post Post) error {
query := "INSERT INTO posts (thread_id, user_id, content, title, reply_to, post_time) VALUES (?, ?, ?, ?, ?, NOW())"
_, err := db.Exec(query, post.ThreadID, post.UserID, post.Content, post.Title, post.ReplyTo)
return err
}

44
models/reaction.go Normal file
View File

@ -0,0 +1,44 @@
package models
import "database/sql"
type Reaction struct {
ID int
PostID int
UserID int
Emoji string
}
func GetReactionsByPostID(db *sql.DB, postID int) ([]Reaction, error) {
query := "SELECT id, post_id, user_id, emoji FROM reactions WHERE post_id = ?"
rows, err := db.Query(query, postID)
if err != nil {
return nil, err
}
defer rows.Close()
var reactions []Reaction
for rows.Next() {
reaction := Reaction{}
err := rows.Scan(&reaction.ID, &reaction.PostID, &reaction.UserID, &reaction.Emoji)
if err != nil {
return nil, err
}
reactions = append(reactions, reaction)
}
return reactions, nil
}
// Stubbed for future implementation
func CreateReaction(db *sql.DB, reaction Reaction) error {
query := "INSERT INTO reactions (post_id, user_id, emoji) VALUES (?, ?, ?)"
_, err := db.Exec(query, reaction.PostID, reaction.UserID, reaction.Emoji)
return err
}
// Stubbed for future implementation
func DeleteReaction(db *sql.DB, postID, userID int, emoji string) error {
query := "DELETE FROM reactions WHERE post_id = ? AND user_id = ? AND emoji = ?"
_, err := db.Exec(query, postID, userID, emoji)
return err
}

41
models/repost.go Normal file
View File

@ -0,0 +1,41 @@
package models
import (
"database/sql"
"time"
)
type Repost struct {
ID int
ThreadID int
BoardID int
UserID int
CreatedAt time.Time
}
func GetRepostsByThreadID(db *sql.DB, threadID int) ([]Repost, error) {
query := "SELECT id, thread_id, board_id, user_id, created_at FROM reposts WHERE thread_id = ?"
rows, err := db.Query(query, threadID)
if err != nil {
return nil, err
}
defer rows.Close()
var reposts []Repost
for rows.Next() {
repost := Repost{}
err := rows.Scan(&repost.ID, &repost.ThreadID, &repost.BoardID, &repost.UserID, &repost.CreatedAt)
if err != nil {
return nil, err
}
reposts = append(reposts, repost)
}
return reposts, nil
}
// Stubbed for future implementation
func CreateRepost(db *sql.DB, repost Repost) error {
query := "INSERT INTO reposts (thread_id, board_id, user_id, created_at) VALUES (?, ?, ?, NOW())"
_, err := db.Exec(query, repost.ThreadID, repost.BoardID, repost.UserID)
return err
}

76
models/thread.go Normal file
View File

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

161
models/user.go Normal file
View File

@ -0,0 +1,161 @@
package models
import (
"crypto/sha256"
"database/sql"
"fmt"
"time"
)
type User struct {
ID int
Username string
DisplayName string
PfpURL string
Bio string
AuthenticationString string
AuthenticationSalt string
AuthenticationAlgorithm string
CreatedAt time.Time
UpdatedAt time.Time
Verified bool
Permissions int64
}
func GetUserByID(db *sql.DB, id int) (*User, error) {
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?"
row := db.QueryRow(query, id)
user := &User{}
var displayName sql.NullString
var pfpURL sql.NullString
var bio sql.NullString
var createdAtString sql.NullString
var updatedAtString sql.NullString
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if displayName.Valid {
user.DisplayName = displayName.String
} else {
user.DisplayName = ""
}
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid {
user.Bio = bio.String
} else {
user.Bio = ""
}
if createdAtString.Valid {
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing created_at: %v", err)
}
} else {
user.CreatedAt = time.Time{}
}
if updatedAtString.Valid {
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing updated_at: %v", err)
}
} else {
user.UpdatedAt = time.Time{}
}
return user, nil
}
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?"
row := db.QueryRow(query, username)
user := &User{}
var displayName sql.NullString
var pfpURL sql.NullString
var bio sql.NullString
var createdAtString sql.NullString
var updatedAtString sql.NullString
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
if err != nil {
return nil, err
}
if displayName.Valid {
user.DisplayName = displayName.String
} else {
user.DisplayName = ""
}
if pfpURL.Valid {
user.PfpURL = pfpURL.String
} else {
user.PfpURL = ""
}
if bio.Valid {
user.Bio = bio.String
} else {
user.Bio = ""
}
if createdAtString.Valid {
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing created_at: %v", err)
}
} else {
user.CreatedAt = time.Time{}
}
if updatedAtString.Valid {
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
if err != nil {
return nil, fmt.Errorf("error parsing updated_at: %v", err)
}
} else {
user.UpdatedAt = time.Time{}
}
return user, nil
}
func CheckPassword(password, salt, algorithm, hash string) bool {
if algorithm != "sha256" {
return false
}
computedHash := HashPassword(password, salt, algorithm)
return computedHash == hash
}
func HashPassword(password, salt, algorithm string) string {
if algorithm != "sha256" {
return ""
}
data := password + salt
hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash)
}
func CreateUser(db *sql.DB, username, password string) error {
salt := "random-salt" // Replace with secure random generation
algorithm := "sha256"
hash := HashPassword(password, salt, algorithm)
query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)"
_, err := db.Exec(query, username, hash, salt, algorithm, false)
return err
}
func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error {
query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?"
_, err := db.Exec(query, displayName, pfpURL, bio, userID)
return err
}
const (
PermCreateBoard int64 = 1 << 0
PermManageUsers int64 = 1 << 1
)
func HasGlobalPermission(user *User, perm int64) bool {
return user.Permissions&perm != 0
}

View File

@ -1,33 +0,0 @@
# Short documentation of each file
### [[DIR] about](./about)
contains the about us page
### [[DIR] board](./board)
contains the files needed to display a board
### [[DIR] boards](./boards)
contains the files for the board list
### [[DIR] img](./img)
the directory that contains images that are part of the website itself (no user content)
### [[DIR] login](./login)
everything that is needed to log in a user
### [[DIR] logout](./logout)
the logout process
### [[DIR] news](./news)
a page displaying a special board that is write-protected and shows updates about ThreadR
### [[DIR] profile](./profile)
profile pages and profile settings
### [[DIR] signup](./signup)
the signup process
### [[DIR] userhome](./userhome)
the users feed
### [accept_cookie.php](./accept_cookie.php)
redirect immediately back to where the user came from but sets a cookie that says 'You accept cookies.'
$_GET variables:
* from: the url the user came from after urlencode() was used on it
### [index.php](./index.php)
the homepage
### [README.md](./README.php)
this file
### [style.css](./style.css)
the stylesheet used on every ThreadR site
### [redirect_home.html](./redirect_home.html)
small HTML file that redirects to ThreadR, can be linked or copied to any place that needs to redirect to ThreadRs landing page

View File

@ -1,5 +0,0 @@
# Short documentation of all files
### [index.php](./index.php)
Self-Explanatory
### [README.md](./README.md)
this file

View File

@ -1,47 +0,0 @@
<?php
session_start();
%SET_LOGIN_VARIABLE%
%PLEAZE_NO_CACHE%
%NO_CHEAP_SESSION_STEALING%
$navbar="about";
?>
<!DOCTYPE html>
<html>
<head>
<title>
ThreadR - About Us
</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<div class="container">
<div class="item-1">
<h1>
<center>About this ThreadR instance...</center>
</h1>
</div>
<div class="item-2">
%ABOUT_PAGE%
</div>
<div class="item-3">
<h1>
<center>About ThreadR...</center>
</h1>
</div>
<div class="item-4">
<p>
This forum is powered by the ThreadR Forum Engine. More information here:
<a href="https://threadr.lostcave.ddnss.de/threadr/about">[about page on the Developer's instance]</a>
and <a href="https://lostcave.ddnss.de/git/root/threadr.lostcave.ddnss.de">[git repository]</a>
</p>
<p>
If you encounter any issues, please report them to the issues board on our official instance at <a href="">[put link here when ready]</a>
</p>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,4 +0,0 @@
<?php
setcookie('threadr_cookie_banner', $expires=time()+2592000);
header("Location: https://%DOMAIN_NAME%" . urldecode($_GET['from']));
?>

View File

@ -1,20 +0,0 @@
# A short overview of the files:
### [index.php](./index.php)
Somewhat obvious...
$_GET values:
* id → the board id
* action → what action file should be loaded in the bottom section (see below)
* valid actions: [not present], post, submit, edit
* end → if present, jump to the end of the page
### [board.php](./board.php)
the top part of a board page, displays the posts, loaded via include()
### [post.php](./post.php)
one of the action files for the bottom of the page, displays a form to add a post, loaded via include()
### [submit.php](./submit.php)
displays a message in the bottom of the page and submits a new post, loaded via include()
### [edit.php](./edit.php)
? [Will look into it later, seems not to do very much ATM]
### [default.php](./default.php)
displays a button giving the user the option to post something
### [README.md](./README.md)
this file

View File

@ -1,57 +0,0 @@
<?php
$pdo = new PDO('mysql:host=%DB_SERVER%;dbname=%DB_NAME%', '%DB_USERNAME%', '%DB_PASSWORD%');
$statement = $pdo->prepare("SELECT * FROM posts WHERE board_id=:bid ORDER BY post_time asc");
$statement->execute(array("bid"=>$id));
foreach($statement->fetchAll() as $ROW) {
// get post creator
$statement = $pdo->prepare("SELECT * FROM users WHERE id=:uid");
$statement->execute(array("uid"=>$ROW[user_id]));
$post_creator = $statement->fetch();
// get post content and make sure it doesn't mess with the website
$post_id = $ROW['id'];
$post_title = htmlspecialchars($ROW['title']);
$post_creator_name = htmlspecialchars($post_creator['name']);
$post_time = htmlspecialchars($ROW['post_time']);
$post_content = htmlspecialchars($ROW['content']);
// add line breaks to post content, to be replaced with proper makrdown support in the future (see #44)
$newlines = array("\r\n", "\n\r", "\r", "\n"); // two-character newlines first to prevent placing two line breaks instead of one
$post_content = str_replace($newlines, "<br />", $post_content);
// if this is a reply, build reference
$reply_to = $ROW['reply_to'];
if ($reply_to > -1) {
$reply_reference = "<p class=\"post_reply\" >This is a reply to <a href=./?id=" . $_GET['id'] . "#$reply_to >this</a> message.</p>";
} else {
$reply_reference = "";
}
// determine whether to put a reply button
if ($login) {
$reply_button = "<a href=\"./?id=" . $_GET['id'] . "&action=post&reply_to=$post_id&end\" class=\"post_reply\"><button class=\"post_reply\">Reply</button></a>";
} else {
$reply_button = "";
}
echo "<section id=\"$post_id\" >
<div>
$reply_button
<h1 class=\"post\">$post_title</h1>
$reply_reference
</div>
<article>
<header>
<div>
<p class='beige'> $post_creator_name <time datetime='$post_time'>$post_time</time></p>
</div>
</header>
<div class='postcontent'>
<p>$post_content</p>
</div>
</article>
</section>";
}
?>

View File

@ -1,4 +0,0 @@
<?php
%REQUIRE_LOGIN%
echo "<div class='margin'><a href='%CONTENT_DIR%/board/?id=$id&action=post&end'><button> Post something </button></a></div>";
?>

View File

@ -1,4 +0,0 @@
<?php
%REQUIRE_LOGIN%
/*edit.php*/
?>

View File

@ -1,73 +0,0 @@
<?php
session_start();
%SET_LOGIN_VARIABLE%
%NO_CHEAP_SESSION_STEALING%
%PLEAZE_NO_CACHE%
$navbar="boards";
$id=$_GET['id'];
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - Boards</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<?php
if(isset($_GET['action'])) {
if ($_GET['action']=='submit') {
echo "<meta http-equiv=\"refresh\" content=\"5;URL=%CONTENT_DIR%/board/?id=$id&end\">";
}
} else {
echo "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">";
}
?>
</head>
<body <?php if (isset($_GET['end'])) { echo "onLoad=\"window.scrollTo(0,document.body.scrollHeight)\""; } ?> >
%NAVBAR%
<div class="container">
<div class="item-1">
<h1><center>
<?php
$pdo = new PDO('mysql:host=%DB_SERVER%;dbname=%DB_NAME%', '%DB_USERNAME%', '%DB_PASSWORD%');
$error = false;
$error_message = "";
if (!$error) {
$statement = $pdo->prepare("SELECT * FROM boards WHERE id=:id");
$statement->execute(array("id"=>$id));
$result = $statement->fetch();
echo $result['name'];
}
if (!$result) {
$error_message = "Error: SQL error.\n" . $statement->queryString . "\n" . $statement->errorInfo()[2];
}
?>
</center></h1>
</div>
<div class="item-2">
<?php
include("./board.php");
?>
</div>
<div class="item-3">
<?php
if($login) {
if(isset($_GET['action'])) {
if($_GET['action']=='post') {
include("./post.php");
} elseif($_GET['action']=='submit') {
include("./submit.php");
} elseif($_GET['action']=='edit') {
include("./edit.php");
}
} else {
include("./default.php");
}
} else {
echo "<div class='margin'><a href='%CONTENT_DIR%/login/'><button> Log in to post something </button></a></div>";
}
?>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,10 +0,0 @@
<?php
%REQUIRE_LOGIN%
echo "<section>";
if (isset($_GET['reply_to'])) {
echo "<form action='%CONTENT_DIR%/board/?id=$id&action=submit&reply_to=" . $_GET['reply_to'] . "&end' method='post'>";
} else {
echo "<form action='%CONTENT_DIR%/board/?id=$id&action=submit&end' method='post'>";
}
echo "<input type='text' name='title' maxlength='128' placeholder='Title'/><textarea name='content' maxlength='65535' placeholder='You can contribute to the conversation here. Tell us your story in up to 65535 characters...' rows='3'></textarea><input type='submit' value='Post'></form></section>";
?>

View File

@ -1,31 +0,0 @@
<?php
%REQUIRE_LOGIN%
if (isset($_GET['reply_to'])) {
$reply_to = $_GET['reply_to'];
} else {
$reply_to = -1;
}
echo "<section>";
if ($_POST['title']==="" || $_POST['content']==="") {
echo "<center><h1>Please fill out both the title field and content box.</h1></center>";
}
else {
$error = false;
$error_message = "";
if (!$error) {
$statement = $pdo->prepare("INSERT INTO posts (board_id, user_id, content, title, reply_to) VALUES (:bid, :uid, :content, :title, :replyto)");
$result = $statement->execute(array('bid'=>$id, 'uid'=>$_SESSION[user_id], 'content'=>$_POST['content'], 'title'=>$_POST['title'], 'replyto'=>$reply_to));
}
if (!$result) {
$error_message = "<p>Error: SQL error.</p><pre>" . $statement->queryString . "</pre><pre>" . $statement->errorInfo()[2] . "</pre>";
}
echo "<center><h1>Post submitted.</h1></center>";
}
echo "</section>";
?>

View File

@ -1,5 +0,0 @@
# Short description of each file
### [index.php](./index.php)
obvious, hopefully
### [README.md](./README.md)
this file

View File

@ -1,66 +0,0 @@
<?php
session_start();
%SET_LOGIN_VARIABLE%
%PLEAZE_NO_CACHE%
%NO_CHEAP_SESSION_STEALING%
$navbar="boards";
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - Boards</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<div class="container">
<div class="item-1">
<h1><center>ThreadR Boards</center></h1>
</div>
<div class="item-2">
<ul class="list">
<?php
$pdo = new PDO('mysql:host=%DB_SERVER%;dbname=%DB_NAME%', '%DB_USERNAME%', '%DB_PASSWORD%');
$error = false;
$error_message = "";
if (!$error) {
echo '<center><h2 class="beige">Public Boards</h2></center>';
$statement = $pdo->prepare("SELECT * FROM boards WHERE private='0' ORDER BY id asc");
$statement->execute();
foreach($statement->fetchAll() as $ROW) {
echo "<li><a href='%CONTENT_DIR%/board/?id=$ROW[id]'>$ROW[name]</a></li>";
}
}
if (!$result) {
$error_message = "Error: SQL error.\n" . $statement->queryString . "\n" . $statement->errorInfo()[2];
}
?>
</ul>
</div>
<div class="item-3">
<ul class="list">
<?php
if ($login) {
$error = false;
$error_message = "";
if (!$error) {
echo '<center><h2 class="beige">Private Boards</h2></center>';
$statement = $pdo->prepare("SELECT * FROM boards WHERE private='1' ORDER BY id asc");
$statement->execute();
foreach($statement->fetchAll() as $ROW) {
echo "<li><a href='%CONTENT_DIR%/board/?id=$ROW[id]'>$ROW[name]</a></li>";
}
}
if (!$result) {
$error_message = "Error: SQL error.\n" . $statement->queryString . "\n" . $statement->errorInfo()[2];
}
}
?>
</ul>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,7 +0,0 @@
# Short documentation of all files
### [index.html](./index.html)
This is my usual nope.html to prevent kiddies from poking at my files. It just redirects. There is no real security benefit to it though as it does not even prevent `wget -r`.
### *.{svg,png,jpg}
images that are used on the page
### [README.md](./README.md)
this file

View File

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0;%CONTENT_DIR%/" />
</head>
<body>
<p> Nope. </p>
</body>
</html>

View File

@ -1,31 +0,0 @@
<?php
session_start();
%SET_LOGIN_VARIABLE%
%PLEAZE_NO_CACHE%
%NO_CHEAP_SESSION_STEALING%
$navbar="home";
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - Home</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<div class="container">
<div class="item-1">
<h1>
<center>Welcome to</center>
</h1>
</div>
<div class="item-2">
<img src="%CONTENT_DIR%/img/ThreadR.svg" alt="ThreadR" height="100%" width="100%">
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,7 +0,0 @@
# A short description of each file
### [index.php](./index.php)
should be obvious, accepts a $_GET variable to display error messages: error=<credentials|session>
### [README.md](./README.md)
this file
### [redirect.php](./redirect.php)
does some tasks for login and redirects to the users personal feed page

View File

@ -1,56 +0,0 @@
<?php
session_start();
%PLEAZE_NO_CACHE%
$navbar = "login";
if (isset($_SESSION['user_id'])){
%FORCE_LOGOUT%
}
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - Log In</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<br />
<div class="container">
<div class="item-1">
<h1> <center>Log in to your ThreadR account</center> </h1>
</div>
<div class="item-2">
<section>
<p>
<?php
if (isset($_GET['error'])){
if ($_GET['error'] == "credentials"){
echo "Invalid credentials. Try again:";
}
if ($_GET['error'] == "session"){
echo "You are not logged in or your session has been closed. We are sorry for the inconvenience.";
}
} else {
echo "Login:";
}
?></p>
<form action="%CONTENT_DIR%/login/redirect.php" method="post">
<input type="text" name="username" maxlength="20" placeholder="Username" />
<input type="password" name="password" maxlength="256" placeholder="Password" />
<p></p>
<input type="submit" value="Log in" />
</form>
<p></p>
</section>
</div>
<div class="item-3 margin">
<p></p>
<a href="%CONTENT_DIR%/signup/"> <button> Register </button> </a>
<p></p>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,29 +0,0 @@
<?php
session_start();
%PLEAZE_NO_CACHE%
$pdo = new PDO('mysql:host=%DB_SERVER%;dbname=%DB_NAME%', '%DB_USERNAME%', '%DB_PASSWORD%');
$statement = $pdo->prepare('SELECT id, authentication_algorithm, authentication_salt, authentication_string FROM users WHERE name = :username;');
$result = $statement->execute(array('username' => $_POST['username']));
if ($statement->rowCount() > 0) {
//existing user name
$dbentry = $statement->fetch();
//chechk for correct password
if ($dbentry['authentication_string'] == hash($dbentry['authentication_algorithm'], $_POST['password'] . $dbentry['authentication_salt'])) {
//password correct
$_SESSION['user_id'] = $dbentry['id'];
// IP and user agent string are used to prevent cheap session stealing
$_SESSION['user_ip'] = $_SERVER['REMOTE_ADDR'];
$_SESSION['user_http_user_agent'] = $_SERVER['HTTP_USER_AGENT'];
header("Location: https://%DOMAIN_NAME%%CONTENT_DIR%/userhome/");
} else {
//password inorrect
header("Location: https://%DOMAIN_NAME%%CONTENT_DIR%/login/?error=credentials");
die();
}
} else {
//wrong user name
header("Location: https://%DOMAIN_NAME%%CONTENT_DIR%/login/?error=credentials");
die();
}
?>

View File

@ -1,5 +0,0 @@
# Short documentation of all files
### [index.php](./index.php)
Self-Explanatory
### [README.md](./README.md)
this file

View File

@ -1,29 +0,0 @@
<?php
session_start();
%PLEAZE_NO_CACHE%
$navbar = "logout";
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - Home</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<br />
<div class="container">
<div class="item-1">
<h1>
<center>Bye! Cya around some time soon.</center>
</h1>
</div>
<div class="item-2">
<img src="%CONTENT_DIR%/img/ThreadR.svg" alt="ThreadR" height="100%" width="100%">
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,5 +0,0 @@
# Short description of each file
### [index.php](./index.php)
Well, guess what?
### [README.md](./README.md)
this file

View File

@ -1,35 +0,0 @@
<?php
session_start();
%SET_LOGIN_VARIABLE%
%PLEAZE_NO_CACHE%
%NO_CHEAP_SESSION_STEALING%
$navbar = "news";
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - News</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<br />
<div class="container">
<div class="item-1">
<h1>
<center>ThreadR Newsfeed</center>
</h1>
</div>
<div class="item-2">
<ul class="list">
<li><p> 2020-02-21 Whole Website updated: Homepage, News, Boards, About, Log In, Userhome, Log Out</p></li>
<li><p> 2020-01-06 First Steps done </p></li>
<li><pre> @ my fellow devs: Yeeee! This will be gone once the DB table is in place. Don&apos;t bother. </pre></li>
</ul>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,10 +0,0 @@
# Short documentation of each file
### [index.php](./index.php)
Fairly obvious, if you ask me.
Accepts a $_GET variable: action=<[not present]|edit>
### [default.php](./default.php)
shown by default, loaded via include()
### [edit.php](./edit.php)
profile edit page, loaded via include()
### [README.md](./README.md)
this file

View File

@ -1,10 +0,0 @@
<?php
%REQUIRE_LOGIN%
?>
<section>
<center><h1><?php echo " $username "; ?> </h1></center>
<a href="%CONTENT_DIR%/profile/?action=edit">
<button>Edit Profile</button>
</a>
</section>

View File

@ -1,15 +0,0 @@
<?php
%REQUIRE_LOGIN%
?>
<section>
<center><h1><?php echo " $username "; ?> </h1></center>
<form action="%CONTENT_DIR%/profile/" method="post">
<input type="text" name="name" maxlength="20" placeholder="First name"/>
<input type="text" name="email" placeholder="E-mail"/>
<input type="text" name="biography" maxlength="2000" placeholder="Describe yourself"/>
<input type="text" name="website" maxlength="127" placeholder="Website">
<button>Save Profile</button>
</form>
</section>

View File

@ -1,52 +0,0 @@
<?php
session_start();
%SET_LOGIN_VARIABLE%
%PLEAZE_NO_CACHE%
%NO_CHEAP_SESSION_STEALING%
%REQUIRE_LOGIN%
$pdo = new PDO('mysql:host=%DB_SERVER%;dbname=%DB_NAME%', '%DB_USERNAME%', '%DB_PASSWORD%');
$error = false;
$error_message = "";
if (!$error) {
$statement = $pdo->prepare("SELECT name FROM users WHERE id=:uid"); // to be replaced with optional user name off the user data table
$statement->execute(array("uid"=>$_SESSION[user_id]));
$dbentry = $statement->fetch();
$username = $dbentry[name];
}
if (!$result) {
$error_message = "Error: SQL error.\n" . $statement->queryString . "\n" . $statement->errorInfo()[2];
}
$navbar = "profile";
?>
<html>
<head>
<title>ThreadR - Profile</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<div class="container">
<div class="item-1">
<center><h1>ThreadR</h1></center>
</div>
<div class="item-2">
<?php
if(isset($_GET['action'])) {
if($_GET['action']=='edit') {
include("./edit.php");
}
} else {
include("./default.php");
}
?>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0;%CONTENT_DIR%/" />
</head>
<body>
<p> Your browser seems to not support redirects. <br />
Please klick the following link to continue: <a href="%CONTENT_DIR%/"> Go to home page... </a></p>
</body>
</html>

View File

@ -1,8 +0,0 @@
# Short description for each file
### [[DIR] verify-email](./verify-email)
the folder containing the email verification page
### [index.php](./index.php)
You can figure that out on your own.
Interesting fact about this page: Will force-logout the user without notice.
### [README.md](./README.md)
this file

View File

@ -1,37 +0,0 @@
<?php
session_start();
%PLEAZE_NO_CACHE%
%NO_CHEAP_SESSION_STEALING%
$navbar = "signup";
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - Sign Up</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<div class="container">
<div class="item-1">
<h1><center>Create your ThreadR account</center></h1>
</div>
<div class="item-2">
<section>
<p>Sign up:</p>
<form action="%CONTENT_DIR%/signup/verify-email/" method="post">
<p>Username: <input type="text" name="username" placeholder="Username" /> </p>
<p>E-mail: <input type="text" name="email" placeholder="email@example.com" /> </p>
<p>Password: <input type="password" name="password" placeholder="Password" /> </p>
<p>Password confirmation: <input type="password" name="password_confirm" placeholder="Confirm password" /> </p>
<input type="submit" value="Register" />
</form>
<p></p>
</section>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,7 +0,0 @@
# Files
### [index.php](./index.php)
obvious
### [redirect.php](./redirect.php)
activates the user after the email has been verified
### [README.md](./README.php)
this file

View File

@ -1,60 +0,0 @@
<?php
%SET_LOGIN_VARIABLE%
%PLEAZE_NO_CACHE%
//permitted chars for password salt
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&/()[]$:?_';
//generates password salt
function generate_salt($input, $strength = 5) {
$input_length = strlen($input);
$random_string = '';
for($i = 0; $i < $strength; $i++) {
$random_character = $input[random_int(0, $input_length - 1)];
$random_string .= $random_character;
}
return $random_string;
}
//for token generation
$token_salt = generate_salt($permitted_chars);
$token_hashes = hash("crc32", $_POST['email']) . hash("crc32", $_POST['username']);
$token = str_shuffle($token_hashes . $token_salt);
//for password hashing
$password_salt = generate_salt($permitted_chars);
$password_hash_method = "sha256";
$pdo = new PDO('mysql:host=%DB_SERVER%;dbname=%DB_NAME%', '%DB_USERNAME%', '%DB_PASSWORD%');
//$statement = $pdo->prepare('');
$navbar = "verify-email";
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR - Verification</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<div class="container">
<div class="item-1">
<h1>
<center>E-mail verification</center>
</h1>
</div>
<div class="item-2">
<section>
<p>Please send an e-mail containing the following token to <a class="pink-b" href="mailto:signup@lostcave.ddnss.de?subject=ThreadR%20-%20Verification&body=<?php echo $token; ?>">signup@lostcave.ddnss.de</a>:</p>
<form action="%CONTENT_DIR%/signup/verify-email/redirect.php" method="post">
<p>Token: <?php echo $token; ?></p>
<input type="submit" value="Done, sign me up!" />
</form>
</section>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

@ -1,3 +0,0 @@
<?php
%PLEAZE_NO_CACHE%
?>

View File

@ -1,406 +0,0 @@
.container{
display: grid;
grid-template-rows: 100px;
grid-template-columns: 1fr 3fr 1fr;
grid-gap: 10px;
grid-auto-rows: minmax(100px, auto);
padding: 25px 25px;
}
.beige {
color: %COLOR_BEIGE%;
}
.orange {
color: %COLOR_ORANGE%;
}
.blue {
color: %COLOR_BLUE%;
}
.pink {
color: %COLOR_PINK%;
}
.cyan {
color: %COLOR_CYAN%;
}
.beige-b {
background-color: %COLOR_BEIGE%;
}
.orange-b {
background-color: %COLOR_ORANGE%;
}
.blue-b {
background-color: %COLOR_BLUE%;
}
.pink-b {
background-color: %COLOR_PINK%;
}
.cyan-b {
background-color: %COLOR_CYAN%;
}
div {
overflow: hidden;
text-overflow: clip;
}
div.item-1 {
grid-column: 2 / 3;
grid-row: 1;
}
div.item-2 {
grid-column: 2 / 3;
grid-row: 2;
}
div.item-3 {
grid-column: 2 / 3;
grid-row: 3;
}
div.item-4 {
grid-column: 2 / 3;
grid-row: 4;
}
div.margin {
margin-top: 0px;
margin-bottom: 24px;
margin-left: 16px;
margin-right: 16px;
}
div.postcontent {
padding: 40px 0px 0px 0px;
}
div.banner {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
background-color: %COLOR_BLUE%;
}
body {
margin: 0px;
background-color: %COLOR_BEIGE%;
color: %COLOR_BLUE%;
font-size: 1em;
}
p {
font-family: monospace;
color: %COLOR_BLUE%;
}
a {
font-family: monospace;
color: %COLOR_BLUE%;
}
i {
font-family: monospace;
color: %COLOR_BLUE%;
}
li {
font-family: monospace;
color: %COLOR_BLUE%;
}
input {
font-family: monospace;
width: 100%;
border-radius: 4px;
padding: 14px 20px;
margin: 8px 0;
background-color: %COLOR_BEIGE%;
}
input[type=text], select {
color: %COLOR_BLUE%;
display: inline-block;
border: 1px solid %COLOR_BLUE%;
box-sizing: border-box;
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
}
input[type=password], select {
color: %COLOR_BLUE%;
display: inline-block;
border: 1px solid %COLOR_BLUE%;
box-sizing: border-box;
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
}
input[type=submit] {
background-color: %COLOR_BLUE%;
color: %COLOR_BEIGE%;
border: none;
cursor: pointer;
}
input[type=submit]:hover {
background-color: %COLOR_CYAN%;
}
textarea {
font-family: monospace;
width: 100%;
border-radius: 4px;
padding: 14px 20px;
margin: 8px 0;
background-color: %COLOR_BEIGE%;
color: %COLOR_BLUE%;
display: inline-block;
border: 1px solid %COLOR_BLUE%;
box-sizing: border-box;
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
}
button {
font-family: monospace;
background-color: %COLOR_BLUE%;
color: white;
padding: 14px 20px;
margin: 8px 0;
width: 100%;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: %COLOR_CYAN%;
}
h1, h2, h3, h4, h5, h6 {
font-family: monospace;
color: %COLOR_BLUE%;
}
time {
font-family: monospace;
color: %COLOR_BEIGE%;
float: right;
}
div.right {
float: right;
}
div.round-border {
border: 1px solid %COLOR_BLUE%;
padding: 14px 20px;
margin: 8px 0;
border-radius: 5px;
}
img {
max-width: 100%;
}
img.available_space {
object-fit: contain;
}
div.image_container {
border: 1px solid %COLOR_BLUE%;
padding: 0.5em 0.5em;
background-color: #f0f0f0;
border-radius: 5px;
}
p.image_container {
font-size: 0.8em;
}
ul.topnav {
list-style-type: none;
margin: 0;
padding: 0;
position: fixed;
left: 0;
top: 0;
width: 100%;
overflow: hidden;
background-color: %COLOR_BLUE%;
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;
text-align: center;
color: %COLOR_BEIGE%;
font-family: monospace;
font-size: 1em;
padding: 1.2em 1.3em;
text-decoration: none;
}
ul.topnav li a:hover:not(.active) {
background-color: %COLOR_CYAN%;
}
ul.topnav li a.active {
background-color: %COLOR_PINK%;
}
ul.topnav li.right {
float: right;
}
/*@media screen and (max-width: 600px) {
ul.topnav li.right,
ul.topnav li {float: none;}
}*/
ul.topnav img {
height: 2.55em;
}
ul.topnav li a.icon {
display: block;
text-align: center;
color: %COLOR_BEIGE%;
font-family: monospace;
font-size: 1em;
text-decoration: none;
padding: 5.4px 9px;
}
/* invisible spacer under nav bar */
div.topnav {
height: 3em;
}
ul.list {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: %COLOR_BLUE%;
}
ul.list li {
float: left;
}
ul.list li a {
display: block;
text-align: center;
color: %COLOR_BEIGE%;
font-family: monospace;
font-size: 1em;
padding: 14px 16px;
text-decoration: none;
}
ul.list li a:hover:not(.active) {
background-color: %COLOR_CYAN%;
}
ul.list li a.active {
background-color: %COLOR_PINK%;
}
.dropdown-content {
display: none;
position: fixed;
background-color: %COLOR_BLUE%;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
.dropdown:hover .dropdown-content {
display: block;
}
.dropdown {
float: left;
overflow: hidden;
}
.dropdown .dropbtn {
font-size: 1em;
border: none;
outline: none;
color: %COLOR_BEIGE%;
padding: 1.2em 1.3em;
background-color: inherit;
font-family: inherit;
margin: 0;
}
div.dropdown {
color: %COLOR_BEIGE%;
text-align: center;
font-family: monospace;
font-size: 1em;
text-decoration: none;
}
.dropdown:hover:not(.active) {
background-color: %COLOR_CYAN%;
}
section {
margin-top: 0px;
margin-bottom: 24px;
margin-left: 16px;
margin-right: 16px;
border-radius: 5px;
border: 1px solid %COLOR_BLUE%;
background-color: %COLOR_ORANGE%;
padding: 14px 20px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
}
article {
border-radius: 5px;
border: 1px solid %COLOR_BLUE%;
background-color: %COLOR_BEIGE%;
padding: 14px 20px;
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
overflow: hidden;
}
header {
border-radius: 0px;
border: none;
background-color: %COLOR_BLUE%;
color: %COLOR_BEIGE%;
margin: -40px;
padding: 40px 40px 10px 40px;
box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.2);
}
h1.post{
margin: 0;
}
p.post_reply {
margin: 0.1em;
}
a.post_reply {
float: right;
}
button.post_reply {
width: auto;
margin: 0.1em;
}

View File

@ -1,5 +0,0 @@
## Short documentation of all files
### [index.php](./index.php)
Self-Explanatory
### [README.md](./README.md)
this file

View File

@ -1,43 +0,0 @@
<?php
session_start();
%PLEAZE_NO_CACHE%
%SET_LOGIN_VARIABLE%
%NO_CHEAP_SESSION_STEALING%
%REQUIRE_LOGIN%
$pdo = new PDO('mysql:host=%DB_SERVER%;dbname=%DB_NAME%', '%DB_USERNAME%', '%DB_PASSWORD%');
$error = false;
$error_message = "";
if (!$error) {
$statement = $pdo->prepare("SELECT name FROM users WHERE id=:uid"); // to be replaced with optional user name off the user data table
$statement->execute(array("uid"=>$_SESSION[user_id]));
$dbentry = $statement->fetch();
$username = $dbentry[name];
}
if (!$result) {
$error_message = "Error: SQL error.\n" . $statement->queryString . "\n" . $statement->errorInfo()[2];
}
$navbar = "home";
?>
<!DOCTYPE html>
<html>
<head>
<title>ThreadR</title>
%STYLESHEET%
<link rel="icon" type="image/png" href="%CONTENT_DIR%/img/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
%NAVBAR%
<div class="container">
<div class="item-1">
<center><h1>ThreadR</h1></center>
</div>
<div class="item-2">
<center><h3><?php echo "Welcome back, "; echo "$username"; echo "!";?> </h3></center>
</div>
</div>
%BANNER_COOKIES%
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

Before

Width:  |  Height:  |  Size: 752 B

After

Width:  |  Height:  |  Size: 752 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

371
static/style.css Normal file
View File

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

16
templates/base.html Normal file
View File

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

View File

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

View File

@ -0,0 +1,41 @@
{{define "board"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
</header>
<section>
<h3>Threads</h3>
{{if .Threads}}
<ul>
{{range .Threads}}
<li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</li>
{{end}}
</ul>
{{else}}
<p>No threads available in this board yet.</p>
{{end}}
</section>
{{if .LoggedIn}}
<section>
<h3>Create New Thread</h3>
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
<label for="title">Thread Title:</label>
<input type="text" id="title" name="title" required><br>
<input type="submit" value="Create Thread">
</form>
</section>
{{end}}
</main>
{{template "cookie_banner" .}}
</body>
</html>
{{end}}

View File

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

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

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

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

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

View File

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

Some files were not shown because too many files have changed in this diff Show More