Compare commits

...

10 Commits

Author SHA1 Message Date
66f40e5e97 Add LLM disclosure to README (#79)
Some checks failed
Run unit tests / run-unit-tests (push) Has been cancelled
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/79
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-18 13:09:18 +00:00
02f46cc08c update-docs (#74)
Update docs for 1.0 release

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/74
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-16 15:57:58 +00:00
d60230f975 Only show delete icon when logged in. (#73)
Check for a valid login session before showing delete icons on ticks.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/73
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-15 18:43:31 +00:00
d03c0a5331 Move feeds out of subpath. (#72)
Flatten feed location.

Now, if people decide to host this at my-domain/feed, the feeds won't be at my-domain/feed/feed

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/72
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-15 01:44:50 +00:00
dbd27b266d Gracefully handle validation errors in CSS and Emoji pages. (#71)
Handle CSS and Validation emoji errors so users get descriptive messages and are able to return to the application.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/71
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-14 19:44:41 +00:00
f96616bcef Add a bad docker config to test the setup error page (#70)
Add a configuration with apache and php, but no sqlite. Confirm that the setup error page displays on first load.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/70
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-14 11:54:36 +00:00
eeb73eccd4 Fix feed links, clean up single tick pages (#69)
Fix feed links. Clean up single-tick page.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/69
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-14 01:10:35 +00:00
d3a537aa6c Fix first-time setup issues. (#68)
Fixes for issues found testing first time setup in the different configurations.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/68
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-13 12:02:37 +00:00
801bbebf4f configure-delete-window (#66)
Let users configure the amount of time that a tick can be deleted

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/66
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-11 00:34:06 +00:00
86abf587f6 simplify web server configs (#65)
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/65
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-10 22:13:45 +00:00
54 changed files with 1155 additions and 987 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ storage/logs
# Testing stuff
/docker-compose.yml
scratch
storage.bak
# Build artifacts
tkr.tgz

229
README.md
View File

@ -2,95 +2,77 @@
![Prerequisite tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/prerequisites.yaml/badge.svg)
![Unit tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/unit_tests.yaml/badge.svg)
A lightweight, HTML-only status feed for self-hosted personal websites. Written in PHP. Heavily inspired by [status.cafe](https://status.cafe).
A simple, HTML-only status feed for self-hosted personal websites. Written in PHP. Heavily inspired by [status.cafe](https://status.cafe).
## Screenshots
## LLM Disclosure
### Mobile
I used Claude for guidance on some portions of this. I limited it to things that I couldn't find good answers to otherwise, and I used it as a learning tool. I've adapted any LLM-generated code suggestions, but if you strongly object to LLMs and don't want any LLM-assisted code on your site, then you shouldn't use this.
<img src="https://subcultureofone.org/images/tkr/tkr-logged-out-mobile-v4.png"
alt="tkr logged out view - mobile"
width="40%" height="40%">
<img src="https://subcultureofone.org/images/tkr/tkr-logged-in-mobile-v4.png"
alt="tkr logged in view - mobile"
width="40%" height="40%">
## Requirements
### Desktop
<img src="https://subcultureofone.org/images/tkr/tkr-logged-out-desktop-v4.png"
alt="tkr logged in view - desktop"
width="60%" height="60%">
<img src="https://subcultureofone.org/images/tkr/tkr-logged-in-desktop-v4.png"
alt="tkr logged in view - desktop"
width="60%" height="60%">
## Features
* HTML and CSS implementation. No Javascript.
* Accessible by default
* RSS `/feed/rss` and Atom `/feed/atom` feeds
* CSS uploads for custom theming
* Custom emoji to personalize moods (unicode only)
I'm trying to make sure that the HTML is both semantically valid and accessible, but I have a lot to learn about both. If you see something I should fix, please let me know!
## Prerequisites
* A web server with PHP support, such as:
* Apache with mod_php
* nginx and php-fpm
* PHP 8.2+ with the PDO and PDO_SQLITE extensions
* The PDO and PDO_SQLITE extensions are usually included by default
* This might work with earlier PHP versions, but I've only tested 8.2
* A server running Linux
* A web server, such as apache or nginx
* Web server configs are in the `examples` directory
* PHP 8.2+
* Required extensions:
* [PDO](https://www.php.net/pdo)
* [PDO_SQLITE](https://www.php.net/pdo-sqlite)
* Recommended extensions:
* [mbstring](https://www.php.net/mbstring)
* [fileinfo](https://www.php.net/fileinfo)
## Installation
1. Download the latest tkr archive from [the packages page](https://gitea.subcultureofone.org/greg/tkr/packages)
1. Copy the `.tgz` file to your server and extract it
1. Copy the `tkr` directory to the location you want to serve it from
* on debian-based systems, `/var/www/tkr` is recommended
1. Make the `storage` directory writable by the web server account.
```sh
chown www-data:www-data /path/to/tkr/storage
chmod 0770 /path/to/tkr/storage
```
1. Add the necessary web server configuration.
* Examples for common scenarios can be found in the [examples](./examples) directory.
* Apache VPS, subdomain (e.g. `https://tkr.your-domain.com`): [examples/apache/vps/root](./examples/apache/vps/root)
* Apache VPS, subfolder (e.g. `https://your-domain.com/tkr`): [examples/apache/vps/subfolder](./examples/apache/vps/subfolder)
* Nginx VPS, subdomain (e.g. `https://tkr.your-domain.com`): [examples/nginx/root](./examples/nginx/root)
* Nginx VPS, subfolder (e.g. `https://your-domain.com/tkr`): [examples/nginx/subfolder](./examples/nginx/subfolder)
* Any values that need to be configured for your environment are labeled with `CONFIG`.
* The SSL configurations are basic, but should work. For more robust SSL configurations, see https://ssl-config.mozilla.org
1. Get the latest package from https://gitea.subcultureofone.org/greg/tkr/packages
1. Copy it to your web server
1. Set up the directory permissions:
* `tkr/storage` must be writable by the web server account
* `www-data` on debian-based systems
* `apache` on redhat-based systems
* All other `tkr` directories should be *readable* by the web server account.
* For example, if you're on debian:
```sh
# Make 'root' the owner of everything under 'tkr'
chown -R root:root tkr
# Make 'www-data' the owner of everything under 'tkr/storage'
chown -R www-data:www-data tkr/storage
## Initial configuration
# Make 'tkr/storage' writable bt 'www-data'
chmod 0770 tkr/storage
```
1. Configure your web server for your environment and serving model:
* There are example web server configs for common scenarios in the `examples` directory.
* apache shared hosting (e.g. everything served from `public_html`, .htaccess only)
* apache VPS
* subfolder: e.g. https://example.com/tkr
* subdomain: e.g. https://tke.example.com
* nginx VPS
* subfolder: e.g. https://example.com/tkr
* subdomain: e.g. https://tke.example.com
* Expose only the `public` folder if possible
* The VPS examples are configured this way.
* If this isn't possible (e.g. shared hosting), it's okay. There are `.htaccess` files included to block access to other directories in shared hosting environments.
1. Run the installation:
* Browser-based:
* Navigate to the tkr site (e.g. https://my-domain.org/tkr or https://tkr.my-domain.org)
* You'll be presented with the setup page.
* Complete the required fields.
* Command-line:
* ssh to your web server
* run `php /path/to/tkr/tkr-setup.php`
1. Run `php tkr/prerequisites.php`. This will confirm that:
1. PHP 8.2+ is installed
1. All required PHP extensions are installed
1. PDO
1. PDO::sqlite
1. All required directories exist
1. The `tkr/storage` directory exists and is writable
1. If `tkr/storage` is writable, then it will create the required subdirectories
1. `tkr/storage/db`
1. `tkr/storage/upload`
1. The script will write a summary to stdout and will save a log at `tkr/storage/prerequisite-check.log`
1. Edit `config/init.php` to set the domain and base path correctly for your configuration.
* subdirectory installation (e.g. https://my-domain.com/tkr)
```
'base_url' => 'https://my-domain.com',
'base_path' => '/tkr/',
```
* subdomain installation (e.g. https://tkr.my-domain.com)
```
'base_url' => 'https://tkr.my-domain.com',
'base_path' => '/',
```
1. Browse to your tkr URL. You'll be presented with the setup screen to complete initial configuration.
![tkr setup page](https://subcultureofone.org/images/tkr/tkr-setup.png)
If any prerequisites are missing (required PHP extensions, directory permissions)
## Features
* RSS `/rss` and Atom `/atom` feeds
* HTML and CSS implementation. No Javascript.
* Accessible HTML, with strict accessibility settng to aid tab navigation
* CSS uploads for custom theming
* Custom emoji to personalize moods
I'm trying to make sure that the HTML is both semantically valid and accessible, but I have a lot to learn about both. If you see something I should fix, please let me know!
### Server configuration notes
@ -108,8 +90,7 @@ There is an `.htaccess` file in the `tkr/` root directory. It's designed for the
* `tkr/storage`
* `tkr/templates`
### Docker compose
### Docker compose (local development)
The [docker](./docker) directory contains docker-compose.yml files and web server configs for some different server configurations. For simplicity, these do not use SSL.
@ -117,44 +98,62 @@ To run tkr locally on your machine, copy the docker-compose file you're interest
## Accessibility Note
The "Strict Accessibility" setting (enabled by default) addes `tabindex="0"` to all `<a>` tags to force them to get tab focus. This isn't strictly best practice. The `<a>` tag should get tab focus by default. But I've learned that some browsers (at least Safari and Vivaldi) disable this in their default configurations, making accessibility an opt-in feature.
The "Strict Accessibility" setting (enabled by default) adds `tabindex="0"` to all `<a>` tags to force them to get tab focus. This isn't strictly best practice. The `<a>` tag should get tab focus by default. But I've learned that some browsers (at least Safari and Vivaldi) disable this in their default configurations, making accessibility an opt-in feature.
If you'd like to revert to the standard behavior, toggle this setting off. But know that people who navigate by keyboard may have to reconfigure their browser settings in order to select hyperlinks.
## Storage
Ticks are stored in files on the filesystem under `/tkr/storage/ticks`. This directory must be writable by the web server user and so SHOULD NOT be served by the web server. If you set your document root to `/tkr/public/`, then you'll be fine.
tkr stores data at `tkr/storage`. This directory must be writable by the web server account and so *should not* be served by the web server. If you made `tkr/public` your document root, then you're fine. If you can't, then the .htaccess file in that directory will block access if you're running apache.
The file structure is `YYYY/MM/DD.txt`. That is, each day's ticks are located in a file whose full path is `/tkr/storage/ticks/YEAR/MONTH/DAY.txt`. This is to prevent any single file from getting too large.
There are 3 subdirectories:
Each entry takes the form `TIMESTAMP|TICK`, where `TIMESTAMP` is the time that the entry was made and `TICK` is the text of the entry.
* `tkr/storage/db`:
* Contains a sqlite database: `tkr.sqlite`.
* The database contains site settings, user profile data, ticks, and custom emoji.
* `tkr/storage/logs`:
* Runtime log files.
* Logs rotate after 1,000 lines.
* The last 5 log files are retained.
* `tkr/storage/uploads`:
* Stores custom CSS for personalized theming
For illustration, here's a sample from the file `/tkr/storage/ticks/2025/05/25` on my test system.
```sh
# cat /tkr/ticks/2025/05/25.txt
23:27:37|some stuff
23:27:45|some more, stuff
```
### SQLite Database
tkr stores profile information, custom emojis, and uploaded css metadata in a SQLite database located at `tkr/storage/db/tkr.sqlite`.
### SQLite Database Note
You don't have to do any database setup. The database is automatically created and initialized on first run.
## FAQ
## Backup and restore
### Why don't I see the right IPs in the logs?
tkr is completely self-contained. To back up tkr, just zip up the `tkr` directory and copy it to your backup location. To restore it, unzip it in the new location.
This can happen for a few reasons. Some common ones are:
Technically, you only need back up and restore the `tkr/storage` directory if you want to minimize the size of the backup, but the app is pretty small (~40 Kb compressed).
**Docker Development:** If running via Docker, you may see `192.168.65.1` (Docker Desktop gateway). This is normal for development.
## Screenshots
**Behind a Proxy/CDN:** If you're behind Cloudflare (with proxy enabled), load balancers, or other proxies, all requests may appear to come from the proxy's IP addresses.
### Mobile
- **For accurate IP logging:** Configure your web server to trust proxy headers. See your proxy provider's documentation for the required nginx/Apache configuration.
<img src="https://subcultureofone.org/images/tkr/tkr-logged-out-mobile-1.0.png"
alt="tkr logged out view - mobile"
width="40%" height="40%">
<img src="https://subcultureofone.org/images/tkr/tkr-logged-in-mobile-1.0.png"
alt="tkr logged in view - mobile"
width="40%" height="40%">
### Desktop
<img src="https://subcultureofone.org/images/tkr/tkr-logged-out-desktop-1.0.png"
alt="tkr logged out view - desktop"
width="60%" height="60%">
<img src="https://subcultureofone.org/images/tkr/tkr-logged-in-desktop-1.0.png"
alt="tkr logged in view - desktop"
width="60%" height="60%">
### Setup validation failure example
<img src="https://subcultureofone.org/images/tkr/tkr-setup-failure-example-1.0.png"
alt="tkr setup validation failure"
width="60%" height="60%">
## Acknowledgements
@ -163,29 +162,3 @@ It's been a lot of fun to get back to building something. I'm grateful to the pe
* [armaina](https://armaina.com) - Armaina's a talented artist (check out the site!) who had the original idea for a self-hosted PHP version of status.cafe. That sounded like a fun project so I thought I'd see if I could manage it. This project doesn't exist without Armaina. Thank you!
* [status.cafe](https://status.cafe) - The technological inspiration. Unless you really want to self-host, you should use status.cafe instead! I took a lot of inspiration from its design and then I made the CSS way heavier and probably lost some of the soul along the way.
* [32-bit cafe](https://32bit.cafe) - I started in technology as a hobbyist and idealist. Then I became a professional. The decades since have sucked the joy and the hope out of technology. 32-bit cafe reminded me that they're both still there.
## Tentative 0.y.z releases to 1.0
I'd like to alternate beteen architecture and feature releases between here and 1.0. This is my current thinking, but these may change.
### 0.7.5 (architecture improvements)
* Add linting and tests
* Add artifact build pipeline
### 0.8.0 (features and enhancements)
* Support microformats
* Support h-feed and JSON
### 0.8.5 (architecture improvements)
* Add docker build and deployment
### 0.9.0 (features and enhancements)
* Allow customization of time zone and time display for ticks
### 0.9.5 (architecture enhancements)
* Improve exception handling
* Add logging, including log viewer screen
### 1.0.0
* Polish README and other docs
* Set up dedicated webpage

View File

@ -5,15 +5,23 @@ declare(strict_types=1);
// - define paths
// - set up autoloader
// Set a couple ini settings for security
ini_set('allow_url_fopen', 0); // don't allow remote files to be read
ini_set('expose_php', 0); // don't advertise the PHP version
// Define all the important paths
define('APP_ROOT', dirname(dirname(__FILE__)));
// Root-level directories
define('CONFIG_DIR', APP_ROOT . '/config');
define('PUBLIC_DIR', APP_ROOT . '/public');
define('SRC_DIR', APP_ROOT . '/src');
define('STORAGE_DIR', APP_ROOT . '/storage');
define('TEMPLATES_DIR', APP_ROOT . '/templates');
define('DATA_DIR', STORAGE_DIR . '/db');
define('DB_FILE', DATA_DIR . '/tkr.sqlite');
// Storage subdirectories
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css');
define('DATA_DIR', STORAGE_DIR . '/db');
define('TEMPLATES_DIR', APP_ROOT . '/templates');
// Database file
define('DB_FILE', DATA_DIR . '/tkr.sqlite');
// Janky autoloader function
// This is a bit more consistent with current frameworks

View File

@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN tick_delete_hours INTEGER NULL;

View File

@ -0,0 +1,22 @@
# Basic .htaccess for tkr on shared hosting
# For use with included docker-compose.yml
# Enable mod_rewrite
RewriteEngine On
# Set directory index
DirectoryIndex public/index.php
# Block access to sensitive directories
RewriteRule ^(storage|src|templates|config)(/.*)?$ - [F,L]
# Block access to hidden files
RewriteRule ^\..*$ - [F,L]
# Block access to setup script
RewriteRule ^tkr-setup\.php$ - [F,L]
# Route everything else through public/index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ public/index.php [L]

View File

@ -0,0 +1,22 @@
services:
apache-php-no-sqlite:
image: debian:bookworm
container_name: apache-no-sqlite-test
ports:
- "80:80"
volumes:
- ./config:/var/www/html/tkr/config
- ./public:/var/www/html/tkr/public
- ./src:/var/www/html/tkr/src
- ./storage:/var/www/html/tkr/storage
- ./templates:/var/www/html/tkr/templates
- ./tkr-setup.php:/var/www/html/tkr/tkr-setup.php
- ./docker/apache/shared-hosting/.htaccess:/var/www/html/tkr/.htaccess
command: >
bash -c "apt-get update &&
apt-get install -y apache2 php libapache2-mod-php &&
a2enmod rewrite headers expires php8.2 &&
sed -i 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf &&
chown -R www-data:www-data /var/www/html/tkr/storage &&
apache2ctl -D FOREGROUND"
restart: unless-stopped

View File

@ -1,49 +1,22 @@
# Example Apache VirtualHost
# for serving tkr as a subdirectory path
# on shared hosting via .htaccess
#
# e.g. http://www.my-domain.com/tkr
#
# This should work without modification if you extract the app
# to /tkr from your web document root
# Basic .htaccess for tkr on shared hosting
# For use with included docker-compose.yml
# Enable mod_rewrite
RewriteEngine On
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
# Directory index
# Set directory index
DirectoryIndex public/index.php
# Security: Block direct access to .php files (except through rewrites)
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Block access to sensitive directories
RewriteRule ^(storage|src|templates|config)(/.*)?$ - [F,L]
# Security: Block access to sensitive directories
RewriteRule ^(storage|src|templates|examples|config)(/.*)?$ - [F,L]
# Security: Block access to hidden files
# Block access to hidden files
RewriteRule ^\..*$ - [F,L]
# Cache CSS files for 1 hour
<FilesMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
# Block access to setup script
RewriteRule ^tkr-setup\.php$ - [F,L]
# Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ public/css/tkr.css [L]
# 404 all other static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
RewriteRule \.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$ - [R=404,L]
# Everything else goes to the front controller
# Route everything else through public/index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ public/index.php [L]

View File

@ -10,6 +10,7 @@ services:
- ./src:/var/www/html/tkr/src
- ./storage:/var/www/html/tkr/storage
- ./templates:/var/www/html/tkr/templates
- ./tkr-setup.php:/var/www/html/tkr/tkr-setup.php
- ./docker/apache/shared-hosting/.htaccess:/var/www/html/tkr/.htaccess
command: >
bash -c "a2enmod rewrite headers expires &&

View File

@ -10,6 +10,7 @@ services:
- ./src:/var/www/tkr/src
- ./storage:/var/www/tkr/storage
- ./templates:/var/www/tkr/templates
- ./tkr-setup.php:/var/www/html/tkr/tkr-setup.php
- ./docker/apache/vps/root/tkr.my-domain.com.conf:/etc/apache2/sites-enabled/tkr.my-domain.com.conf
command: >
bash -c "a2enmod rewrite headers expires &&

View File

@ -1,19 +1,21 @@
# Example Apache VirtualHost
# for serving tkr as a subdomain root without SSL
# e.g. http://tkr.my-domain.com/
#
# NOTE: Do not use in production.
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
# Basic Apache VirtualHost for tkr
# For use with included docker-compose.yml
# HTTP - redirect to HTTPS
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/tkr/public
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Main directory - route everything through index.php
<Directory "/var/www/tkr/public">
AllowOverride None
Require all granted
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Block access to sensitive directories
<Directory "/var/www/tkr/storage">
@ -22,59 +24,13 @@
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
# Block access to hidden files
<DirectoryMatch "^\.|/\.">
<Directory "/var/www/tkr/templates">
Require all denied
</DirectoryMatch>
# Cache CSS files
<LocationMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</LocationMatch>
# Serve static CSS file
Alias /css/tkr.css /var/www/tkr/public/css/tkr.css
# 404 all non-css static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
<LocationMatch "\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$">
<RequireAll>
Require all denied
</RequireAll>
</LocationMatch>
# Enable rewrite engine
<Directory "/var/www/tkr/public">
Options -Indexes
AllowOverride None
Require all granted
RewriteEngine On
# Block direct PHP access
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ css/tkr.css [L]
# Everything else to front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log
CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined
</VirtualHost>

View File

@ -10,6 +10,7 @@ services:
- ./src:/var/www/tkr/src
- ./storage:/var/www/tkr/storage
- ./templates:/var/www/tkr/templates
- ./tkr-setup.php:/var/www/html/tkr/tkr-setup.php
- ./docker/apache/vps/subfolder/my-domain.com.conf:/etc/apache2/sites-enabled/my-domain.com.conf
command: >
bash -c "a2enmod rewrite headers expires &&

View File

@ -1,72 +1,38 @@
# Example Apache VirtualHost
# for serving tkr as a subdirectory path without SSL
# e.g. http://www.my-domain.com/tkr
#
# NOTE: Do not use in production.
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
# Basic Apache config for tkr in subfolder
# e.g. https://your-domain.com/tkr
# For use with included docker-compose.yml
# Alias for tkr subfolder
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/html
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# tkr Application at /tkr
# NOTE: If you change the directory name,
# remember to update all instances of /var/www/tkr in this file to match
Alias /tkr /var/www/tkr/public
# Block access to sensitive TKR directories
<Directory "/var/www/tkr/public">
AllowOverride None
Require all granted
# Front controller pattern
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /tkr/index.php [L]
</Directory>
# Block access to sensitive directories
<Directory "/var/www/tkr/storage">
Require all denied
</Directory>
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
# 404 all non-css static files in /tkr (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
<LocationMatch "^/tkr/.*\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$">
<RequireAll>
Require all denied
</RequireAll>
</LocationMatch>
# tkr application directory
<Directory "/var/www/tkr/public">
Options -Indexes
AllowOverride None
Require all granted
RewriteEngine On
# Block direct PHP access
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/tkr/css/custom/
RewriteRule ^css/tkr\.css$ css/tkr.css [L]
# Send everything else to the front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/my-domain_error.log
CustomLog ${APACHE_LOG_DIR}/my-domain_access.log combined
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log
CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined
</VirtualHost>

View File

@ -20,6 +20,7 @@ services:
- ./src:/var/www/tkr/src
- ./storage:/var/www/tkr/storage
- ./templates:/var/www/tkr/templates
- ./tkr-setup.php:/var/www/html/tkr/tkr-setup.php
command: >
sh -c "
chown -R www-data:www-data /var/www/tkr/storage &&

View File

@ -1,100 +1,45 @@
# Example nginx config
# for serving tkr as a subdomain without SSL
# e.g. http://tkr.my-domain.com/
#
# NOTE: Do not use in production.
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
# Basic nginx config for tkr
# Replace "your-domain.com" with your actual domain
# Replace "/var/www/tkr" with your installation path
# HTTP - redirect to HTTPS
server {
listen 80;
listen 80 default_server;
server_name localhost;
root /var/www/tkr/public;
index index.php;
# Security headers
# The first rule is to prevent including in a frame on a different domain.
# Remove it if you want to do that.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
# Block access to sensitive directories
location ~ ^/(storage|src|templates|config) {
deny all;
return 404;
}
# Deny access to hidden files
# Block access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# PHP routing - everything goes through index.php
location / {
# Cache static files
# Note that I don't actually serve most of this (just css)
# but this prevents requests for static content from getting to the PHP handler.
#
# I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app.
# That lets me store uploaded content outside of the document root,
# so it isn't served directly.
# CSS files - 1 hour cache
location ~* ^/(?!css/custom/).+\.css$ {
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
# Other static assets - 1 year cache
location ~* ^/.+\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.php is the entry point
# It needs to be sent to php-fpm
# But if someone tries to directly access index.php, that file will throw a 404
# so bots and scanners can't tell this is a php app
location = /index.php {
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Block attempts to access all other .php files directly
# (these are bots and scanners)
location ~ ^/.+\.php$ {
return 404;
}
# forward other requests to the fallback block,
# which sends them to php-fpm for handling
try_files $uri $uri/ @tkr_fallback;
}
# Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr_fallback {
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
# Handle PHP files
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
}
# Front controller pattern - route everything else through index.php
location / {
try_files $uri $uri/ @tkr;
}
# Handle PHP requests
location @tkr {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Deny access to sensitive directories
location ~ ^/(storage|src|templates|uploads|config) {
deny all;
return 404;
}
}

View File

@ -20,6 +20,7 @@ services:
- ./src:/var/www/tkr/src
- ./storage:/var/www/tkr/storage
- ./templates:/var/www/tkr/templates
- ./tkr-setup.php:/var/www/html/tkr/tkr-setup.php
command: >
sh -c "
chown -R www-data:www-data /var/www/tkr/storage &&

View File

@ -1,101 +1,47 @@
# Example nginx config
# for serving tkr as a subdfolder without SSL
# e.g. http://my-domain.com/tkr
#
# NOTE: Do not use in production.
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
# Basic nginx config for tkr in subfolder
# e.g. https://your-domain.com/tkr
# For use with included docker-compose.yml
server {
listen 80 default_server;
listen [::]:80 default_server;
# replace localhost with your subdomain
# e.g. tkr.my-domain.com
server_name localhost;
root /var/www/html;
index index.html;
# Security headers
# The first rule is to prevent including in a frame on a different domain.
# Remove it if you want to do that.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# PHP routing - everything under /tkr goes through index.php
location /tkr {
alias /var/www/tkr/public;
index index.php;
# Cache static files
# Note that I don't actually serve most of this (just css)
# but this prevents requests for static content from getting to the PHP handler.
#
# I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app.
# That lets me store uploaded content outside of the document root,
# so it isn't served directly.
# CSS files - 1 hour cache
location ~* ^/tkr/(?!css/custom/).+\.css$ {
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
# Other static assets - 1 year cache
location ~* ^/tkr/.+\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.php is the entry point
# It needs to be sent to php-fpm
# But if someone tries to directly access index.php, that file will throw a 404
# so bots and scanners can't tell this is a php app
location = /tkr/index.php {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Block attempts to access all other .php files directly
# (these are bots and scanners)
location ~ ^/tkr/.+\.php$ {
# Block access to sensitive directories
location ~ ^/tkr/(storage|src|templates|config) {
deny all;
return 404;
}
# forward other requests to the fallback block,
# which sends them to php-fpm for handling
try_files $uri $uri/ @tkr_fallback;
# Block access to hidden files
location ~ /\. {
deny all;
}
# Handle PHP files
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
}
# Front controller pattern
# Send everything else to index.php
try_files $uri $uri/ @tkr;
}
# Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr_fallback {
location @tkr {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Deny access to sensitive directories
location ~ ^/tkr/(storage|src|templates|uploads|config) {
deny all;
return 404;
}
}

View File

@ -1,49 +1,23 @@
# Example Apache VirtualHost
# for serving tkr as a subdirectory path
# on shared hosting via .htaccess
#
# e.g. http://www.my-domain.com/tkr
#
# This should work without modification if you extract the app
# to /tkr from your web document root
# Basic .htaccess for tkr on shared hosting
# Place this file inside the tkr directory
# e.g. if tkr is at /public_html/tkr/, this goes in /public_html/tkr/
# Enable mod_rewrite
RewriteEngine On
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
# Directory index
# Set directory index
DirectoryIndex public/index.php
# Security: Block direct access to .php files (except through rewrites)
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Block access to sensitive directories
RewriteRule ^(storage|src|templates|config)(/.*)?$ - [F,L]
# Security: Block access to sensitive directories
RewriteRule ^(storage|src|templates|examples|config)(/.*)?$ - [F,L]
# Security: Block access to hidden files
# Block access to hidden files
RewriteRule ^\..*$ - [F,L]
# Cache CSS files for 1 hour
<FilesMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
# Block access to setup script
RewriteRule ^tkr-setup\.php$ - [F,L]
# Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ public/css/tkr.css [L]
# 404 all other static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
RewriteRule \.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$ - [R=404,L]
# Everything else goes to the front controller
# Route everything else through the front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ public/index.php [L]

View File

@ -1,38 +1,33 @@
# Example Apache VirtualHost
# for serving tkr as a subdomain root with SSL
# e.g. https://tkr.my-domain.com/
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
# Basic Apache VirtualHost for tkr
# Replace "your-domain.com" with your actual domain
# Replace "/var/www/tkr" with your installation path
# HTTP - redirect to HTTPS
<VirtualHost *:80>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr/public
# Redirect HTTP to HTTPS
Redirect permanent / https://tkr.my-domain.com/
ServerName your-domain.com
Redirect permanent / https://your-domain.com/
</VirtualHost>
# HTTPS
<VirtualHost *:443>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName your-domain.com
DocumentRoot /var/www/tkr/public
# SSL Configuration
# SSL Configuration (using Let's Encrypt)
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
SSLCertificateFile /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem
# Main directory - route everything through index.php
<Directory "/var/www/tkr/public">
AllowOverride None
Require all granted
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Block access to sensitive directories
<Directory "/var/www/tkr/storage">
@ -41,59 +36,13 @@
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
# Block access to hidden files
<DirectoryMatch "^\.|/\.">
<Directory "/var/www/tkr/templates">
Require all denied
</DirectoryMatch>
# Cache CSS files
<LocationMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</LocationMatch>
# Serve static CSS file
Alias /css/tkr.css /var/www/tkr/public/css/tkr.css
# 404 all non-css static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
<LocationMatch "\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$">
<RequireAll>
Require all denied
</RequireAll>
</LocationMatch>
# Enable rewrite engine
<Directory "/var/www/tkr/public">
Options -Indexes
AllowOverride None
Require all granted
RewriteEngine On
# Block direct PHP access
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ css/tkr.css [L]
# Everything else to front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log
CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined
</VirtualHost>

View File

@ -1,91 +1,31 @@
# Example Apache VirtualHost
# for serving tkr as a subdirectory path with SSL
# e.g. https://www.my-domain.com/tkr
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
<VirtualHost *:80>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr
# Redirect HTTP to HTTPS
Redirect permanent / https://my-domain.com/
</VirtualHost>
# Basic Apache config for tkr in subfolder
# e.g. https://your-domain.com/tkr
# Add this to your existing VirtualHost configuration
<VirtualHost *:443>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr/
# Alias for tkr subfolder
Alias /tkr /var/www/tkr/public
# SSL Configuration
SSLEngine on
<Directory "/var/www/tkr/public">
AllowOverride None
Require all granted
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
SSLCertificateFile /etc/letsencrypt/live/my-domain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/my-domain.com/privkey.pem
# Front controller pattern
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /tkr/index.php [L]
</Directory>
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# tkr Application at /tkr
# NOTE: If you change the directory name,
# remember to update all instances of /var/www/tkr in this file to match
Alias /tkr /var/www/tkr/public
# Block access to sensitive TKR directories
<Directory "/var/www/tkr/storage">
Require all denied
</Directory>
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
# 404 all non-css static files in /tkr (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
<LocationMatch "^/tkr/.*\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$">
<RequireAll>
Require all denied
</RequireAll>
</LocationMatch>
# tkr application directory
<Directory "/var/www/tkr/public">
Options -Indexes
AllowOverride None
Require all granted
RewriteEngine On
# Block direct PHP access
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/tkr/css/custom/
RewriteRule ^css/tkr\.css$ css/tkr.css [L]
# Send everything else to the front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/my-domain_error.log
CustomLog ${APACHE_LOG_DIR}/my-domain_access.log combined
</VirtualHost>
# Block access to sensitive directories
<Directory "/var/www/tkr/storage">
Require all denied
</Directory>
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>

View File

@ -1,117 +1,51 @@
# Example nginx config
# for serving tkr as a subdomain with SSL
# e.g. https://tkr.my-domain.com/
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
# Basic nginx config for tkr
# Replace "your-domain.com" with your actual domain
# Replace "/var/www/tkr" with your installation path
# HTTP - redirect to HTTPS
server {
listen 80;
listen [::]:80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
# HTTPS
server {
listen 443 ssl;
listen [::]:443 ssl;
# CONFIG: replace "localhost" with your subdomain (e.g. tkr.my-domain.com)
server_name localhost;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
server_name your-domain.com;
root /var/www/tkr/public;
index index.php;
# CONFIG:
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
ssl_certificate /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem;
# SSL Configuration (using Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Security headers
# The first rule is to prevent including in a frame on a different domain.
# Remove it if you want to do that.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
# Block access to sensitive directories
location ~ ^/(storage|src|templates|config) {
deny all;
return 404;
}
# Deny access to hidden files
# Block access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# PHP routing - everything goes through index.php
# Front controller pattern - route everything through index.php
location / {
# Cache static files
# Note that I don't actually serve most of this (just css)
# but this prevents requests for static content from getting to the PHP handler.
#
# I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app.
# That lets me store uploaded content outside of the document root,
# so it isn't served directly.
location ~* ^/(?!css/custom/).+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.php is the entry point
# It needs to be sent to php-fpm
# But if someone tries to directly access index.php, that file will throw a 404
# so bots and scanners can't tell this is a php app
location = /index.php {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Block attempts to access all other .php files directly
# (these are bots and scanners)
location ~ ^/.+\.php$ {
return 404;
}
# forward other requests to the fallback block,
# which sends them to php-fpm for handling
try_files $uri $uri/ @tkr_fallback;
try_files $uri $uri/ @tkr;
}
# Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr_fallback {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
# Handle PHP requests
location @tkr {
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version as needed
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Deny access to sensitive directories
location ~ ^/(storage|src|templates|uploads|config) {
deny all;
return 404;
}
}
server {
listen 80 default_server;
listen [::]:80 default_server;
return 301 https://$host$request_uri;
}

View File

@ -1,118 +1,38 @@
# Example nginx config
# for serving tkr as a subdfolder with SSL
# e.g. https://my-domain.com/tkr
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
server {
listen 443 ssl;
listen [::]:443 ssl;
# Basic nginx config for tkr in subfolder
# e.g. https://your-domain.com/tkr
# Add this location block to your existing server configuration
# CONFIG: Replace localhost with your domain e.g. my-domain.com
server_name localhost;
location /tkr {
alias /var/www/tkr/public;
index index.php;
# CONFIG:
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
ssl_certificate /etc/letsencrypt/live/my-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/my-domain.com/privkey.pem;
# Security headers
# The first rule is to prevent including in a frame on a different domain.
# Remove it if you want to do that.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# PHP routing - everything under /tkr goes through index.php
location /tkr {
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
alias /var/www/tkr/public;
index index.php;
# Cache static files
# Note that I don't actually serve most of this (just css)
# but this prevents requests for static content from getting to the PHP handler.
#
# I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app.
# That lets me store uploaded content outside of the document root,
# so it isn't served directly.
location ~* ^/tkr/(?!css/custom/).+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.php is the entry point
# It needs to be sent to php-fpm
# But if someone tries to directly access index.php, that file will throw a 404
# so bots and scanners can't tell this is a php app
location = /tkr/index.php {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Block attempts to access all other .php files directly
# (these are bots and scanners)
location ~ ^/tkr/.+\.php$ {
return 404;
}
# forward other requests to the fallback block,
# which sends them to php-fpm for handling
try_files $uri $uri/ @tkr_fallback;
}
# Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr_fallback {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
# Handle PHP files
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version/socket as needed
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Deny access to sensitive directories
location ~ ^/tkr/(storage|src|templates|uploads|config) {
# Block access to sensitive directories
location ~ ^/tkr/(storage|src|templates|config) {
deny all;
return 404;
}
# Block access to hidden files
location ~ /\. {
deny all;
}
# Front controller pattern
try_files $uri $uri/ @tkr;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
return 301 https://$host$request_uri;
location @tkr {
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version as needed
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}

View File

@ -0,0 +1,49 @@
# Example Apache VirtualHost
# for serving tkr as a subdirectory path
# on shared hosting via .htaccess
#
# e.g. http://www.my-domain.com/tkr
#
# This should work without modification if you extract the app
# to /tkr from your web document root
# Enable mod_rewrite
RewriteEngine On
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
# Directory index
DirectoryIndex public/index.php
# Security: Block direct access to .php files (except through rewrites)
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Security: Block access to sensitive directories
RewriteRule ^(storage|src|templates|examples|config)(/.*)?$ - [F,L]
# Security: Block access to hidden files
RewriteRule ^\..*$ - [F,L]
# Cache CSS files for 1 hour
<FilesMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
# Serve the one static file that exists: css/default.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/default\.css$ public/css/default.css [L]
# 404 all other static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
RewriteRule \.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$ - [R=404,L]
# Everything else goes to the front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ public/index.php [L]

View File

@ -0,0 +1,91 @@
# Example Apache VirtualHost
# for serving tkr as a subdirectory path with SSL
# e.g. https://www.my-domain.com/tkr
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
<VirtualHost *:80>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr
# Redirect HTTP to HTTPS
Redirect permanent / https://my-domain.com/
</VirtualHost>
<VirtualHost *:443>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr/
# SSL Configuration
SSLEngine on
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
SSLCertificateFile /etc/letsencrypt/live/my-domain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/my-domain.com/privkey.pem
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# tkr Application at /tkr
# NOTE: If you change the directory name,
# remember to update all instances of /var/www/tkr in this file to match
Alias /tkr /var/www/tkr/public
# Block access to sensitive TKR directories
<Directory "/var/www/tkr/storage">
Require all denied
</Directory>
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
# 404 all non-css static files in /tkr (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
<LocationMatch "^/tkr/.*\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$">
<RequireAll>
Require all denied
</RequireAll>
</LocationMatch>
# tkr application directory
<Directory "/var/www/tkr/public">
Options -Indexes
AllowOverride None
Require all granted
RewriteEngine On
# Block direct PHP access
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Serve the one static file that exists: css/default.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/tkr/css/custom/
RewriteRule ^css/default\.css$ css/tkr.css [L]
# Send everything else to the front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/my-domain_error.log
CustomLog ${APACHE_LOG_DIR}/my-domain_access.log combined
</VirtualHost>

View File

@ -0,0 +1,99 @@
# Example Apache VirtualHost
# for serving tkr as a subdomain root with SSL
# e.g. https://tkr.my-domain.com/
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
<VirtualHost *:80>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr/public
# Redirect HTTP to HTTPS
Redirect permanent / https://tkr.my-domain.com/
</VirtualHost>
<VirtualHost *:443>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
ServerName localhost
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr/public
# SSL Configuration
SSLEngine on
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
SSLCertificateFile /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Block access to sensitive directories
<Directory "/var/www/tkr/storage">
Require all denied
</Directory>
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
# Block access to hidden files
<DirectoryMatch "^\.|/\.">
Require all denied
</DirectoryMatch>
# Cache CSS files
<LocationMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</LocationMatch>
# Serve static CSS file
Alias /css/tkr.css /var/www/tkr/public/css/tkr.css
# 404 all non-css static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
<LocationMatch "\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$">
<RequireAll>
Require all denied
</RequireAll>
</LocationMatch>
# Enable rewrite engine
<Directory "/var/www/tkr/public">
Options -Indexes
AllowOverride None
Require all granted
RewriteEngine On
# Block direct PHP access
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Serve the one static file that exists: css/default.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/default\.css$ css/default.css [L]
# Everything else to front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log
CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined
</VirtualHost>

View File

@ -0,0 +1,117 @@
# Example nginx config
# for serving tkr as a subdomain with SSL
# e.g. https://tkr.my-domain.com/
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
server {
listen 443 ssl;
listen [::]:443 ssl;
# CONFIG: replace "localhost" with your subdomain (e.g. tkr.my-domain.com)
server_name localhost;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
root /var/www/tkr/public;
index index.php;
# CONFIG:
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
ssl_certificate /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem;
# Security headers
# The first rule is to prevent including in a frame on a different domain.
# Remove it if you want to do that.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# PHP routing - everything goes through index.php
location / {
# Cache static files
# Note that I don't actually serve most of this (just css)
# but this prevents requests for static content from getting to the PHP handler.
#
# I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app.
# That lets me store uploaded content outside of the document root,
# so it isn't served directly.
location ~* ^/(?!css/custom/).+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.php is the entry point
# It needs to be sent to php-fpm
# But if someone tries to directly access index.php, that file will throw a 404
# so bots and scanners can't tell this is a php app
location = /index.php {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Block attempts to access all other .php files directly
# (these are bots and scanners)
location ~ ^/.+\.php$ {
return 404;
}
# forward other requests to the fallback block,
# which sends them to php-fpm for handling
try_files $uri $uri/ @tkr_fallback;
}
# Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr_fallback {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Deny access to sensitive directories
location ~ ^/(storage|src|templates|uploads|config) {
deny all;
return 404;
}
}
server {
listen 80 default_server;
listen [::]:80 default_server;
return 301 https://$host$request_uri;
}

View File

@ -0,0 +1,118 @@
# Example nginx config
# for serving tkr as a subdfolder with SSL
# e.g. https://my-domain.com/tkr
#
# Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
server {
listen 443 ssl;
listen [::]:443 ssl;
# CONFIG: Replace localhost with your domain e.g. my-domain.com
server_name localhost;
# CONFIG:
# Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
ssl_certificate /etc/letsencrypt/live/my-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/my-domain.com/privkey.pem;
# Security headers
# The first rule is to prevent including in a frame on a different domain.
# Remove it if you want to do that.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# PHP routing - everything under /tkr goes through index.php
location /tkr {
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
alias /var/www/tkr/public;
index index.php;
# Cache static files
# Note that I don't actually serve most of this (just css)
# but this prevents requests for static content from getting to the PHP handler.
#
# I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app.
# That lets me store uploaded content outside of the document root,
# so it isn't served directly.
location ~* ^/tkr/(?!css/custom/).+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.php is the entry point
# It needs to be sent to php-fpm
# But if someone tries to directly access index.php, that file will throw a 404
# so bots and scanners can't tell this is a php app
location = /tkr/index.php {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Block attempts to access all other .php files directly
# (these are bots and scanners)
location ~ ^/tkr/.+\.php$ {
return 404;
}
# forward other requests to the fallback block,
# which sends them to php-fpm for handling
try_files $uri $uri/ @tkr_fallback;
}
# Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr_fallback {
# CONFIG:
# If you're running php-fpm on the same server as nginx,
# then change this to the local php-fpm socket
# e.g. fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_pass php:9000;
# CONFIG:
# replace "/var/www/tkr" with the directory you extracted the .zip file to (if different)
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
}
# Deny access to sensitive directories
location ~ ^/tkr/(storage|src|templates|uploads|config) {
deny all;
return 404;
}
}
server {
listen 80 default_server;
listen [::]:80 default_server;
return 301 https://$host$request_uri;
}

View File

@ -12,6 +12,9 @@
--color-flash-success: darkgreen;
--color-flash-success-bg: honeydew;
--color-flash-success-border-left: forestgreen;
--color-flash-warning: darkgoldenrod;
--color-flash-warning-bg: lightgoldenrodyellow;
--color-flash-warning-border-left: gold;
--color-mood-border: darkslateblue;
--color-mood-hover: lightsteelblue;
--color-mood-selected: lightblue;
@ -324,6 +327,12 @@ summary:focus,
color: var(--color-flash-error);
}
.flash-warning {
background-color: var(--color-flash-warning-bg);
border-left-color: var(--color-flash-warning-border-left);
color: var(--color-flash-warning);
}
.fieldset-items {
margin-bottom: 14px;
display: grid;
@ -370,6 +379,11 @@ time {
font-size: 1.0em;
display: block;
}
.tick-meta {
color: var(--color-log-muted);
font-size: 0.9em;
margin-bottom: 0.4em;
}
.tick-pagination a {
margin: 0 5px;

View File

@ -42,8 +42,15 @@ if (!$prerequisites->validateApplication()) {
// Get the working database connection from prerequisites
$db = $prerequisites->getDatabase();
// Apply any pending database migrations
if (!$prerequisites->applyMigrations($db)){
$prerequisites->generateWebSummary();
exit;
}
// Check if setup is complete (user exists and URL is configured)
if (!(preg_match('/tkr-setup$/', $path))) {
// Skip the setup check for the default css
if (!(preg_match('/tkr-setup$/', $path) || preg_match('/default.css$/', $path))) {
try {
$user_count = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn();
$settings = (new SettingsModel($db))->get();
@ -66,6 +73,10 @@ if (!(preg_match('/tkr-setup$/', $path))) {
echo "<p>Please check your installation or contact your hosting provider.</p>";
exit;
}
} else {
// we're heading to setup. the base path hasn't been set. autodetect it
$autodetected = Util::getAutodetectedUrl();
$basePath = $autodetected['basePath'];
}
/*
@ -86,8 +97,11 @@ Session::start();
Session::generateCsrfToken();
// Remove the base path from the URL
if (strpos($path, $app['settings']->basePath) === 0) {
$path = substr($path, strlen($app['settings']->basePath));
// If basePath isn't already set (i.e. we're not autodetecting it en route to tkr-setup),
// set it to the value from settings
$basePath ??= $app['settings']->basePath;
if (strpos($path, $basePath) === 0) {
$path = substr($path, strlen($basePath));
}
// strip the trailing slash from the resulting route
@ -100,11 +114,11 @@ Log::debug("Path requested: {$path}");
// if this is a POST and we aren't in setup,
// make sure there's a valid session
// if not, redirect to /login or die as appropriate
if ($method === 'POST' && $path != 'setup') {
if ($method === 'POST' && $path != 'tkr-setup') {
if ($path != 'login'){
if (!Session::isValid($_POST['csrf_token'])) {
// Invalid session - redirect to /login
Log::info('Attempt to POST with invalid session. Redirecting to login.');
Log::warning('Attempt to POST with invalid session. Redirecting to login.');
header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'login'));
exit;
}

View File

@ -50,7 +50,7 @@ class AdminController extends Controller {
}
$result = $this->saveSettings($_POST, false);
header('Location: ' . $_SERVER['PHP_SELF']);
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
@ -58,7 +58,7 @@ class AdminController extends Controller {
// for setup, we don't care if they're logged in
// (because they can't be until setup is complete)
$result = $this->saveSettings($_POST, true);
header('Location: ' . $_SERVER['PHP_SELF']);
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
@ -91,6 +91,7 @@ class AdminController extends Controller {
$itemsPerPage = (int) ($postData['items_per_page'] ?? 25);
$strictAccessibility = isset($postData['strict_accessibility']);
$logLevel = (int) ($postData['log_level'] ?? 0);
$tickDeleteHours = (int) ($postData['tick_delete_hours'] ?? 1);
// Password
$password = $postData['password'] ?? '';
@ -152,6 +153,7 @@ class AdminController extends Controller {
$app['settings']->itemsPerPage = $itemsPerPage;
$app['settings']->strictAccessibility = $strictAccessibility;
$app['settings']->logLevel = $logLevel;
$app['settings']->tickDeleteHours = $tickDeleteHours;
// Save site settings and reload config from database
$app['settings'] = $app['settings']->save();

View File

@ -39,7 +39,7 @@ class AuthController extends Controller {
} catch (Exception $e) {
Log::error("Failed to create login session for {$username}: " . $e->getMessage());
Session::setFlashMessage('error', 'Login failed - session error');
header('Location: ' . $_SERVER['PHP_SELF']);
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
} else {
@ -47,13 +47,13 @@ class AuthController extends Controller {
// Set a flash message and reload the login page
Session::setFlashMessage('error', 'Invalid username or password');
header('Location: ' . $_SERVER['PHP_SELF']);
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
} catch (Exception $e) {
Log::error("Database error during login for {$username}: " . $e->getMessage());
Session::setFlashMessage('error', 'Login temporarily unavailable');
header('Location: ' . $_SERVER['PHP_SELF']);
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
}

View File

@ -20,32 +20,63 @@ class CssController extends Controller {
global $app;
$cssModel = new CssModel($app['db']);
$filename = "$baseFilename.css";
Log::debug("Attempting to serve custom css: {$filename}");
// Make sure the file exists in the database
$cssRow = $cssModel->getByFilename($filename);
if (!$cssRow){
http_response_code(404);
exit("CSS file not found: $filename");
$msg = "Custom css file not in database: {$filename}";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
}
// Make sure the file exists on the filesystem and is readable
$filePath = CSS_UPLOAD_DIR . "/$filename";
if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit("CSS file not found: $filePath");
$msg = "Custom css file not found or not readable: {$filePath}";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
}
// This shouldn't be possible, but I'm being extra paranoid
// about user input
// Make sure the file has a .css extension
$ext = strToLower(pathinfo($filename, PATHINFO_EXTENSION));
if($ext != 'css'){
http_response_code(400);
exit("Invalid file type requested: $ext");
$msg = "Invalid file type requested: {$ext}";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
}
// If we get here, serve the file
Log::debug("Serving custom css: {$filename}");
header('Content-type: text/css');
header('Cache-control: public, max-age=3600');
readfile($filePath);
exit;
}
public function serveDefaultCss(){
$filePath = PUBLIC_DIR . '/css/default.css';
Log::debug("Serving default css: {$filePath}");
// Make sure the default CSS file exists and is readable
if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404);
$msg = "Default CSS file not found";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
}
// Serve the file
header('Content-type: text/css');
header('Cache-control: public, max-age=3600');
readfile($filePath);
exit;
}
@ -64,7 +95,7 @@ class CssController extends Controller {
}
// redirect after handling to avoid resubmitting form
header('Location: ' . $_SERVER['PHP_SELF']);
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
@ -74,18 +105,24 @@ class CssController extends Controller {
// Don't try to delete the default theme.
if (!$_POST['selectCssFile']){
http_response_code(400);
exit("Cannot delete default theme");
$msg = "Cannot delete default theme.";
Log::warning($msg);
Session::setFlashMessage('warning', $msg);
return;
}
// Get the data for the selected CSS file
$cssId = $_POST['selectCssFile'];
$cssId = (int) $_POST['selectCssFile'];
$cssModel = new CssModel($app['db']);
$cssRow = $cssModel->getById($cssId);
// exit if the requested file isn't in the database
if (!$cssRow){
http_response_code(400);
exit("No entry found for css id $cssId");
$msg = "No entry found for css id {$cssId}.";
Log::warning($msg);
Session::setFlashMessage('warning', $msg);
return;
}
// get the filename
@ -93,8 +130,11 @@ class CssController extends Controller {
// delete the file from the database
if (!$cssModel->delete($cssId)){
http_response_code(400);
exit("Error deleting theme");
http_response_code(500);
$msg = "Error deleting theme {$cssId}.";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
}
// Build the full path to the file
@ -103,33 +143,42 @@ class CssController extends Controller {
// Exit if the file doesn't exist or isn't readable
if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit("CSS file not found: $filePath");
$msg = "CSS file not found: {$filePath}";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
}
// Delete the file
if (!unlink($filePath)){
http_response_code(400);
exit("Error deleting file: $filePath");
http_response_code(500);
$msg = "Error deleting file: {$filePath}";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
}
// Set the theme back to default
try {
$app['settings']->cssId = null;
$app['settings'] = $app['settings']->save();
Session::setFlashMessage('success', 'Theme ' . $cssFilename . ' deleted.');
$msg = "Theme {$cssFilename} deleted.";
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) {
Log::error("Failed to update config after deleting theme: " . $e->getMessage());
Session::setFlashMessage('error', 'Theme deleted but failed to update settings');
$msg = "Failed to update config after deleting theme.";
Log::error($msg . ' ' . $e->getMessage());
Session::setFlashMessage('error', $msg);
}
}
private function handleSetTheme() {
private function handleSetTheme(): void {
global $app;
try {
if ($_POST['selectCssFile']){
// Set custom theme
$app['settings']->cssId = $_POST['selectCssFile'];
$app['settings']->cssId = (int)($_POST['selectCssFile']);
} else {
// Set default theme
$app['settings']->cssId = null;
@ -144,7 +193,7 @@ class CssController extends Controller {
}
}
private function handleUpload() {
private function handleUpload(): void {
try {
// Check if file was uploaded
if (!isset($_FILES['uploadCssFile']) || $_FILES['uploadCssFile']['error'] !== UPLOAD_ERR_OK) {
@ -199,12 +248,11 @@ class CssController extends Controller {
} catch (Exception $e) {
// Set error flash message
// Todo - don't do a global catch like this. Subclass Exception.
Session::setFlashMessage('error', 'Upload exception: ' . $e->getMessage());
}
}
private function validateCssContent($content) {
private function validateCssContent($content): void {
// Remove comments
$content = preg_replace('/\/\*.*?\*\//s', '', $content);
@ -225,7 +273,7 @@ class CssController extends Controller {
}
}
private function scanForMaliciousContent($content, $fileName) {
private function scanForMaliciousContent($content, $fileName): void {
// Check for suspicious patterns
$suspiciousPatterns = [
'/javascript:/i',
@ -264,12 +312,11 @@ class CssController extends Controller {
}
}
private function generateSafeFileName($originalName) {
private function generateSafeFileName($originalName): string {
// Remove path information and dangerous characters
$fileName = basename($originalName);
$fileName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $fileName);
return $fileName;
}
}

View File

@ -10,9 +10,10 @@ declare(strict_types=1);
$emojiModel = new EmojiModel($app['db']);
$emojiList = $emojiModel->getAll();
} catch (Exception $e) {
Log::error("Failed to load emoji list: " . $e->getMessage());
$emojiList = [];
Session::setFlashMessage('error', 'Failed to load custom emoji');
$msg = "Failed to load emoji list.";
Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
}
$vars = [
@ -49,23 +50,31 @@ declare(strict_types=1);
// TODO - log a warning if mbstring isn't loaded
$charCount = mb_strlen($emoji, 'UTF-8');
if ($charCount !== 1) {
// TODO - handle error
$msg = "Emoji must be a single UTF-8 encoded character.";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false;
}
} else {
Log::warning("mbstring extension not loaded. Skipping emoji character count validation.");
}
// Validate the emoji is actually an emoji
$emojiPattern = '/^[\x{1F000}-\x{1F9FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{1F600}-\x{1F64F}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F1E0}-\x{1F1FF}\x{1F900}-\x{1F9FF}\x{1FA70}-\x{1FAFF}]$/u';
if (!preg_match($emojiPattern, $emoji)) {
// TODO - handle error
$msg = "Character is not a valid emoji.";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false;
}
// emojis should have more bytes than characters
$byteCount = strlen($emoji);
if ($byteCount <= 1) {
// TODO - handle error
$msg = "Character is not a valid emoji (too few bytes).";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false;
}
@ -76,7 +85,7 @@ declare(strict_types=1);
global $app;
if (!$this->isValidEmoji($emoji)){
Session::setFlashMessage('error', 'Invalid emoji format');
// exceptions are handled in isValidEmoji
return;
}
@ -84,10 +93,14 @@ declare(strict_types=1);
try {
$emojiModel = new EmojiModel($app['db']);
$emojiModel->add($emoji, $description);
Session::setFlashMessage('success', 'Emoji added successfully');
$msg = "Emoji added: {$emoji} - {$description}";
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) {
Log::error("Failed to add emoji: " . $e->getMessage());
Session::setFlashMessage('error', 'Failed to add emoji');
$msg = "Failed to add emoji.";
Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
}
}
@ -100,10 +113,14 @@ declare(strict_types=1);
try {
$emojiModel = new EmojiModel($app['db']);
$emojiModel->delete($ids);
Session::setFlashMessage('success', 'Emoji deleted successfully');
$msg = "Emoji deleted.";
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) {
Log::error("Failed to delete emoji: " . $e->getMessage());
Session::setFlashMessage('error', 'Failed to delete emoji');
$msg = "Failed to delete emoji.";
Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
}
}
}

View File

@ -6,8 +6,8 @@ class HomeController extends Controller {
// renders the homepage view.
public function index(){
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$data = $this->getHomeData($page);
$this->render("home.php", $data);
$vars = $this->getHomeData($page);
$this->render("home.php", $vars);
}
public function getHomeData(int $page): array {

View File

@ -104,7 +104,7 @@ class LogController extends Controller {
}
private function parseLogLine(string $line): ?array {
// Parse format: [2025-01-31 08:30:15] DEBUG: 192.168.1.100 [GET feed/rss] - message
// Parse format: [2025-01-31 08:30:15] DEBUG: 192.168.1.100 [GET admin/settings] - message
$pattern = '/^\[([^\]]+)\] (\w+): ([^\s]+)(?:\s+\[([^\]]+)\])? - (.+)$/';
if (preg_match($pattern, $line, $matches)) {

View File

@ -2,22 +2,27 @@
declare(strict_types=1);
class TickController extends Controller{
public function index(int $id){
public function index(string $id){
// This is because my router is too simplistic to cleanly handle type casting,
// so I just accept a sting here and cast it to an int immediately.
$id = (int) $id;
global $app;
$vars = ['settings' => $app['settings']];
Log::debug("Fetching tick with ID: {$id}");
try {
$tickModel = new TickModel($app['db'], $app['settings']);
$vars = $tickModel->get($id);
$tick = $tickModel->get($id);
if (empty($vars) || !isset($vars['tick'])) {
if (empty($tick) || !isset($tick['tick'])) {
Log::warning("Tick not found for ID: {$id}");
http_response_code(404);
echo '<h1>404 - Tick Not Found</h1>';
$this->render('tick-404.php', $vars);
return;
}
$vars = array_merge($tick, $vars);
Log::info("Successfully loaded tick {$id}: " . substr($vars['tick'], 0, 50) . (strlen($vars['tick']) > 50 ? '...' : ''));
$this->render('tick.php', $vars);
@ -56,6 +61,6 @@ class TickController extends Controller{
// Redirect back to homepage
header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, ''));
exit();
exit;
}
}

View File

@ -18,7 +18,7 @@ class AtomGenerator extends FeedGenerator {
Log::debug("Building Atom feed for " . $this->settings->siteTitle);
$feedTitle = Util::escape_xml($this->settings->siteTitle . " Atom Feed");
$siteUrl = Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath));
$feedUrl = Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'feed/atom'));
$feedUrl = Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'atom'));
$updated = date(DATE_ATOM, strtotime($this->ticks[0]['timestamp'] ?? 'now'));
ob_start();
@ -41,7 +41,7 @@ class AtomGenerator extends FeedGenerator {
$tickUrl = Util::escape_xml($siteUrl . $tickPath);
$tickTime = date(DATE_ATOM, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']);
$tickContent = Util::linkify($tickTitle);
$tickContent = Util::escape_xml(Util::linkify(Util::escape_html($tick['tick'])));
?>
<entry>
<title><?= $tickTitle ?></title>

View File

@ -23,7 +23,7 @@ class RssGenerator extends FeedGenerator {
<channel>
<title><?php echo Util::escape_xml($this->settings->siteTitle . ' RSS Feed') ?></title>
<link><?php echo Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath))?></link>
<atom:link href="<?php echo Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'feed/rss'))?>"
<atom:link href="<?php echo Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'rss'))?>"
rel="self"
type="application/rss+xml" />
<description><?php echo Util::escape_xml($this->settings->siteDescription) ?></description>
@ -35,10 +35,11 @@ class RssGenerator extends FeedGenerator {
$tickUrl = Util::escape_xml($this->buildTickUrl($tick['id']));
$tickDate = date(DATE_RSS, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']);
$tickDescription = Util::linkify($tickTitle);
$tickDescription = Util::escape_xml(Util::linkify(Util::escape_html($tick['tick'])));
Log::debug("RSS item: {$tickDescription}");
?>
<item>
<title><?php echo $tickTitle ?></title>
<title><?php echo $tickTitle; ?></title>
<link><?php echo $tickUrl; ?></link>
<description><?php echo $tickDescription; ?></description>
<pubDate><?php echo $tickDate; ?></pubDate>

View File

@ -407,7 +407,7 @@ class Prerequisites {
}
}
private function createDatabase() {
private function createDatabase(): bool {
$dbFile = $this->baseDir . '/storage/db/tkr.sqlite';
// Test database connection (will create file if needed)
@ -442,7 +442,7 @@ class Prerequisites {
}
}
private function applyMigrations($db) {
public function applyMigrations($db): bool {
try {
$migrator = new Migrator($db);
$migrator->migrate();
@ -452,8 +452,6 @@ class Prerequisites {
true,
'All database migrations applied successfully'
);
return true;
} catch (Exception $e) {
$this->addCheck(
'Database Migrations',
@ -463,6 +461,8 @@ class Prerequisites {
);
return false;
}
return true;
}
// Validate system requirements that can't be fixed by the script
@ -511,6 +511,8 @@ class Prerequisites {
// Create missing application components
public function createMissing(): bool {
// If we're calling this, there were likely setup validation errors
$currentErrors = count($this->errors);
$this->log("=== tkr setup started at " . date('Y-m-d H:i:s') . " ===", true);
if ($this->isCli) {
@ -526,8 +528,8 @@ class Prerequisites {
$this->generateCliSummary($results);
}
// Return true only if no errors occurred
return count($this->errors) === 0;
// Return true only if no NEW errors occurred
return count($this->errors) === $currentErrors;
}
/**

View File

@ -15,8 +15,8 @@ class Router {
['admin/emoji', 'EmojiController'],
['admin/emoji', 'EmojiController@handlePost', ['POST']],
['admin/logs', 'LogController'],
['feed/rss', 'FeedController@rss'],
['feed/atom', 'FeedController@atom'],
['rss', 'FeedController@rss'],
['atom', 'FeedController@atom'],
['login', 'AuthController@showLogin'],
['login', 'AuthController@handleLogin', ['POST']],
['logout', 'AuthController@handleLogout', ['GET', 'POST']],
@ -27,6 +27,7 @@ class Router {
['tick/{id}', 'TickController'],
['tick/{id}/delete', 'TickController@handleDelete', ['POST']],
['css/custom/{filename}.css', 'CssController@serveCustomCss'],
['css/default.css', 'CssController@serveDefaultCss'],
];

View File

@ -7,6 +7,15 @@ class Session {
// global $_SESSION associative array
public static function start(): void{
if (session_status() === PHP_SESSION_NONE) {
// Cookie security settings
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_samesite', 'Strict');
// Enable secure cookie flag if HTTPS is being used
if (($_SERVER['HTTPS'] ?? 'off') === 'on') {
ini_set('session.cookie_secure', 1);
}
$existingSessionId = $_COOKIE['PHPSESSID'] ?? null;
session_start();
@ -58,7 +67,6 @@ class Session {
// valid types are:
// - success
// - error
// - info
// - warning
public static function setFlashMessage(string $type, string $message): void {
if (!isset($_SESSION['flash'][$type])){

View File

@ -125,6 +125,13 @@ class Util {
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
$basePath = dirname($scriptName);
// Handle shared hosting scenario where document root can't be set to public/
// If script name ends with /public/index.php, we need to go up one directory
if (str_ends_with($scriptName, '/public/index.php')) {
$basePath = dirname($basePath);
}
# Ensure base path always has a trailing /
if ($basePath === '/' || $basePath === '.' || $basePath === '') {
$basePath = '/';
} else {
@ -132,10 +139,7 @@ class Util {
}
// Construct full URL
$fullUrl = $baseUrl;
if ($basePath !== '/') {
$fullUrl .= ltrim($basePath, '/');
}
$fullUrl = $baseUrl . $basePath;
return [
'baseUrl' => $baseUrl,

View File

@ -12,8 +12,7 @@ class SettingsModel {
public ?int $cssId = null;
public bool $strictAccessibility = true;
public ?int $logLevel = null;
// not currently configurable
public int $tickDeleteHours = 1;
public ?int $tickDeleteHours = null;
public function __construct(private PDO $db) {}
@ -28,7 +27,8 @@ class SettingsModel {
items_per_page,
css_id,
strict_accessibility,
log_level
log_level,
tick_delete_hours
FROM settings WHERE id=1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
@ -41,7 +41,8 @@ class SettingsModel {
$c->itemsPerPage = (int) $row['items_per_page'];
$c->cssId = (int) $row['css_id'];
$c->strictAccessibility = (bool) $row['strict_accessibility'];
$c->logLevel = $row['log_level'];
$c->logLevel = (int) ($row['log_level'] ?? 2);
$c->tickDeleteHours = (int) ($row['tick_delete_hours'] ?? 1);
}
return $c;
@ -51,6 +52,7 @@ class SettingsModel {
$settingsCount = (int) $this->db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
if ($settingsCount === 0){
Log::debug('Initializing settings');
$stmt = $this->db->prepare("INSERT INTO settings (
id,
site_title,
@ -60,10 +62,12 @@ class SettingsModel {
items_per_page,
css_id,
strict_accessibility,
log_level
log_level,
tick_delete_hours
)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)");
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
} else {
Log::debug('Updating settings');
$stmt = $this->db->prepare("UPDATE settings SET
site_title=?,
site_description=?,
@ -72,10 +76,21 @@ class SettingsModel {
items_per_page=?,
css_id=?,
strict_accessibility=?,
log_level=?
log_level=?,
tick_delete_hours=?
WHERE id=1");
}
Log::debug("Site title: " . $this->siteTitle);
Log::debug("Site description: " . $this->siteDescription);
Log::debug("Base URL: " . $this->baseUrl);
Log::debug("Base path: " . $this->basePath);
Log::debug("Items per page: " . $this->itemsPerPage);
Log::debug("CSS ID: " . $this->cssId);
Log::debug("Strict accessibility: " . $this->strictAccessibility);
Log::debug("Log level: " . $this->logLevel);
Log::debug("Tick delete window: " . $this->tickDeleteHours);
$stmt->execute([$this->siteTitle,
$this->siteDescription,
$this->baseUrl,
@ -83,7 +98,8 @@ class SettingsModel {
$this->itemsPerPage,
$this->cssId,
$this->strictAccessibility,
$this->logLevel
$this->logLevel,
$this->tickDeleteHours
]);
return $this->get();

View File

@ -41,7 +41,6 @@ class TickModel {
return [
'tickTime' => $row['timestamp'],
'tick' => $row['tick'],
'settings' => $this->settings,
];
}

View File

@ -23,14 +23,14 @@ class TicksView {
$relativeTime = Util::relative_time($tick['timestamp']);
?>
<li class="tick" tabindex="0">
<?php if ($tick['can_delete']): ?>
<?php if (Session::isLoggedIn() && $tick['can_delete']): ?>
<form method="post"
action="<?= Util::buildRelativeUrl($settings->basePath, "tick/{$tick['id']}/delete") ?>"
class="delete-tick-form">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<button type="submit" class="delete-tick-button">🗑️</button>
</form>
<?php endif ?>
<?php endif ?>
<time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
<span class="tick-text"><?php echo Util::linkify(Util::escape_html($tick['tick'])) ?></span>
</li>

View File

@ -19,11 +19,11 @@
<link rel="alternate"
type="application/rss+xml"
title="<?php echo Util::escape_html($settings->siteTitle) ?> RSS Feed"
href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>feed/rss/">
href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>rss/">
<link rel="alternate"
type="application/atom+xml"
title="<?php echo Util::escape_html($settings->siteTitle) ?> Atom Feed"
href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>feed/atom/">
href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>atom/">
</head>
<body>
<?php include TEMPLATES_DIR . '/partials/navbar.php'?>

View File

@ -1,12 +1,16 @@
<?php /** @var SettingsModel $settings */ ?>
<?php /** @var UserModel $user */ ?>
<?php /** @var isSetup bool */ ?>
<h1><?php if ($isSetup): ?>Setup<?php else: ?>Admin<?php endif; ?></h1>
<?php
$title = $isSetup ? 'Setup' : 'Admin';
$urlPath = $isSetup ? 'tkr-setup' : 'admin'
?>
<h1><?php echo $title ?></h1>
<main>
<form
action="<?php echo Util::buildRelativeUrl($settings->basePath, ($isSetup ? 'setup' : 'admin')) ?>"
action="<?php echo Util::buildRelativeUrl($settings->basePath, $urlPath) ?>"
method="post">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<input type="hidden" name="csrf_token" value="<?php echo Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset>
<legend>User settings</legend>
<div class="fieldset-items">
@ -14,19 +18,19 @@
<input type="text"
id="username"
name="username"
value="<?= Util::escape_html($user->username) ?>"
value="<?php echo Util::escape_html($user->username) ?>"
required>
<label for="display_name">Display name <span class=required>*</span></label>
<input type="text"
id="display_name"
name="display_name"
value="<?= Util::escape_html($user->displayName) ?>"
value="<?php echo Util::escape_html($user->displayName) ?>"
required>
<label for="website">Website </label>
<input type="text"
id="website"
name="website"
value="<?= Util::escape_html($user->website) ?>">
value="<?php echo Util::escape_html($user->website) ?>">
</div>
</fieldset>
<fieldset>
@ -36,43 +40,48 @@
<input type="text"
id="site_title"
name="site_title"
value="<?= Util::escape_html($settings->siteTitle) ?>"
value="<?php echo Util::escape_html($settings->siteTitle) ?>"
required>
<label for="site_description">Description <span class=required>*</span></label>
<input type="text"
id="site_description"
name="site_description"
value="<?= Util::escape_html($settings->siteDescription) ?>">
value="<?php echo Util::escape_html($settings->siteDescription) ?>">
<label for="base_url">Base URL <span class=required>*</span></label>
<input type="text"
id="base_url"
name="base_url"
value="<?= Util::escape_html($settings->baseUrl) ?>"
value="<?php echo Util::escape_html($settings->baseUrl) ?>"
required>
<label for="base_path">Base path <span class=required>*</span></label>
<input type="text"
id="base_path"
name="base_path"
value="<?= Util::escape_html($settings->basePath) ?>"
value="<?php echo Util::escape_html($settings->basePath) ?>"
required>
<label for="items_per_page">Ticks per page (max 50) <span class=required>*</span></label>
<input type="number"
id="items_per_page"
name="items_per_page"
value="<?= $settings->itemsPerPage ?>" min="1" max="50"
value="<?php echo $settings->itemsPerPage ?>" min="1" max="50"
required>
<label for="tick_delete_hours">Tick delete window (hours)</label>
<input type="number"
id="tick_delete_hours"
name="tick_delete_hours"
value="<?php echo ($settings->tickDeleteHours ?? 1) ?>" min="1">
<label for="strict_accessibility">Strict accessibility</label>
<input type="checkbox"
id="strict_accessibility"
name="strict_accessibility"
value="1"
<?php if ($settings->strictAccessibility): ?> checked <?php endif; ?>>
<label for="strict_accessibility">Log Level</label>
<label for="log_level">Log Level</label>
<select id="log_level" name="log_level">
<option value="1" <?= ($settings->logLevel ?? 2) == 1 ? 'selected' : '' ?>>DEBUG</option>
<option value="2" <?= ($settings->logLevel ?? 2) == 2 ? 'selected' : '' ?>>INFO</option>
<option value="3" <?= ($settings->logLevel ?? 2) == 3 ? 'selected' : '' ?>>WARNING</option>
<option value="4" <?= ($settings->logLevel ?? 2) == 4 ? 'selected' : '' ?>>ERROR</option>
<option value="1" <?php echo ($settings->logLevel ?? 2) == 1 ? 'selected' : '' ?>>DEBUG</option>
<option value="2" <?php echo ($settings->logLevel ?? 2) == 2 ? 'selected' : '' ?>>INFO</option>
<option value="3" <?php echo ($settings->logLevel ?? 2) == 3 ? 'selected' : '' ?>>WARNING</option>
<option value="4" <?php echo ($settings->logLevel ?? 2) == 4 ? 'selected' : '' ?>>ERROR</option>
</select>
</div>
</fieldset>

View File

@ -7,9 +7,9 @@
<summary aria-haspopup="true">feeds</summary>
<div class="dropdown-items">
<a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'feed/rss')) ?>">rss</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'rss')) ?>">rss</a>
<a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'feed/atom')) ?>">atom</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'atom')) ?>">atom</a>
</div>
</details>
<?php if (!Session::isLoggedIn()): ?>

View File

@ -0,0 +1,4 @@
<div class="not-found-container">
<h1>Tick Not Found</h1>
<p>The tick you're looking for has been deleted or never existed.</p>
</div>

View File

@ -1,4 +1,14 @@
<?php /** @var Date $tickTime */ ?>
<?php /** @var string $tick */ ?>
<h1>Tick from <?= $tickTime; ?></h1>
<p><?= Util::linkify(Util::escape_html($tick)) ?></p>
<?php $displayTime = DateTimeImmutable::createFromformat('Y-m-d H:i:s', $tickTime) ?>
<div class="tick-container">
<article class="tick">
<header class="tick-header">
<h1>Tick</h1>
<p class="tick-meta">Posted on <time class="tick-meta" datetime="<?= $displayTime->format('c') ?>"><?= $displayTime->format('F j, Y \a\t g:i A') ?></time> UTC</p>
</header>
<div class="tick-text">
<?= Util::linkify(Util::escape_html($tick)) ?>
</div>
</article>
</div>

View File

@ -22,6 +22,7 @@ class AdminControllerTest extends TestCase
$this->settings->baseUrl = 'https://example.com';
$this->settings->basePath = '/tkr';
$this->settings->itemsPerPage = 10;
$this->settings->tickDeleteHours = 2;
$this->user = new UserModel($this->mockPdo);
$this->user->username = 'testuser';
@ -119,7 +120,8 @@ class AdminControllerTest extends TestCase
'items_per_page' => 15,
'css_id' => null,
'strict_accessibility' => true,
'log_level' => 2
'log_level' => 2,
'tick_delete_hours' => 3
],
[
'username' => 'newuser',
@ -153,7 +155,8 @@ class AdminControllerTest extends TestCase
'base_path' => '/updated',
'items_per_page' => 15,
'strict_accessibility' => 'on',
'log_level' => 2
'log_level' => 2,
'tick_delete_hours' => 3
];
$result = $controller->saveSettings($postData, false);
@ -177,7 +180,8 @@ class AdminControllerTest extends TestCase
'items_per_page' => 10,
'css_id' => null,
'strict_accessibility' => true,
'log_level' => 2
'log_level' => 2,
'tick_delete_hours' => 3
],
[
'username' => 'testuser',

View File

@ -62,7 +62,7 @@ class TickControllerTest extends TestCase
ob_start();
$controller = new TickController();
$controller->index(123);
$controller->index("123");
$output = ob_get_clean();
@ -96,12 +96,12 @@ class TickControllerTest extends TestCase
ob_start();
$controller = new TickController();
$controller->index(999);
$controller->index("999");
$output = ob_get_clean();
// Should return 404 error
$this->assertStringContainsString('404 - Tick Not Found', $output);
$this->assertStringContainsString('Tick Not Found', $output);
}
public function testIndexWithEmptyTickData(): void
@ -125,12 +125,12 @@ class TickControllerTest extends TestCase
ob_start();
$controller = new TickController();
$controller->index(456);
$controller->index("456");
$output = ob_get_clean();
// Should return 404 error for empty data
$this->assertStringContainsString('404 - Tick Not Found', $output);
$this->assertStringContainsString('Tick Not Found', $output);
}
public function testIndexWithDatabaseException(): void
@ -145,7 +145,7 @@ class TickControllerTest extends TestCase
ob_start();
$controller = new TickController();
$controller->index(123);
$controller->index("123");
$output = ob_get_clean();

View File

@ -35,7 +35,7 @@ class AtomGeneratorTest extends TestCase
$this->assertStringContainsString('<title>Test Site Atom Feed</title>', $xml);
$this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml);
$this->assertStringContainsString('<link rel="self"', $xml);
$this->assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml);
$this->assertStringContainsString('href="https://example.com/tkr/atom"', $xml);
$this->assertStringContainsString('<id>https://example.com/tkr/</id>', $xml);
$this->assertStringContainsString('<author>', $xml);
$this->assertStringContainsString('<name>Test Site</name>', $xml);
@ -69,7 +69,7 @@ class AtomGeneratorTest extends TestCase
$this->assertStringContainsString('<title>Test Site Atom Feed</title>', $xml);
$this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml);
$this->assertStringContainsString('<link rel="self"', $xml);
$this->assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml);
$this->assertStringContainsString('href="https://example.com/tkr/atom"', $xml);
$this->assertStringContainsString('<id>https://example.com/tkr/</id>', $xml);
$this->assertStringContainsString('<author>', $xml);
$this->assertStringContainsString('<name>Test Site</name>', $xml);

View File

@ -34,7 +34,7 @@ class RssGeneratorTest extends TestCase
$this->assertStringContainsString('<rss version="2.0"', $xml);
$this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml);
$this->assertStringContainsString('<link>https://example.com/tkr/</link>', $xml);
$this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/rss"', $xml);
$this->assertStringContainsString('<atom:link href="https://example.com/tkr/rss"', $xml);
$this->assertStringContainsString('<channel>', $xml);
$this->assertStringContainsString('<item>', $xml);
$this->assertStringContainsString('</item>', $xml);
@ -66,7 +66,7 @@ class RssGeneratorTest extends TestCase
$this->assertStringContainsString('<rss version="2.0"', $xml);
$this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml);
$this->assertStringContainsString('<link>https://example.com/tkr/</link>', $xml);
$this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/rss"', $xml);
$this->assertStringContainsString('<atom:link href="https://example.com/tkr/rss"', $xml);
$this->assertStringContainsString('<channel>', $xml);
$this->assertStringContainsString('</channel>', $xml);
$this->assertStringEndsWith('</rss>' . "\n", $xml);

View File

@ -177,6 +177,7 @@ try {
// Create/update settings
$settingsModel = new SettingsModel($db);
$settingsModel->siteTitle = $siteTitle;
$settingsModel->siteDescription = $siteTitle;
$settingsModel->baseUrl = $baseUrl;
$settingsModel->basePath = $basePath;
$settings = $settingsModel->save();
@ -184,7 +185,7 @@ try {
// Create admin user
$userModel = new UserModel($db);
$userModel->username = $adminUsername;
$userModel->display_name = $adminUsername;
$userModel->displayName = $adminUsername;
$userModel->website = '';
$userModel->mood = '';
$user = $userModel->save();