Compare commits
29 Commits
Author | SHA1 | Date |
---|---|---|
|
bdf81e7c68 | |
|
c10535492b | |
![]() |
e7567d0f08 | |
![]() |
8b95ec3e38 | |
![]() |
869d974f71 | |
![]() |
0bee74ab5b | |
|
cb9022d8bd | |
|
3b56c7e831 | |
|
e6f097d35c | |
|
b1c3f80afb | |
|
4eb97f27d8 | |
|
6b6ca1d85d | |
|
92fd9948eb | |
|
de1f442082 | |
|
ba5ed6c182 | |
|
484f435ff2 | |
|
eee9540bdc | |
![]() |
af91df4986 | |
![]() |
8cc33a9727 | |
![]() |
b193cd00bc | |
![]() |
3bc870bbe0 | |
![]() |
b9aa1baf9c | |
![]() |
a13d3e1242 | |
![]() |
98596e5695 | |
![]() |
4052787790 | |
![]() |
e1cac20414 | |
![]() |
aec8caa11a | |
![]() |
2f6b6d09de | |
![]() |
be97370acb |
|
@ -1 +1,5 @@
|
|||
*.swp
|
||||
config/config.json
|
||||
config/about_page.htmlbody
|
||||
|
||||
# nano
|
||||
.swp
|
||||
|
|
179
LICENSE.md
|
@ -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
|
@ -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
|
@ -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 doesn’t really make sense anymore (at least to me) so it needs to shift slightly. Below is a list of goals that I would like to see achieved, feel free to discuss this in the issues or commit comments.
|
||||
## 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 that’s supposed to be unique?))
|
||||
- `display_name` (varchar)
|
||||
- `status` (varchar)
|
||||
- `about` (very long varchar)
|
||||
- `website` (varchar)
|
||||
- users
|
||||
- `id` (smallint (again, this makes no sense), primary key)
|
||||
- `name` (varchar, index (again, that’s probably supposed to be unique))
|
||||
- `authentication_string` (varchar(128))
|
||||
- `authentication_salt` (varchar)
|
||||
- `authentication_algorithm` (varchar)
|
||||
- `time_created` (timestamp, default current_timestamp())
|
||||
- `time_altered` (timestamp, default current_timestamp(), on update current_timestamp())
|
||||
- `verified` (boolean or tinyint(1), default 0)
|
||||
|
||||
# Git based automatic web deployment system
|
||||
This repository will be automagically pulled by the web server each time something is pushed by a user.
|
||||
|
||||
Dear Developers, Please use pushes sparingly because it takes a while for the server to replace all code variables.
|
||||
|
||||
What this thing does basically equates to:
|
||||
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 haven’t exactly figured out how to handle this in the future. It is absolutely necessary to deploy a ThreadR instance because it is used to configure ThreadR so we need a copy of it here.
|
||||
**Authors:** BodgeMaster, Jocadbz
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
# Placeholder
|
||||
This directory is here as a placeholder for the build process.
|
||||
|
||||
# Files
|
||||
### [README.md](./README.md)
|
||||
this 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. Don’t 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
|
|
@ -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'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>
|
|
@ -0,0 +1,31 @@
|
|||
<main>
|
||||
<header>
|
||||
<h2>About ThreadR</h2>
|
||||
</header>
|
||||
<section>
|
||||
<p>
|
||||
This is a ThreadR development instance. Beep beep. Boop boop.
|
||||
</p>
|
||||
<p>
|
||||
If you see this message in a production environment (aka. a Forum that is actually being used), kindly tell the admin that they forgot to change the about page. :)
|
||||
</p>
|
||||
<h2>
|
||||
What is ThreadR?
|
||||
</h2>
|
||||
<p>
|
||||
ThreadR is a free and open-source forum engine. That means you can download
|
||||
it and host an instance of ThreadR on your own web server to run your own forum.
|
||||
It originated in 2019 as a school project and has died twice since.
|
||||
Currently, the project is being rewritten in Go with (hopefully) less ugly hacks.
|
||||
</p>
|
||||
<h2>
|
||||
Who dis?
|
||||
</h2>
|
||||
<p>
|
||||
Depends.<br />
|
||||
If this site is hosted on a LostCave domain, then it's probably us, the developers.
|
||||
For now, you can find us on Discord: <a href="https://discord.gg/r3w3zSkEUE">discord.gg/r3w3zSkEUE</a><br />
|
||||
If it isn't on a LostCave domain, then this site belongs to some lazy admin who forgot to change the about page.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"domain_name": "localhost",
|
||||
"threadr_dir": "/threadr",
|
||||
"db_username": "threadr_user",
|
||||
"db_password": "threadr_password",
|
||||
"db_database": "threadr_db",
|
||||
"db_svr_host": "localhost:3306"
|
||||
}
|
|
@ -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
|
|
@ -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."
|
|
@ -0,0 +1,16 @@
|
|||
module threadr
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.9.0
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
)
|
|
@ -0,0 +1,16 @@
|
|||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
||||
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
|
@ -0,0 +1,46 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"github.com/gorilla/sessions"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
func AboutHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
loggedIn := session.Values["user_id"] != nil
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
|
||||
aboutContent, err := ioutil.ReadFile("config/about_page.htmlbody")
|
||||
if err != nil {
|
||||
log.Printf("Error reading about_page.htmlbody: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
AboutContent template.HTML
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - About",
|
||||
Navbar: "about",
|
||||
LoggedIn: loggedIn,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
AboutContent: template.HTML(aboutContent),
|
||||
}
|
||||
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "about", data); err != nil {
|
||||
log.Printf("Error executing template in AboutHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AcceptCookieHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "threadr_cookie_banner",
|
||||
Value: "accepted",
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||
})
|
||||
from := r.URL.Query().Get("from")
|
||||
if from == "" {
|
||||
from = app.Config.ThreadrDir + "/"
|
||||
}
|
||||
http.Redirect(w, r, from, http.StatusFound)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Navbar string
|
||||
LoggedIn bool
|
||||
ShowCookieBanner bool
|
||||
BasePath string
|
||||
StaticPath string
|
||||
CurrentURL string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DomainName string `json:"domain_name"`
|
||||
ThreadrDir string `json:"threadr_dir"`
|
||||
DBUsername string `json:"db_username"`
|
||||
DBPassword string `json:"db_password"`
|
||||
DBDatabase string `json:"db_database"`
|
||||
DBServerHost string `json:"db_svr_host"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
DB *sql.DB
|
||||
Store *sessions.CookieStore
|
||||
Config *Config
|
||||
Tmpl *template.Template
|
||||
}
|
||||
|
||||
func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := app.Store.Get(r, "session-name")
|
||||
if err != nil {
|
||||
session = sessions.NewSession(app.Store, "session-name")
|
||||
session.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
HttpOnly: true,
|
||||
}
|
||||
}
|
||||
if _, ok := session.Values["user_id"].(int); ok {
|
||||
// Skip IP and User-Agent check for WebSocket connections
|
||||
if r.URL.Query().Get("ws") != "true" {
|
||||
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
|
||||
session.Values = make(map[interface{}]interface{})
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "session", session)
|
||||
r = r.WithContext(ctx)
|
||||
} else {
|
||||
ctx := context.WithValue(r.Context(), "session", session)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
if _, ok := session.Values["user_id"].(int); !ok {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func BoardHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
loggedIn := session.Values["user_id"] != nil
|
||||
userID, _ := session.Values["user_id"].(int)
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
|
||||
boardIDStr := r.URL.Query().Get("id")
|
||||
boardID, err := strconv.Atoi(boardIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid board ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
board, err := models.GetBoardByID(app.DB, boardID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching board: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if board == nil {
|
||||
http.Error(w, "Board not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if board.Private {
|
||||
if !loggedIn {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermViewBoard)
|
||||
if err != nil {
|
||||
log.Printf("Error checking permission: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasPerm {
|
||||
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost && loggedIn {
|
||||
action := r.URL.Query().Get("action")
|
||||
if action == "create_thread" {
|
||||
title := r.FormValue("title")
|
||||
if title == "" {
|
||||
http.Error(w, "Thread title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if board.Private {
|
||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermPostInBoard)
|
||||
if err != nil {
|
||||
log.Printf("Error checking permission: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasPerm {
|
||||
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
thread := models.Thread{
|
||||
BoardID: boardID,
|
||||
Title: title,
|
||||
CreatedByUserID: userID,
|
||||
}
|
||||
err = models.CreateThread(app.DB, thread)
|
||||
if err != nil {
|
||||
log.Printf("Error creating thread: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var threadID int
|
||||
err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&threadID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting last insert id: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+strconv.Itoa(threadID), http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
threads, err := models.GetThreadsByBoardID(app.DB, boardID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching threads: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Board models.Board
|
||||
Threads []models.Thread
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - " + board.Name,
|
||||
Navbar: "boards",
|
||||
LoggedIn: loggedIn,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Board: *board,
|
||||
Threads: threads,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "board", data); err != nil {
|
||||
log.Printf("Error executing template in BoardHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func BoardsHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
loggedIn := session.Values["user_id"] != nil
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
userID, _ := session.Values["user_id"].(int)
|
||||
isAdmin := false
|
||||
|
||||
if loggedIn {
|
||||
user, err := models.GetUserByID(app.DB, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching user: %v", err)
|
||||
} else if user != nil {
|
||||
isAdmin = models.HasGlobalPermission(user, models.PermCreateBoard)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost && loggedIn && isAdmin {
|
||||
name := r.FormValue("name")
|
||||
description := r.FormValue("description")
|
||||
if name == "" {
|
||||
http.Error(w, "Board name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
board := models.Board{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Private: false,
|
||||
PublicVisible: true,
|
||||
}
|
||||
query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)"
|
||||
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible)
|
||||
if err != nil {
|
||||
log.Printf("Error creating board: %v", err)
|
||||
http.Error(w, "Failed to create board", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
boardID, _ := result.LastInsertId()
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/board/?id="+strconv.FormatInt(boardID, 10), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
publicBoards, err := models.GetAllBoards(app.DB, false)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching public boards: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var privateBoards []models.Board
|
||||
if loggedIn {
|
||||
privateBoards, err = models.GetAllBoards(app.DB, true)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching private boards: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var accessiblePrivateBoards []models.Board
|
||||
for _, board := range privateBoards {
|
||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
|
||||
if err != nil {
|
||||
log.Printf("Error checking permission: %v", err)
|
||||
continue
|
||||
}
|
||||
if hasPerm {
|
||||
accessiblePrivateBoards = append(accessiblePrivateBoards, board)
|
||||
}
|
||||
}
|
||||
privateBoards = accessiblePrivateBoards
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
PublicBoards []models.Board
|
||||
PrivateBoards []models.Board
|
||||
IsAdmin bool
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Boards",
|
||||
Navbar: "boards",
|
||||
LoggedIn: loggedIn,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
PublicBoards: publicBoards,
|
||||
PrivateBoards: privateBoards,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "boards", data); err != nil {
|
||||
log.Printf("Error executing template in BoardsHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,188 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins for now; restrict in production
|
||||
},
|
||||
}
|
||||
|
||||
// ChatHub manages WebSocket connections and broadcasts messages
|
||||
type ChatHub struct {
|
||||
clients map[*websocket.Conn]int // Map of connections to user IDs
|
||||
broadcast chan []byte
|
||||
register chan *websocket.Conn
|
||||
unregister chan *websocket.Conn
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewChatHub() *ChatHub {
|
||||
return &ChatHub{
|
||||
clients: make(map[*websocket.Conn]int),
|
||||
broadcast: make(chan []byte),
|
||||
register: make(chan *websocket.Conn),
|
||||
unregister: make(chan *websocket.Conn),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ChatHub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mutex.Lock()
|
||||
h.clients[client] = 0 // UserID set later
|
||||
h.mutex.Unlock()
|
||||
case client := <-h.unregister:
|
||||
h.mutex.Lock()
|
||||
delete(h.clients, client)
|
||||
h.mutex.Unlock()
|
||||
client.Close()
|
||||
case message := <-h.broadcast:
|
||||
h.mutex.Lock()
|
||||
for client := range h.clients {
|
||||
err := client.WriteMessage(websocket.TextMessage, message)
|
||||
if err != nil {
|
||||
log.Printf("Error broadcasting message: %v", err)
|
||||
client.Close()
|
||||
delete(h.clients, client)
|
||||
}
|
||||
}
|
||||
h.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hub = NewChatHub()
|
||||
|
||||
func init() {
|
||||
go hub.Run()
|
||||
}
|
||||
|
||||
func ChatHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
|
||||
if r.URL.Query().Get("ws") == "true" {
|
||||
// Handle WebSocket connection
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Error upgrading to WebSocket: %v", err)
|
||||
return
|
||||
}
|
||||
hub.register <- ws
|
||||
hub.mutex.Lock()
|
||||
hub.clients[ws] = userID
|
||||
hub.mutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
hub.unregister <- ws
|
||||
}()
|
||||
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("Error reading WebSocket message: %v", err)
|
||||
break
|
||||
}
|
||||
var chatMsg struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
ReplyTo int `json:"replyTo"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &chatMsg); err != nil {
|
||||
log.Printf("Error unmarshaling message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if chatMsg.Type == "message" {
|
||||
msgObj := models.ChatMessage{
|
||||
UserID: userID,
|
||||
Content: chatMsg.Content,
|
||||
ReplyTo: chatMsg.ReplyTo,
|
||||
}
|
||||
if err := models.CreateChatMessage(app.DB, msgObj); err != nil {
|
||||
log.Printf("Error saving chat message: %v", err)
|
||||
continue
|
||||
}
|
||||
// Fetch the saved message with timestamp and user details
|
||||
var msgID int
|
||||
app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
|
||||
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching saved message: %v", err)
|
||||
continue
|
||||
}
|
||||
response, _ := json.Marshal(savedMsg)
|
||||
hub.broadcast <- response
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("autocomplete") == "true" {
|
||||
// Handle autocomplete for mentions
|
||||
prefix := r.URL.Query().Get("prefix")
|
||||
usernames, err := models.GetUsernamesMatching(app.DB, prefix)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching usernames for autocomplete: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response, _ := json.Marshal(usernames)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(response)
|
||||
return
|
||||
}
|
||||
|
||||
// Render chat page
|
||||
messages, err := models.GetRecentChatMessages(app.DB, 50)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching chat messages: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Reverse messages to show oldest first
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Messages []models.ChatMessage
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Chat",
|
||||
Navbar: "chat",
|
||||
LoggedIn: true,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Messages: messages,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
||||
log.Printf("Error executing template in ChatHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func HomeHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
loggedIn := session.Values["user_id"] != nil
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
data := struct {
|
||||
PageData
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Home",
|
||||
Navbar: "home",
|
||||
LoggedIn: loggedIn,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: filepath.Join(app.Config.ThreadrDir, "static"),
|
||||
CurrentURL: r.URL.String(),
|
||||
},
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "home", data); err != nil {
|
||||
log.Printf("Error executing template in HomeHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func LikeHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
postIDStr := r.FormValue("post_id")
|
||||
postID, err := strconv.Atoi(postIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid post ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
likeType := r.FormValue("type")
|
||||
if likeType != "like" && likeType != "dislike" {
|
||||
http.Error(w, "Invalid like type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
existingLike, err := models.GetLikeByPostAndUser(app.DB, postID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error checking existing like: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if existingLike != nil {
|
||||
if existingLike.Type == likeType {
|
||||
err = models.DeleteLike(app.DB, postID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting like: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = models.UpdateLikeType(app.DB, postID, userID, likeType)
|
||||
if err != nil {
|
||||
log.Printf("Error updating like: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
like := models.Like{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
Type: likeType,
|
||||
}
|
||||
err = models.CreateLike(app.DB, like)
|
||||
if err != nil {
|
||||
log.Printf("Error creating like: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func LoginHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
if r.Method == http.MethodPost {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
user, err := models.GetUserByUsername(app.DB, username)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Printf("Error fetching user in LoginHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if user == nil || !models.CheckPassword(password, user.AuthenticationSalt, user.AuthenticationAlgorithm, user.AuthenticationString) {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound)
|
||||
return
|
||||
}
|
||||
session.Values["user_id"] = user.ID
|
||||
session.Values["user_ip"] = r.RemoteAddr
|
||||
session.Values["user_agent"] = r.UserAgent()
|
||||
session.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
HttpOnly: true,
|
||||
}
|
||||
if err := session.Save(r, w); err != nil {
|
||||
log.Printf("Error saving session: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/userhome/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Error string
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Login",
|
||||
Navbar: "login",
|
||||
LoggedIn: false,
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Error: "",
|
||||
}
|
||||
if r.URL.Query().Get("error") == "invalid" {
|
||||
data.Error = "Invalid username or password"
|
||||
}
|
||||
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "login", data); err != nil {
|
||||
log.Printf("Error executing template in LoginHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func LogoutHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
session.Values = make(map[interface{}]interface{})
|
||||
session.Options.MaxAge = -1
|
||||
if err := session.Save(r, w); err != nil {
|
||||
log.Printf("Error saving session in LogoutHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/", http.StatusFound)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func NewsHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
loggedIn := session.Values["user_id"] != nil
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
userID, _ := session.Values["user_id"].(int)
|
||||
isAdmin := false
|
||||
|
||||
if loggedIn {
|
||||
user, err := models.GetUserByID(app.DB, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching user: %v", err)
|
||||
} else if user != nil {
|
||||
isAdmin = models.HasGlobalPermission(user, models.PermManageUsers)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost && loggedIn && isAdmin {
|
||||
if action := r.URL.Query().Get("action"); action == "delete" {
|
||||
newsIDStr := r.URL.Query().Get("id")
|
||||
newsID, err := strconv.Atoi(newsIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid news ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = models.DeleteNews(app.DB, newsID)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting news item: %v", err)
|
||||
http.Error(w, "Failed to delete news item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/news/", http.StatusFound)
|
||||
return
|
||||
} else {
|
||||
title := r.FormValue("title")
|
||||
content := r.FormValue("content")
|
||||
if title != "" && content != "" {
|
||||
news := models.News{
|
||||
Title: title,
|
||||
Content: content,
|
||||
PostedBy: userID,
|
||||
}
|
||||
err := models.CreateNews(app.DB, news)
|
||||
if err != nil {
|
||||
log.Printf("Error creating news item: %v", err)
|
||||
http.Error(w, "Failed to create news item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/news/", http.StatusFound)
|
||||
return
|
||||
} else {
|
||||
http.Error(w, "Title and content are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newsItems, err := models.GetAllNews(app.DB)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching news items: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
News []models.News
|
||||
IsAdmin bool
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - News",
|
||||
Navbar: "news",
|
||||
LoggedIn: loggedIn,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
News: newsItems,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "news", data); err != nil {
|
||||
log.Printf("Error executing template in NewsHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func ProfileHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, err := models.GetUserByID(app.DB, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching user in ProfileHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
displayName := user.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = user.Username
|
||||
}
|
||||
data := struct {
|
||||
PageData
|
||||
User models.User
|
||||
DisplayName string
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Profile",
|
||||
Navbar: "profile",
|
||||
LoggedIn: true,
|
||||
ShowCookieBanner: false,
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
User: *user,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "profile", data); err != nil {
|
||||
log.Printf("Error executing template in ProfileHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func ProfileEditHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
displayName := r.FormValue("display_name")
|
||||
pfpURL := r.FormValue("pfp_url")
|
||||
bio := r.FormValue("bio")
|
||||
err := models.UpdateUserProfile(app.DB, userID, displayName, pfpURL, bio)
|
||||
if err != nil {
|
||||
log.Printf("Error updating profile: %v", err)
|
||||
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/profile/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := models.GetUserByID(app.DB, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching user: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
User models.User
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Edit Profile",
|
||||
Navbar: "profile",
|
||||
LoggedIn: true,
|
||||
ShowCookieBanner: false,
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
User: *user,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "profile_edit", data); err != nil {
|
||||
log.Printf("Error executing template in ProfileEditHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func SignupHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
if r.Method == http.MethodPost {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
err := models.CreateUser(app.DB, username, password)
|
||||
if err != nil {
|
||||
log.Printf("Error creating user: %v", err)
|
||||
data := struct {
|
||||
PageData
|
||||
Error string
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Sign Up",
|
||||
Navbar: "signup",
|
||||
LoggedIn: false,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Error: "An error occurred during sign up. Please try again.",
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
|
||||
log.Printf("Error executing template in SignupHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
data := struct {
|
||||
PageData
|
||||
Error string
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Sign Up",
|
||||
Navbar: "signup",
|
||||
LoggedIn: session.Values["user_id"] != nil,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Error: "",
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "signup", data); err != nil {
|
||||
log.Printf("Error executing template in SignupHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func ThreadHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
loggedIn := session.Values["user_id"] != nil
|
||||
userID, _ := session.Values["user_id"].(int)
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
|
||||
threadIDStr := r.URL.Query().Get("id")
|
||||
threadID, err := strconv.Atoi(threadIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
thread, err := models.GetThreadByID(app.DB, threadID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching thread: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if thread == nil {
|
||||
http.Error(w, "Thread not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
board, err := models.GetBoardByID(app.DB, thread.BoardID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching board: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if board.Private {
|
||||
if !loggedIn {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
|
||||
if err != nil {
|
||||
log.Printf("Error checking permission: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasPerm {
|
||||
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost && loggedIn {
|
||||
action := r.URL.Query().Get("action")
|
||||
if action == "submit" {
|
||||
content := r.FormValue("content")
|
||||
replyToStr := r.URL.Query().Get("to")
|
||||
replyTo := -1
|
||||
if replyToStr != "" {
|
||||
replyTo, err = strconv.Atoi(replyToStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if content == "" {
|
||||
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if board.Private {
|
||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
|
||||
if err != nil {
|
||||
log.Printf("Error checking permission: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasPerm {
|
||||
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
post := models.Post{
|
||||
ThreadID: threadID,
|
||||
UserID: userID,
|
||||
Content: content,
|
||||
ReplyTo: replyTo,
|
||||
}
|
||||
err = models.CreatePost(app.DB, post)
|
||||
if err != nil {
|
||||
log.Printf("Error creating post: %v", err)
|
||||
http.Error(w, "Failed to create post", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
posts, err := models.GetPostsByThreadID(app.DB, threadID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching posts: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Thread models.Thread
|
||||
Posts []models.Post
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - " + thread.Title,
|
||||
Navbar: "boards",
|
||||
LoggedIn: loggedIn,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Thread: *thread,
|
||||
Posts: posts,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
|
||||
log.Printf("Error executing template in ThreadHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func UserHomeHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, err := models.GetUserByID(app.DB, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching user in UserHomeHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := struct {
|
||||
PageData
|
||||
Username string
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - User Home",
|
||||
Navbar: "userhome",
|
||||
LoggedIn: true,
|
||||
ShowCookieBanner: false,
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Username: user.Username,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "userhome", data); err != nil {
|
||||
log.Printf("Error executing template in UserHomeHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,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
|
|
@ -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
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"NAVBAR":["file","macros/templates/navbar.template"],
|
||||
"BANNER_COOKIES":["file","macros/templates/banner_cookies.template"]
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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();"
|
||||
}
|
|
@ -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"]
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
?>
|
|
@ -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>
|
|
@ -0,0 +1,366 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"threadr/handlers"
|
||||
"threadr/models"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/gorilla/sessions"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func loadConfig(filename string) (*handlers.Config, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
var config handlers.Config
|
||||
err = json.NewDecoder(file).Decode(&config)
|
||||
return &config, err
|
||||
}
|
||||
|
||||
func createTablesIfNotExist(db *sql.DB) error {
|
||||
// Create boards table
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE boards (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
private BOOLEAN DEFAULT FALSE,
|
||||
public_visible BOOLEAN DEFAULT TRUE,
|
||||
pinned_threads TEXT,
|
||||
custom_landing_page TEXT,
|
||||
color_scheme VARCHAR(255)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating boards table: %v", err)
|
||||
}
|
||||
|
||||
// Create users table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(255),
|
||||
pfp_url VARCHAR(255),
|
||||
bio TEXT,
|
||||
authentication_string VARCHAR(128) NOT NULL,
|
||||
authentication_salt VARCHAR(255) NOT NULL,
|
||||
authentication_algorithm VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
permissions BIGINT DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating users table: %v", err)
|
||||
}
|
||||
|
||||
// Create threads table (without type field)
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE threads (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
board_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_by_user_id INT NOT NULL,
|
||||
accepted_answer_post_id INT,
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating threads table: %v", err)
|
||||
}
|
||||
|
||||
// Create posts table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
thread_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
post_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
edit_time TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
content TEXT,
|
||||
attachment_hash BIGINT,
|
||||
attachment_name VARCHAR(255),
|
||||
title VARCHAR(255),
|
||||
reply_to INT DEFAULT -1,
|
||||
FOREIGN KEY (thread_id) REFERENCES threads(id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating posts table: %v", err)
|
||||
}
|
||||
|
||||
// Create likes table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE likes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
UNIQUE KEY unique_like (post_id, user_id),
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating likes table: %v", err)
|
||||
}
|
||||
|
||||
// Create board_permissions table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE board_permissions (
|
||||
user_id INT NOT NULL,
|
||||
board_id INT NOT NULL,
|
||||
permissions BIGINT DEFAULT 0,
|
||||
PRIMARY KEY (user_id, board_id),
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating board_permissions table: %v", err)
|
||||
}
|
||||
|
||||
// Create notifications table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE notifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
related_id INT NOT NULL,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating notifications table: %v", err)
|
||||
}
|
||||
|
||||
// Create reactions table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE reactions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
emoji VARCHAR(10) NOT NULL,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating reactions table: %v", err)
|
||||
}
|
||||
|
||||
// Create reposts table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE reposts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
thread_id INT NOT NULL,
|
||||
board_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (thread_id) REFERENCES threads(id),
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating reposts table: %v", err)
|
||||
}
|
||||
|
||||
// Create news table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE news (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
posted_by INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating news table: %v", err)
|
||||
}
|
||||
|
||||
// Create chat_messages table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE chat_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
reply_to INT DEFAULT -1,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating chat_messages table: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Database tables created.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureAdminUser(db *sql.DB) error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
// Get username
|
||||
fmt.Print("Enter admin username: ")
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
// Check if user already exists
|
||||
existingUser, err := models.GetUserByUsername(db, username)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return fmt.Errorf("error checking for admin user: %v", err)
|
||||
}
|
||||
if existingUser != nil {
|
||||
return fmt.Errorf("user '%s' already exists", username)
|
||||
}
|
||||
|
||||
// Get password
|
||||
fmt.Print("Enter admin password: ")
|
||||
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading password: %v", err)
|
||||
}
|
||||
password := string(bytePassword)
|
||||
fmt.Println() // Newline after password input
|
||||
|
||||
// Confirm password
|
||||
fmt.Print("Confirm admin password: ")
|
||||
bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading password confirmation: %v", err)
|
||||
}
|
||||
passwordConfirm := string(bytePasswordConfirm)
|
||||
fmt.Println()
|
||||
|
||||
if password != passwordConfirm {
|
||||
return fmt.Errorf("passwords do not match")
|
||||
}
|
||||
|
||||
// Create user
|
||||
log.Printf("Creating admin user: %s", username)
|
||||
err = models.CreateUser(db, username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating admin user: %v", err)
|
||||
}
|
||||
|
||||
// Get the newly created admin user to update permissions
|
||||
adminUser, err := models.GetUserByUsername(db, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching new admin user: %v", err)
|
||||
}
|
||||
|
||||
// Set admin permissions (all permissions)
|
||||
_, err = db.Exec("UPDATE users SET permissions = ? WHERE id = ?",
|
||||
models.PermCreateBoard|models.PermManageUsers, adminUser.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting admin permissions: %v", err)
|
||||
}
|
||||
log.Println("Admin user created successfully with full permissions")
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Define command-line flag for initialization
|
||||
initialize := flag.Bool("initialize", false, "Initialize database tables and admin user")
|
||||
flag.BoolVar(initialize, "i", false, "Short for --initialize")
|
||||
flag.Parse()
|
||||
|
||||
config, err := loadConfig("config/config.json")
|
||||
if err != nil {
|
||||
log.Fatal("Error loading config:", err)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
log.Fatal("Error connecting to database:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Perform initialization if the flag is set
|
||||
if *initialize {
|
||||
log.Println("Initializing database...")
|
||||
err = createTablesIfNotExist(db)
|
||||
if err != nil {
|
||||
log.Fatal("Error creating database tables:", err)
|
||||
}
|
||||
|
||||
err = ensureAdminUser(db)
|
||||
if err != nil {
|
||||
log.Fatal("Error ensuring admin user:", err)
|
||||
}
|
||||
|
||||
log.Println("Initialization completed successfully. Exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
// Normal startup (without automatic table creation)
|
||||
log.Println("Starting ThreadR server...")
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal("Error getting working directory:", err)
|
||||
}
|
||||
|
||||
// Parse partial templates
|
||||
tmpl := template.Must(template.ParseFiles(
|
||||
filepath.Join(dir, "templates/partials/navbar.html"),
|
||||
filepath.Join(dir, "templates/partials/cookie_banner.html"),
|
||||
))
|
||||
|
||||
// Parse page-specific templates with unique names
|
||||
tmpl, err = tmpl.ParseFiles(
|
||||
filepath.Join(dir, "templates/pages/about.html"),
|
||||
filepath.Join(dir, "templates/pages/board.html"),
|
||||
filepath.Join(dir, "templates/pages/boards.html"),
|
||||
filepath.Join(dir, "templates/pages/home.html"),
|
||||
filepath.Join(dir, "templates/pages/login.html"),
|
||||
filepath.Join(dir, "templates/pages/news.html"),
|
||||
filepath.Join(dir, "templates/pages/profile.html"),
|
||||
filepath.Join(dir, "templates/pages/profile_edit.html"),
|
||||
filepath.Join(dir, "templates/pages/signup.html"),
|
||||
filepath.Join(dir, "templates/pages/thread.html"),
|
||||
filepath.Join(dir, "templates/pages/userhome.html"),
|
||||
filepath.Join(dir, "templates/pages/chat.html"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Error parsing page templates:", err)
|
||||
}
|
||||
|
||||
store := sessions.NewCookieStore([]byte("secret-key")) // Replace with secure key in production
|
||||
|
||||
app := &handlers.App{
|
||||
DB: db,
|
||||
Store: store,
|
||||
Config: config,
|
||||
Tmpl: tmpl,
|
||||
}
|
||||
|
||||
fs := http.FileServer(http.Dir("static"))
|
||||
http.Handle(config.ThreadrDir+"/static/", http.StripPrefix(config.ThreadrDir+"/static/", fs))
|
||||
|
||||
http.HandleFunc(config.ThreadrDir+"/", app.SessionMW(handlers.HomeHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/login/", app.SessionMW(handlers.LoginHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/logout/", app.SessionMW(handlers.LogoutHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/userhome/", app.SessionMW(app.RequireLoginMW(handlers.UserHomeHandler(app))))
|
||||
http.HandleFunc(config.ThreadrDir+"/boards/", app.SessionMW(handlers.BoardsHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/board/", app.SessionMW(handlers.BoardHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/thread/", app.SessionMW(handlers.ThreadHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/about/", app.SessionMW(handlers.AboutHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/profile/", app.SessionMW(app.RequireLoginMW(handlers.ProfileHandler(app))))
|
||||
http.HandleFunc(config.ThreadrDir+"/profile/edit/", app.SessionMW(app.RequireLoginMW(handlers.ProfileEditHandler(app))))
|
||||
http.HandleFunc(config.ThreadrDir+"/like/", app.SessionMW(app.RequireLoginMW(handlers.LikeHandler(app))))
|
||||
http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app))))
|
||||
|
||||
log.Println("Server starting on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type Board struct {
|
||||
ID int
|
||||
Name string
|
||||
Description string
|
||||
Private bool
|
||||
PublicVisible bool
|
||||
PinnedThreads []int // Stored as JSON
|
||||
CustomLandingPage string
|
||||
ColorScheme string
|
||||
}
|
||||
|
||||
func GetBoardByID(db *sql.DB, id int) (*Board, error) {
|
||||
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?"
|
||||
row := db.QueryRow(query, id)
|
||||
board := &Board{}
|
||||
var pinnedThreadsJSON sql.NullString
|
||||
var customLandingPage sql.NullString
|
||||
var colorScheme sql.NullString
|
||||
var description sql.NullString
|
||||
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if description.Valid {
|
||||
board.Description = description.String
|
||||
} else {
|
||||
board.Description = ""
|
||||
}
|
||||
if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" {
|
||||
err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if customLandingPage.Valid {
|
||||
board.CustomLandingPage = customLandingPage.String
|
||||
} else {
|
||||
board.CustomLandingPage = ""
|
||||
}
|
||||
if colorScheme.Valid {
|
||||
board.ColorScheme = colorScheme.String
|
||||
} else {
|
||||
board.ColorScheme = ""
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
|
||||
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC"
|
||||
rows, err := db.Query(query, private)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var boards []Board
|
||||
for rows.Next() {
|
||||
board := Board{}
|
||||
var pinnedThreadsJSON sql.NullString
|
||||
var customLandingPage sql.NullString
|
||||
var colorScheme sql.NullString
|
||||
var description sql.NullString
|
||||
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if description.Valid {
|
||||
board.Description = description.String
|
||||
} else {
|
||||
board.Description = ""
|
||||
}
|
||||
if pinnedThreadsJSON.Valid && pinnedThreadsJSON.String != "" {
|
||||
err = json.Unmarshal([]byte(pinnedThreadsJSON.String), &board.PinnedThreads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if customLandingPage.Valid {
|
||||
board.CustomLandingPage = customLandingPage.String
|
||||
} else {
|
||||
board.CustomLandingPage = ""
|
||||
}
|
||||
if colorScheme.Valid {
|
||||
board.ColorScheme = colorScheme.String
|
||||
} else {
|
||||
board.ColorScheme = ""
|
||||
}
|
||||
boards = append(boards, board)
|
||||
}
|
||||
return boards, nil
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package models
|
||||
|
||||
import "database/sql"
|
||||
|
||||
type BoardPermission struct {
|
||||
UserID int
|
||||
BoardID int
|
||||
Permissions int64
|
||||
}
|
||||
|
||||
func GetBoardPermission(db *sql.DB, userID, boardID int) (*BoardPermission, error) {
|
||||
query := "SELECT user_id, board_id, permissions FROM board_permissions WHERE user_id = ? AND board_id = ?"
|
||||
row := db.QueryRow(query, userID, boardID)
|
||||
bp := &BoardPermission{}
|
||||
err := row.Scan(&bp.UserID, &bp.BoardID, &bp.Permissions)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bp, nil
|
||||
}
|
||||
|
||||
func SetBoardPermission(db *sql.DB, bp BoardPermission) error {
|
||||
query := "INSERT INTO board_permissions (user_id, board_id, permissions) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE permissions = ?"
|
||||
_, err := db.Exec(query, bp.UserID, bp.BoardID, bp.Permissions, bp.Permissions)
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
PermPostInBoard int64 = 1 << 0
|
||||
PermModerateBoard int64 = 1 << 1
|
||||
PermViewBoard int64 = 1 << 2
|
||||
)
|
||||
|
||||
func HasBoardPermission(db *sql.DB, userID, boardID int, perm int64) (bool, error) {
|
||||
bp, err := GetBoardPermission(db, userID, boardID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if bp == nil {
|
||||
return false, nil
|
||||
}
|
||||
return bp.Permissions&perm != 0, nil
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
ID int
|
||||
UserID int
|
||||
Content string
|
||||
ReplyTo int // -1 if not a reply
|
||||
Timestamp time.Time
|
||||
Username string // For display, fetched from user
|
||||
PfpURL string // For display, fetched from user
|
||||
Mentions []string // List of mentioned usernames
|
||||
}
|
||||
|
||||
func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
|
||||
query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())"
|
||||
_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
|
||||
query := `
|
||||
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
|
||||
FROM chat_messages cm
|
||||
JOIN users u ON cm.user_id = u.id
|
||||
ORDER BY cm.timestamp DESC
|
||||
LIMIT ?`
|
||||
rows, err := db.Query(query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []ChatMessage
|
||||
for rows.Next() {
|
||||
var msg ChatMessage
|
||||
var timestampStr string
|
||||
var pfpURL sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
|
||||
if err != nil {
|
||||
msg.Timestamp = time.Time{}
|
||||
}
|
||||
if pfpURL.Valid {
|
||||
msg.PfpURL = pfpURL.String
|
||||
}
|
||||
// Parse mentions from content (simple @username detection)
|
||||
msg.Mentions = extractMentions(msg.Content)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
||||
query := `
|
||||
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
|
||||
FROM chat_messages cm
|
||||
JOIN users u ON cm.user_id = u.id
|
||||
WHERE cm.id = ?`
|
||||
row := db.QueryRow(query, id)
|
||||
var msg ChatMessage
|
||||
var timestampStr string
|
||||
var pfpURL sql.NullString
|
||||
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
|
||||
if err != nil {
|
||||
msg.Timestamp = time.Time{}
|
||||
}
|
||||
if pfpURL.Valid {
|
||||
msg.PfpURL = pfpURL.String
|
||||
}
|
||||
msg.Mentions = extractMentions(msg.Content)
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
|
||||
query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10"
|
||||
rows, err := db.Query(query, prefix+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var usernames []string
|
||||
for rows.Next() {
|
||||
var username string
|
||||
if err := rows.Scan(&username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usernames = append(usernames, username)
|
||||
}
|
||||
return usernames, nil
|
||||
}
|
||||
|
||||
// Simple utility to extract mentions from content
|
||||
func extractMentions(content string) []string {
|
||||
var mentions []string
|
||||
var currentMention string
|
||||
inMention := false
|
||||
|
||||
for _, char := range content {
|
||||
if char == '@' {
|
||||
inMention = true
|
||||
currentMention = "@"
|
||||
} else if inMention && (char == ' ' || char == '\n' || char == '\t') {
|
||||
if len(currentMention) > 1 {
|
||||
mentions = append(mentions, currentMention)
|
||||
}
|
||||
inMention = false
|
||||
currentMention = ""
|
||||
} else if inMention {
|
||||
currentMention += string(char)
|
||||
}
|
||||
}
|
||||
if inMention && len(currentMention) > 1 {
|
||||
mentions = append(mentions, currentMention)
|
||||
}
|
||||
return mentions
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package models
|
||||
|
||||
import "database/sql"
|
||||
|
||||
type Like struct {
|
||||
ID int
|
||||
PostID int
|
||||
UserID int
|
||||
Type string // "like" or "dislike"
|
||||
}
|
||||
|
||||
func GetLikesByPostID(db *sql.DB, postID int) ([]Like, error) {
|
||||
query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ?"
|
||||
rows, err := db.Query(query, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var likes []Like
|
||||
for rows.Next() {
|
||||
like := Like{}
|
||||
err := rows.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
likes = append(likes, like)
|
||||
}
|
||||
return likes, nil
|
||||
}
|
||||
|
||||
func GetLikeByPostAndUser(db *sql.DB, postID, userID int) (*Like, error) {
|
||||
query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ? AND user_id = ?"
|
||||
row := db.QueryRow(query, postID, userID)
|
||||
like := &Like{}
|
||||
err := row.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return like, nil
|
||||
}
|
||||
|
||||
func CreateLike(db *sql.DB, like Like) error {
|
||||
query := "INSERT INTO likes (post_id, user_id, type) VALUES (?, ?, ?)"
|
||||
_, err := db.Exec(query, like.PostID, like.UserID, like.Type)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateLikeType(db *sql.DB, postID, userID int, likeType string) error {
|
||||
query := "UPDATE likes SET type = ? WHERE post_id = ? AND user_id = ?"
|
||||
_, err := db.Exec(query, likeType, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteLike(db *sql.DB, postID, userID int) error {
|
||||
query := "DELETE FROM likes WHERE post_id = ? AND user_id = ?"
|
||||
_, err := db.Exec(query, postID, userID)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type News struct {
|
||||
ID int
|
||||
Title string
|
||||
Content string
|
||||
PostedBy int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func GetAllNews(db *sql.DB) ([]News, error) {
|
||||
query := "SELECT id, title, content, posted_by, created_at FROM news ORDER BY created_at DESC"
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var newsItems []News
|
||||
for rows.Next() {
|
||||
news := News{}
|
||||
var createdAtStr string
|
||||
err := rows.Scan(&news.ID, &news.Title, &news.Content, &news.PostedBy, &createdAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Parse the timestamp string into time.Time
|
||||
news.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr)
|
||||
if err != nil {
|
||||
// Fallback to a default time if parsing fails
|
||||
news.CreatedAt = time.Time{}
|
||||
}
|
||||
newsItems = append(newsItems, news)
|
||||
}
|
||||
return newsItems, nil
|
||||
}
|
||||
|
||||
func CreateNews(db *sql.DB, news News) error {
|
||||
query := "INSERT INTO news (title, content, posted_by, created_at) VALUES (?, ?, ?, NOW())"
|
||||
_, err := db.Exec(query, news.Title, news.Content, news.PostedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteNews(db *sql.DB, id int) error {
|
||||
query := "DELETE FROM news WHERE id = ?"
|
||||
_, err := db.Exec(query, id)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
ID int
|
||||
UserID int
|
||||
Type string
|
||||
RelatedID int
|
||||
CreatedAt time.Time
|
||||
Read bool
|
||||
}
|
||||
|
||||
func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) {
|
||||
query := "SELECT id, user_id, type, related_id, read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC"
|
||||
rows, err := db.Query(query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notifications []Notification
|
||||
for rows.Next() {
|
||||
notification := Notification{}
|
||||
err := rows.Scan(¬ification.ID, ¬ification.UserID, ¬ification.Type, ¬ification.RelatedID, ¬ification.Read, ¬ification.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// Stubbed for future implementation
|
||||
func CreateNotification(db *sql.DB, notification Notification) error {
|
||||
query := "INSERT INTO notifications (user_id, type, related_id, read, created_at) VALUES (?, ?, ?, ?, NOW())"
|
||||
_, err := db.Exec(query, notification.UserID, notification.Type, notification.RelatedID, notification.Read)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stubbed for future implementation
|
||||
func MarkNotificationAsRead(db *sql.DB, id int) error {
|
||||
query := "UPDATE notifications SET read = true WHERE id = ?"
|
||||
_, err := db.Exec(query, id)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID int
|
||||
ThreadID int
|
||||
UserID int
|
||||
PostTime time.Time
|
||||
EditTime *time.Time
|
||||
Content string
|
||||
AttachmentHash *int64
|
||||
AttachmentName *string
|
||||
Title string
|
||||
ReplyTo int
|
||||
}
|
||||
|
||||
func GetPostsByThreadID(db *sql.DB, threadID int) ([]Post, error) {
|
||||
query := "SELECT id, thread_id, user_id, post_time, edit_time, content, attachment_hash, attachment_name, title, reply_to FROM posts WHERE thread_id = ? ORDER BY post_time ASC"
|
||||
rows, err := db.Query(query, threadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []Post
|
||||
for rows.Next() {
|
||||
post := Post{}
|
||||
var postTimeStr string
|
||||
var editTimeStr sql.NullString
|
||||
err := rows.Scan(&post.ID, &post.ThreadID, &post.UserID, &postTimeStr, &editTimeStr, &post.Content, &post.AttachmentHash, &post.AttachmentName, &post.Title, &post.ReplyTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
post.PostTime, err = time.Parse("2006-01-02 15:04:05", postTimeStr)
|
||||
if err != nil {
|
||||
post.PostTime = time.Time{}
|
||||
}
|
||||
if editTimeStr.Valid {
|
||||
editTime, err := time.Parse("2006-01-02 15:04:05", editTimeStr.String)
|
||||
if err != nil {
|
||||
post.EditTime = nil
|
||||
} else {
|
||||
post.EditTime = &editTime
|
||||
}
|
||||
} else {
|
||||
post.EditTime = nil
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func CreatePost(db *sql.DB, post Post) error {
|
||||
query := "INSERT INTO posts (thread_id, user_id, content, title, reply_to, post_time) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||
_, err := db.Exec(query, post.ThreadID, post.UserID, post.Content, post.Title, post.ReplyTo)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package models
|
||||
|
||||
import "database/sql"
|
||||
|
||||
type Reaction struct {
|
||||
ID int
|
||||
PostID int
|
||||
UserID int
|
||||
Emoji string
|
||||
}
|
||||
|
||||
func GetReactionsByPostID(db *sql.DB, postID int) ([]Reaction, error) {
|
||||
query := "SELECT id, post_id, user_id, emoji FROM reactions WHERE post_id = ?"
|
||||
rows, err := db.Query(query, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reactions []Reaction
|
||||
for rows.Next() {
|
||||
reaction := Reaction{}
|
||||
err := rows.Scan(&reaction.ID, &reaction.PostID, &reaction.UserID, &reaction.Emoji)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reactions = append(reactions, reaction)
|
||||
}
|
||||
return reactions, nil
|
||||
}
|
||||
|
||||
// Stubbed for future implementation
|
||||
func CreateReaction(db *sql.DB, reaction Reaction) error {
|
||||
query := "INSERT INTO reactions (post_id, user_id, emoji) VALUES (?, ?, ?)"
|
||||
_, err := db.Exec(query, reaction.PostID, reaction.UserID, reaction.Emoji)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stubbed for future implementation
|
||||
func DeleteReaction(db *sql.DB, postID, userID int, emoji string) error {
|
||||
query := "DELETE FROM reactions WHERE post_id = ? AND user_id = ? AND emoji = ?"
|
||||
_, err := db.Exec(query, postID, userID, emoji)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repost struct {
|
||||
ID int
|
||||
ThreadID int
|
||||
BoardID int
|
||||
UserID int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func GetRepostsByThreadID(db *sql.DB, threadID int) ([]Repost, error) {
|
||||
query := "SELECT id, thread_id, board_id, user_id, created_at FROM reposts WHERE thread_id = ?"
|
||||
rows, err := db.Query(query, threadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reposts []Repost
|
||||
for rows.Next() {
|
||||
repost := Repost{}
|
||||
err := rows.Scan(&repost.ID, &repost.ThreadID, &repost.BoardID, &repost.UserID, &repost.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reposts = append(reposts, repost)
|
||||
}
|
||||
return reposts, nil
|
||||
}
|
||||
|
||||
// Stubbed for future implementation
|
||||
func CreateRepost(db *sql.DB, repost Repost) error {
|
||||
query := "INSERT INTO reposts (thread_id, board_id, user_id, created_at) VALUES (?, ?, ?, NOW())"
|
||||
_, err := db.Exec(query, repost.ThreadID, repost.BoardID, repost.UserID)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Thread struct {
|
||||
ID int
|
||||
BoardID int
|
||||
Title string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedByUserID int
|
||||
AcceptedAnswerPostID *int
|
||||
}
|
||||
|
||||
func GetThreadByID(db *sql.DB, id int) (*Thread, error) {
|
||||
query := "SELECT id, board_id, title, created_at, updated_at, created_by_user_id, accepted_answer_post_id FROM threads WHERE id = ?"
|
||||
row := db.QueryRow(query, id)
|
||||
thread := &Thread{}
|
||||
var createdAtStr string
|
||||
var updatedAtStr string
|
||||
err := row.Scan(&thread.ID, &thread.BoardID, &thread.Title, &createdAtStr, &updatedAtStr, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
thread.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr)
|
||||
if err != nil {
|
||||
thread.CreatedAt = time.Time{}
|
||||
}
|
||||
thread.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtStr)
|
||||
if err != nil {
|
||||
thread.UpdatedAt = time.Time{}
|
||||
}
|
||||
return thread, nil
|
||||
}
|
||||
|
||||
func GetThreadsByBoardID(db *sql.DB, boardID int) ([]Thread, error) {
|
||||
query := "SELECT id, board_id, title, created_at, updated_at, created_by_user_id, accepted_answer_post_id FROM threads WHERE board_id = ? ORDER BY updated_at DESC"
|
||||
rows, err := db.Query(query, boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var threads []Thread
|
||||
for rows.Next() {
|
||||
thread := Thread{}
|
||||
var createdAtStr string
|
||||
var updatedAtStr string
|
||||
err := rows.Scan(&thread.ID, &thread.BoardID, &thread.Title, &createdAtStr, &updatedAtStr, &thread.CreatedByUserID, &thread.AcceptedAnswerPostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
thread.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtStr)
|
||||
if err != nil {
|
||||
thread.CreatedAt = time.Time{}
|
||||
}
|
||||
thread.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtStr)
|
||||
if err != nil {
|
||||
thread.UpdatedAt = time.Time{}
|
||||
}
|
||||
threads = append(threads, thread)
|
||||
}
|
||||
return threads, nil
|
||||
}
|
||||
|
||||
func CreateThread(db *sql.DB, thread Thread) error {
|
||||
query := "INSERT INTO threads (board_id, title, created_by_user_id, created_at, updated_at, type) VALUES (?, ?, ?, NOW(), NOW(), 'classic')"
|
||||
_, err := db.Exec(query, thread.BoardID, thread.Title, thread.CreatedByUserID)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Username string
|
||||
DisplayName string
|
||||
PfpURL string
|
||||
Bio string
|
||||
AuthenticationString string
|
||||
AuthenticationSalt string
|
||||
AuthenticationAlgorithm string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Verified bool
|
||||
Permissions int64
|
||||
}
|
||||
|
||||
func GetUserByID(db *sql.DB, id int) (*User, error) {
|
||||
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE id = ?"
|
||||
row := db.QueryRow(query, id)
|
||||
user := &User{}
|
||||
var displayName sql.NullString
|
||||
var pfpURL sql.NullString
|
||||
var bio sql.NullString
|
||||
var createdAtString sql.NullString
|
||||
var updatedAtString sql.NullString
|
||||
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if displayName.Valid {
|
||||
user.DisplayName = displayName.String
|
||||
} else {
|
||||
user.DisplayName = ""
|
||||
}
|
||||
if pfpURL.Valid {
|
||||
user.PfpURL = pfpURL.String
|
||||
} else {
|
||||
user.PfpURL = ""
|
||||
}
|
||||
if bio.Valid {
|
||||
user.Bio = bio.String
|
||||
} else {
|
||||
user.Bio = ""
|
||||
}
|
||||
if createdAtString.Valid {
|
||||
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing created_at: %v", err)
|
||||
}
|
||||
} else {
|
||||
user.CreatedAt = time.Time{}
|
||||
}
|
||||
if updatedAtString.Valid {
|
||||
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing updated_at: %v", err)
|
||||
}
|
||||
} else {
|
||||
user.UpdatedAt = time.Time{}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||
query := "SELECT id, username, display_name, pfp_url, bio, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions FROM users WHERE username = ?"
|
||||
row := db.QueryRow(query, username)
|
||||
user := &User{}
|
||||
var displayName sql.NullString
|
||||
var pfpURL sql.NullString
|
||||
var bio sql.NullString
|
||||
var createdAtString sql.NullString
|
||||
var updatedAtString sql.NullString
|
||||
err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if displayName.Valid {
|
||||
user.DisplayName = displayName.String
|
||||
} else {
|
||||
user.DisplayName = ""
|
||||
}
|
||||
if pfpURL.Valid {
|
||||
user.PfpURL = pfpURL.String
|
||||
} else {
|
||||
user.PfpURL = ""
|
||||
}
|
||||
if bio.Valid {
|
||||
user.Bio = bio.String
|
||||
} else {
|
||||
user.Bio = ""
|
||||
}
|
||||
if createdAtString.Valid {
|
||||
user.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAtString.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing created_at: %v", err)
|
||||
}
|
||||
} else {
|
||||
user.CreatedAt = time.Time{}
|
||||
}
|
||||
if updatedAtString.Valid {
|
||||
user.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAtString.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing updated_at: %v", err)
|
||||
}
|
||||
} else {
|
||||
user.UpdatedAt = time.Time{}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func CheckPassword(password, salt, algorithm, hash string) bool {
|
||||
if algorithm != "sha256" {
|
||||
return false
|
||||
}
|
||||
computedHash := HashPassword(password, salt, algorithm)
|
||||
return computedHash == hash
|
||||
}
|
||||
|
||||
func HashPassword(password, salt, algorithm string) string {
|
||||
if algorithm != "sha256" {
|
||||
return ""
|
||||
}
|
||||
data := password + salt
|
||||
hash := sha256.Sum256([]byte(data))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
func CreateUser(db *sql.DB, username, password string) error {
|
||||
salt := "random-salt" // Replace with secure random generation
|
||||
algorithm := "sha256"
|
||||
hash := HashPassword(password, salt, algorithm)
|
||||
query := "INSERT INTO users (username, authentication_string, authentication_salt, authentication_algorithm, created_at, updated_at, verified, permissions) VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)"
|
||||
_, err := db.Exec(query, username, hash, salt, algorithm, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateUserProfile(db *sql.DB, userID int, displayName, pfpURL, bio string) error {
|
||||
query := "UPDATE users SET display_name = ?, pfp_url = ?, bio = ?, updated_at = NOW() WHERE id = ?"
|
||||
_, err := db.Exec(query, displayName, pfpURL, bio, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
PermCreateBoard int64 = 1 << 0
|
||||
PermManageUsers int64 = 1 << 1
|
||||
)
|
||||
|
||||
func HasGlobalPermission(user *User, perm int64) bool {
|
||||
return user.Permissions&perm != 0
|
||||
}
|
|
@ -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 user’s 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 ThreadR’s landing page
|
|
@ -1,5 +0,0 @@
|
|||
# Short documentation of all files
|
||||
### [index.php](./index.php)
|
||||
Self-Explanatory
|
||||
### [README.md](./README.md)
|
||||
this 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>
|
|
@ -1,4 +0,0 @@
|
|||
<?php
|
||||
setcookie('threadr_cookie_banner', $expires=time()+2592000);
|
||||
header("Location: https://%DOMAIN_NAME%" . urldecode($_GET['from']));
|
||||
?>
|
|
@ -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
|
|
@ -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>";
|
||||
}
|
||||
?>
|
||||
|
|
@ -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>";
|
||||
?>
|
|
@ -1,4 +0,0 @@
|
|||
<?php
|
||||
%REQUIRE_LOGIN%
|
||||
/*edit.php*/
|
||||
?>
|
|
@ -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>
|
|
@ -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>";
|
||||
?>
|
|
@ -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>";
|
||||
?>
|
|
@ -1,5 +0,0 @@
|
|||
# Short description of each file
|
||||
### [index.php](./index.php)
|
||||
obvious, hopefully
|
||||
### [README.md](./README.md)
|
||||
this 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>
|
|
@ -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
|
|
@ -1,9 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;%CONTENT_DIR%/" />
|
||||
</head>
|
||||
<body>
|
||||
<p> Nope. </p>
|
||||
</body>
|
||||
</html>
|
|
@ -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>
|
|
@ -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 user’s personal feed page
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
||||
?>
|
|
@ -1,5 +0,0 @@
|
|||
# Short documentation of all files
|
||||
### [index.php](./index.php)
|
||||
Self-Explanatory
|
||||
### [README.md](./README.md)
|
||||
this 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>
|
|
@ -1,5 +0,0 @@
|
|||
# Short description of each file
|
||||
### [index.php](./index.php)
|
||||
Well, guess what?
|
||||
### [README.md](./README.md)
|
||||
this 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't bother. </pre></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
%BANNER_COOKIES%
|
||||
</body>
|
||||
</html>
|
|
@ -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
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
|
@ -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>
|
|
@ -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
|
|
@ -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>
|
|
@ -1,3 +0,0 @@
|
|||
<?php
|
||||
%PLEAZE_NO_CACHE%
|
||||
?>
|
406
src/style.css
|
@ -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;
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
## Short documentation of all files
|
||||
### [index.php](./index.php)
|
||||
Self-Explanatory
|
||||
### [README.md](./README.md)
|
||||
this 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>
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 752 B |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,371 @@
|
|||
body {
|
||||
font-family: monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #fef6e4; /* beige */
|
||||
color: #001858; /* blue */
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
main > header {
|
||||
text-align: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
main > section {
|
||||
margin: 1em;
|
||||
padding: 14px 20px;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 5px;
|
||||
background-color: #f3d2c1; /* orange */
|
||||
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
main > div {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
main > div > article {
|
||||
border: 1px solid #001858;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 1em;
|
||||
background-color: #fef6e4;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
main > div > article:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0px 4px 12px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
article > header {
|
||||
border-bottom: 1px solid #001858;
|
||||
background-color: #001858;
|
||||
color: #fef6e4;
|
||||
padding: 0.5em;
|
||||
margin: -14px -20px 1em -20px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
ul.topnav {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #001858;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-shadow: 0 0.7em 1.2em 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
ul.topnav li {
|
||||
float: left;
|
||||
}
|
||||
|
||||
ul.topnav li a {
|
||||
display: block;
|
||||
color: #fef6e4;
|
||||
text-align: center;
|
||||
padding: 1.2em 1.3em;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
ul.topnav li a:hover:not(.active) {
|
||||
background-color: #8bd3dd; /* cyan */
|
||||
}
|
||||
|
||||
ul.topnav li a.active {
|
||||
background-color: #f582ae; /* pink */
|
||||
}
|
||||
|
||||
div.topnav {
|
||||
height: 3em;
|
||||
}
|
||||
|
||||
div.banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #001858;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.banner p {
|
||||
color: #fef6e4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div.banner a {
|
||||
color: #8bd3dd;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
margin: 8px 0;
|
||||
padding: 14px 20px;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 4px;
|
||||
background-color: #fef6e4;
|
||||
color: #001858;
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 0px 8px 16px 0px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
input[type="submit"] {
|
||||
background-color: #001858;
|
||||
color: #fef6e4;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="submit"]:hover {
|
||||
background-color: #8bd3dd;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 8px 0;
|
||||
padding: 14px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #001858;
|
||||
color: #fef6e4;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #8bd3dd;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: monospace;
|
||||
color: #001858;
|
||||
}
|
||||
|
||||
p, a, li {
|
||||
font-family: monospace;
|
||||
color: #001858;
|
||||
}
|
||||
|
||||
/* Enhanced styles for boards */
|
||||
ul.board-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li.board-item {
|
||||
margin-bottom: 1em;
|
||||
padding: 1em;
|
||||
background-color: #fef6e4;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
li.board-item:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
li.board-item a {
|
||||
color: #001858;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
li.board-item a:hover {
|
||||
color: #f582ae;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
p.board-desc {
|
||||
margin: 0.5em 0 0 0;
|
||||
color: #001858;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Enhanced styles for thread posts */
|
||||
.thread-posts {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
background-color: #fef6e4;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1em;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.post-item:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0px 6px 14px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.post-item header {
|
||||
background-color: #001858;
|
||||
color: #fef6e4;
|
||||
padding: 0.5em;
|
||||
margin: -1em -1em 1em -1em;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border-bottom: 1px solid #001858;
|
||||
}
|
||||
|
||||
.post-item header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.post-item header p {
|
||||
margin: 0.3em 0 0 0;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
margin: 0;
|
||||
padding: 0.5em;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
margin-top: 0.8em;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.post-actions a {
|
||||
color: #001858;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
padding: 0.3em 0.6em;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.post-actions a:hover {
|
||||
background-color: #8bd3dd;
|
||||
color: #fef6e4;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #333;
|
||||
color: #fef6e4;
|
||||
}
|
||||
main > section {
|
||||
background-color: #555;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
main > div > article {
|
||||
background-color: #444;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
article > header {
|
||||
background-color: #222;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
input, textarea, select {
|
||||
background-color: #444;
|
||||
color: #fef6e4;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
input[type="submit"], button {
|
||||
background-color: #fef6e4;
|
||||
color: #001858;
|
||||
}
|
||||
input[type="submit"]:hover, button:hover {
|
||||
background-color: #8bd3dd;
|
||||
}
|
||||
li.board-item {
|
||||
background-color: #444;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
li.board-item a {
|
||||
color: #fef6e4;
|
||||
}
|
||||
li.board-item a:hover {
|
||||
color: #f582ae;
|
||||
}
|
||||
p.board-desc {
|
||||
color: #fef6e4;
|
||||
}
|
||||
.post-item {
|
||||
background-color: #444;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.post-content {
|
||||
color: #fef6e4;
|
||||
}
|
||||
.post-actions a {
|
||||
color: #fef6e4;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.post-actions a:hover {
|
||||
background-color: #8bd3dd;
|
||||
color: #001858;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #fef6e4;
|
||||
}
|
||||
p, a, li {
|
||||
color: #fef6e4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
ul.topnav li {
|
||||
float: none;
|
||||
width: 100%;
|
||||
}
|
||||
main {
|
||||
padding: 10px;
|
||||
}
|
||||
main > section {
|
||||
margin: 0.5em;
|
||||
padding: 0.5em;
|
||||
width: 95%;
|
||||
}
|
||||
main > div {
|
||||
width: 95%;
|
||||
}
|
||||
.thread-posts {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
{{block "content" .}}{{end}} <!-- Define a block for content -->
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -0,0 +1,14 @@
|
|||
{{define "about"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
{{.AboutContent}}
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -0,0 +1,41 @@
|
|||
{{define "board"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
<header>
|
||||
<h2>{{.Board.Name}}</h2>
|
||||
<p>{{.Board.Description}}</p>
|
||||
</header>
|
||||
<section>
|
||||
<h3>Threads</h3>
|
||||
{{if .Threads}}
|
||||
<ul>
|
||||
{{range .Threads}}
|
||||
<li><a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a> - Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>No threads available in this board yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{if .LoggedIn}}
|
||||
<section>
|
||||
<h3>Create New Thread</h3>
|
||||
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
|
||||
<label for="title">Thread Title:</label>
|
||||
<input type="text" id="title" name="title" required><br>
|
||||
<input type="submit" value="Create Thread">
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -0,0 +1,62 @@
|
|||
{{define "boards"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
<header>
|
||||
<h2>Boards</h2>
|
||||
</header>
|
||||
<section>
|
||||
<h3>Public Boards</h3>
|
||||
{{if .PublicBoards}}
|
||||
<ul class="board-list">
|
||||
{{range .PublicBoards}}
|
||||
<li class="board-item">
|
||||
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
|
||||
<p class="board-desc">{{.Description}}</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>No public boards available at the moment.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{if .LoggedIn}}
|
||||
<section>
|
||||
<h3>Private Boards</h3>
|
||||
{{if .PrivateBoards}}
|
||||
<ul class="board-list">
|
||||
{{range .PrivateBoards}}
|
||||
<li class="board-item">
|
||||
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
|
||||
<p class="board-desc">{{.Description}}</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>No private boards available to you at the moment.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .IsAdmin}}
|
||||
<section>
|
||||
<h3>Create New Public Board</h3>
|
||||
<form method="post" action="{{.BasePath}}/boards/">
|
||||
<label for="name">Board Name:</label>
|
||||
<input type="text" id="name" name="name" required><br>
|
||||
<label for="description">Description:</label>
|
||||
<textarea id="description" name="description"></textarea><br>
|
||||
<input type="submit" value="Create Board">
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -0,0 +1,435 @@
|
|||
{{define "chat"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
padding: 0;
|
||||
margin-top: 3em; /* Space for navbar */
|
||||
height: calc(100vh - 3em);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
height: calc(100% - 2em); /* Adjust for header */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: #fef6e4;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-message {
|
||||
margin-bottom: 8px;
|
||||
max-width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.chat-message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.chat-message-pfp {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.chat-message-username {
|
||||
font-weight: bold;
|
||||
color: #001858;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-message-timestamp {
|
||||
font-size: 0.7em;
|
||||
color: #666;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.chat-message-content {
|
||||
background-color: #f3d2c1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.3;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-message-reply {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 3px;
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-message-mention {
|
||||
color: #f582ae;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chat-input {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #001858;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-input textarea {
|
||||
resize: none;
|
||||
height: 50px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-input button {
|
||||
align-self: flex-end;
|
||||
width: auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.post-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.chat-message:hover .post-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.post-actions a {
|
||||
color: #001858;
|
||||
text-decoration: none;
|
||||
font-size: 0.8em;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.post-actions a:hover {
|
||||
background-color: #8bd3dd;
|
||||
color: #fef6e4;
|
||||
}
|
||||
.autocomplete-popup {
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 5px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
.autocomplete-item {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.autocomplete-item:hover {
|
||||
background-color: #f3d2c1;
|
||||
}
|
||||
.reply-indicator {
|
||||
background-color: #001858;
|
||||
color: #fef6e4;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 8px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.reply-indicator span {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.reply-indicator button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fef6e4;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
padding: 0 5px;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
}
|
||||
.reply-indicator button:hover {
|
||||
background: none;
|
||||
color: #f582ae;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-container {
|
||||
background-color: #444;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.chat-message-username {
|
||||
color: #fef6e4;
|
||||
}
|
||||
.chat-message-timestamp {
|
||||
color: #aaa;
|
||||
}
|
||||
.chat-message-content {
|
||||
background-color: #555;
|
||||
}
|
||||
.chat-input {
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.autocomplete-popup {
|
||||
background-color: #444;
|
||||
border-color: #fef6e4;
|
||||
color: #fef6e4;
|
||||
}
|
||||
.autocomplete-item:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
.post-actions a {
|
||||
color: #fef6e4;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.post-actions a:hover {
|
||||
background-color: #8bd3dd;
|
||||
color: #001858;
|
||||
}
|
||||
.reply-indicator {
|
||||
background-color: #222;
|
||||
color: #fef6e4;
|
||||
}
|
||||
.reply-indicator button {
|
||||
color: #fef6e4;
|
||||
}
|
||||
.reply-indicator button:hover {
|
||||
color: #f582ae;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
<header style="display: none;">
|
||||
<h2>General Chat</h2>
|
||||
</header>
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
{{range .Messages}}
|
||||
<div class="chat-message" id="msg-{{.ID}}">
|
||||
<div class="chat-message-header">
|
||||
{{if .PfpURL}}
|
||||
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp">
|
||||
{{else}}
|
||||
<div class="chat-message-pfp" style="background-color: #001858;"></div>
|
||||
{{end}}
|
||||
<span class="chat-message-username">{{.Username}}</span>
|
||||
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
|
||||
</div>
|
||||
{{if gt .ReplyTo 0}}
|
||||
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div>
|
||||
{{end}}
|
||||
<div class="chat-message-content">{{.Content | html}}</div>
|
||||
<div class="post-actions">
|
||||
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<div id="reply-indicator" class="reply-indicator">
|
||||
<span id="reply-username">Replying to </span>
|
||||
<button onclick="cancelReply()">X</button>
|
||||
</div>
|
||||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
||||
<button onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
<script>
|
||||
let ws;
|
||||
let autocompleteActive = false;
|
||||
let autocompletePrefix = '';
|
||||
let replyToId = -1;
|
||||
let replyUsername = '';
|
||||
|
||||
function connectWebSocket() {
|
||||
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' });
|
||||
ws.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
appendMessage(msg);
|
||||
};
|
||||
ws.onclose = function() {
|
||||
console.log("WebSocket closed, reconnecting...");
|
||||
setTimeout(connectWebSocket, 5000); // Reconnect after 5s
|
||||
};
|
||||
ws.onerror = function(error) {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('chat-input-text');
|
||||
const content = input.value.trim();
|
||||
if (content === '') return;
|
||||
const msg = {
|
||||
type: 'message',
|
||||
content: content,
|
||||
replyTo: replyToId
|
||||
};
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
input.value = '';
|
||||
cancelReply(); // Reset reply state after sending
|
||||
} else {
|
||||
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
const messages = document.getElementById('chat-messages');
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.className = 'chat-message';
|
||||
msgDiv.id = 'msg-' + msg.ID;
|
||||
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP" class="chat-message-pfp">` : `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
|
||||
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : '';
|
||||
// Process content for mentions
|
||||
let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
|
||||
msgDiv.innerHTML = `
|
||||
<div class="chat-message-header">
|
||||
${pfpHTML}
|
||||
<span class="chat-message-username">${msg.Username}</span>
|
||||
<span class="chat-message-timestamp">${new Date(msg.Timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
${replyHTML}
|
||||
<div class="chat-message-content">${content}</div>
|
||||
<div class="post-actions">
|
||||
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a>
|
||||
</div>
|
||||
`;
|
||||
messages.appendChild(msgDiv);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function replyToMessage(id, username) {
|
||||
replyToId = id;
|
||||
replyUsername = username;
|
||||
const replyIndicator = document.getElementById('reply-indicator');
|
||||
const replyUsernameSpan = document.getElementById('reply-username');
|
||||
replyUsernameSpan.textContent = `Replying to ${username}`;
|
||||
replyIndicator.style.display = 'flex';
|
||||
document.getElementById('chat-input-text').focus();
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
replyToId = -1;
|
||||
replyUsername = '';
|
||||
const replyIndicator = document.getElementById('reply-indicator');
|
||||
replyIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
function scrollToMessage(id) {
|
||||
const msgElement = document.getElementById('msg-' + id);
|
||||
if (msgElement) {
|
||||
msgElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function showAutocompletePopup(usernames, x, y) {
|
||||
const popup = document.getElementById('autocomplete-popup');
|
||||
popup.innerHTML = '';
|
||||
popup.style.left = x + 'px';
|
||||
popup.style.top = y + 'px';
|
||||
popup.style.display = 'block';
|
||||
autocompleteActive = true;
|
||||
usernames.forEach(username => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'autocomplete-item';
|
||||
item.textContent = username;
|
||||
item.onclick = () => {
|
||||
completeMention(username);
|
||||
popup.style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
};
|
||||
popup.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function completeMention(username) {
|
||||
const input = document.getElementById('chat-input-text');
|
||||
const text = input.value;
|
||||
const atIndex = text.lastIndexOf('@', input.selectionStart - 1);
|
||||
if (atIndex !== -1) {
|
||||
const before = text.substring(0, atIndex);
|
||||
const after = text.substring(input.selectionStart);
|
||||
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
|
||||
const text = e.target.value;
|
||||
const caretPos = e.target.selectionStart;
|
||||
const atIndex = text.lastIndexOf('@', caretPos - 1);
|
||||
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) {
|
||||
const prefix = text.substring(atIndex + 1, caretPos);
|
||||
autocompletePrefix = prefix;
|
||||
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
|
||||
const usernames = await response.json();
|
||||
if (usernames.length > 0) {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
// Approximate caret position (this is a rough estimate)
|
||||
const charWidth = 8; // Rough estimate of character width in pixels
|
||||
const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth;
|
||||
showAutocompletePopup(usernames, caretX, rect.top - 10);
|
||||
} else {
|
||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
}
|
||||
} else {
|
||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
|
||||
if (autocompleteActive) {
|
||||
const popup = document.getElementById('autocomplete-popup');
|
||||
const items = popup.getElementsByClassName('autocomplete-item');
|
||||
if (e.key === 'Enter' && items.length > 0) {
|
||||
items[0].click();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowDown' && items.length > 0) {
|
||||
items[0].focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
sendMessage();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
|
||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Connect WebSocket on page load
|
||||
window.onload = function() {
|
||||
connectWebSocket();
|
||||
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -0,0 +1,21 @@
|
|||
{{define "home"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
<header>
|
||||
<h1><center>Welcome to ThreadR</center></h1>
|
||||
</header>
|
||||
<section>
|
||||
<img src="{{.StaticPath}}/img/ThreadR.png" alt="ThreadR" height="100%" width="100%">
|
||||
</section>
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -0,0 +1,30 @@
|
|||
{{define "login"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
<header>
|
||||
<h2>Login</h2>
|
||||
</header>
|
||||
<section>
|
||||
{{if .Error}}
|
||||
<p style="color: red;">{{.Error}}</p>
|
||||
{{end}}
|
||||
<form method="post" action="{{.BasePath}}/login/">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" name="username" required><br>
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" required><br>
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|