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

3
.gitignore vendored
View File

@ -14,9 +14,10 @@ storage/logs
# Testing stuff # Testing stuff
/docker-compose.yml /docker-compose.yml
scratch scratch
storage.bak
# Build artifacts # Build artifacts
tkr.tgz tkr.tgz
# Test logs # Test logs
storage/prerequisite-check.log storage/prerequisite-check.log

231
README.md
View File

@ -2,95 +2,77 @@
![Prerequisite tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/prerequisites.yaml/badge.svg) ![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) ![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" ## Requirements
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%">
### Desktop * A server running Linux
* A web server, such as apache or nginx
<img src="https://subcultureofone.org/images/tkr/tkr-logged-out-desktop-v4.png" * Web server configs are in the `examples` directory
alt="tkr logged in view - desktop" * PHP 8.2+
width="60%" height="60%"> * Required extensions:
* [PDO](https://www.php.net/pdo)
<img src="https://subcultureofone.org/images/tkr/tkr-logged-in-desktop-v4.png" * [PDO_SQLITE](https://www.php.net/pdo-sqlite)
alt="tkr logged in view - desktop" * Recommended extensions:
width="60%" height="60%"> * [mbstring](https://www.php.net/mbstring)
* [fileinfo](https://www.php.net/fileinfo)
## 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
## Installation ## Installation
1. Download the latest tkr archive from [the packages page](https://gitea.subcultureofone.org/greg/tkr/packages) 1. Get the latest package from https://gitea.subcultureofone.org/greg/tkr/packages
1. Copy the `.tgz` file to your server and extract it 1. Copy it to your web server
1. Copy the `tkr` directory to the location you want to serve it from 1. Set up the directory permissions:
* on debian-based systems, `/var/www/tkr` is recommended * `tkr/storage` must be writable by the web server account
1. Make the `storage` directory writable by the web server account. * `www-data` on debian-based systems
```sh * `apache` on redhat-based systems
chown www-data:www-data /path/to/tkr/storage * All other `tkr` directories should be *readable* by the web server account.
chmod 0770 /path/to/tkr/storage * For example, if you're on debian:
``` ```sh
1. Add the necessary web server configuration. # Make 'root' the owner of everything under 'tkr'
* Examples for common scenarios can be found in the [examples](./examples) directory. chown -R root:root tkr
* 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
# 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: If any prerequisites are missing (required PHP extensions, directory permissions)
1. PHP 8.2+ is installed
1. All required PHP extensions are installed ## Features
1. PDO
1. PDO::sqlite * RSS `/rss` and Atom `/atom` feeds
1. All required directories exist * HTML and CSS implementation. No Javascript.
1. The `tkr/storage` directory exists and is writable * Accessible HTML, with strict accessibility settng to aid tab navigation
1. If `tkr/storage` is writable, then it will create the required subdirectories * CSS uploads for custom theming
1. `tkr/storage/db` * Custom emoji to personalize moods
1. `tkr/storage/upload`
1. The script will write a summary to stdout and will save a log at `tkr/storage/prerequisite-check.log` 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!
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)
### Server configuration notes ### 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/storage`
* `tkr/templates` * `tkr/templates`
### Docker compose (local development)
### Docker compose
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. 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 ## 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. 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 ## 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. ### SQLite Database Note
```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`.
You don't have to do any database setup. The database is automatically created and initialized on first run. 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 ## Acknowledgements
@ -162,30 +161,4 @@ 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! * [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. * [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. * [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 // - define paths
// - set up autoloader // - 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 all the important paths
define('APP_ROOT', dirname(dirname(__FILE__))); define('APP_ROOT', dirname(dirname(__FILE__)));
// Root-level directories
define('CONFIG_DIR', APP_ROOT . '/config'); define('CONFIG_DIR', APP_ROOT . '/config');
define('PUBLIC_DIR', APP_ROOT . '/public');
define('SRC_DIR', APP_ROOT . '/src'); define('SRC_DIR', APP_ROOT . '/src');
define('STORAGE_DIR', APP_ROOT . '/storage'); define('STORAGE_DIR', APP_ROOT . '/storage');
define('TEMPLATES_DIR', APP_ROOT . '/templates'); // Storage subdirectories
define('DATA_DIR', STORAGE_DIR . '/db');
define('DB_FILE', DATA_DIR . '/tkr.sqlite');
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css'); 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 // Janky autoloader function
// This is a bit more consistent with current frameworks // 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 # Basic .htaccess for tkr on shared hosting
# for serving tkr as a subdirectory path # For use with included docker-compose.yml
# 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 # Enable mod_rewrite
RewriteEngine On RewriteEngine On
# Security headers # Set directory index
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 DirectoryIndex public/index.php
# Security: Block direct access to .php files (except through rewrites) # Block access to sensitive directories
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC] RewriteRule ^(storage|src|templates|config)(/.*)?$ - [F,L]
RewriteRule ^.*$ - [R=404,L]
# Security: Block access to sensitive directories # Block access to hidden files
RewriteRule ^(storage|src|templates|examples|config)(/.*)?$ - [F,L]
# Security: Block access to hidden files
RewriteRule ^\..*$ - [F,L] RewriteRule ^\..*$ - [F,L]
# Cache CSS files for 1 hour # Block access to setup script
<FilesMatch "\.css$"> RewriteRule ^tkr-setup\.php$ - [F,L]
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
# Serve the one static file that exists: css/tkr.css # Route everything else through public/index.php
# (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
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ public/index.php [L] RewriteRule ^(.*)$ public/index.php [L]

View File

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

View File

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

View File

@ -1,19 +1,21 @@
# Example Apache VirtualHost # Basic Apache VirtualHost for tkr
# for serving tkr as a subdomain root without SSL # For use with included docker-compose.yml
# e.g. http://tkr.my-domain.com/
# # HTTP - redirect to HTTPS
# NOTE: Do not use in production.
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
<VirtualHost *:80> <VirtualHost *:80>
ServerName localhost ServerName localhost
DocumentRoot /var/www/tkr/public DocumentRoot /var/www/tkr/public
# Security headers # Main directory - route everything through index.php
Header always set X-Frame-Options "SAMEORIGIN" <Directory "/var/www/tkr/public">
Header always set X-XSS-Protection "1; mode=block" AllowOverride None
Header always set X-Content-Type-Options "nosniff" Require all granted
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 # Block access to sensitive directories
<Directory "/var/www/tkr/storage"> <Directory "/var/www/tkr/storage">
@ -22,59 +24,13 @@
<Directory "/var/www/tkr/src"> <Directory "/var/www/tkr/src">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config"> <Directory "/var/www/tkr/config">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/templates">
# Block access to hidden files
<DirectoryMatch "^\.|/\.">
Require all denied 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> </Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log ErrorLog ${APACHE_LOG_DIR}/tkr_error.log
CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined
</VirtualHost> </VirtualHost>

View File

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

View File

@ -1,72 +1,38 @@
# Example Apache VirtualHost # Basic Apache config for tkr in subfolder
# for serving tkr as a subdirectory path without SSL # e.g. https://your-domain.com/tkr
# e.g. http://www.my-domain.com/tkr # For use with included docker-compose.yml
#
# NOTE: Do not use in production. # Alias for tkr subfolder
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
<VirtualHost *:80> <VirtualHost *:80>
ServerName localhost 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 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"> <Directory "/var/www/tkr/storage">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/src"> <Directory "/var/www/tkr/src">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config"> <Directory "/var/www/tkr/config">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/templates">
# 404 all non-css static files in /tkr (images, js, fonts, etc.) Require all denied
# 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> </Directory>
# Error and access logs # Error and access logs
ErrorLog ${APACHE_LOG_DIR}/my-domain_error.log ErrorLog ${APACHE_LOG_DIR}/tkr_error.log
CustomLog ${APACHE_LOG_DIR}/my-domain_access.log combined CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined
</VirtualHost> </VirtualHost>

View File

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

View File

@ -1,100 +1,45 @@
# Example nginx config # Basic nginx config for tkr
# for serving tkr as a subdomain without SSL # Replace "your-domain.com" with your actual domain
# e.g. http://tkr.my-domain.com/ # Replace "/var/www/tkr" with your installation path
#
# NOTE: Do not use in production. # HTTP - redirect to HTTPS
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
server { server {
listen 80; listen 80 default_server;
server_name localhost; server_name localhost;
root /var/www/tkr/public; root /var/www/tkr/public;
index index.php; index index.php;
# Security headers # Block access to sensitive directories
# The first rule is to prevent including in a frame on a different domain. location ~ ^/(storage|src|templates|config) {
# Remove it if you want to do that. deny all;
add_header X-Frame-Options "SAMEORIGIN" always; return 404;
add_header X-XSS-Protection "1; mode=block" always; }
add_header X-Content-Type-Options "nosniff" always;
# Deny access to hidden files # Block access to hidden files
location ~ /\. { location ~ /\. {
deny all; deny all;
access_log off;
log_not_found off;
} }
# PHP routing - everything goes through index.php # Handle PHP files
location / { location ~ \.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 ~* ^/(?!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;
fastcgi_pass php:9000; fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php; fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params; 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_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri; fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string; 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 - ./src:/var/www/tkr/src
- ./storage:/var/www/tkr/storage - ./storage:/var/www/tkr/storage
- ./templates:/var/www/tkr/templates - ./templates:/var/www/tkr/templates
- ./tkr-setup.php:/var/www/html/tkr/tkr-setup.php
command: > command: >
sh -c " sh -c "
chown -R www-data:www-data /var/www/tkr/storage && chown -R www-data:www-data /var/www/tkr/storage &&

View File

@ -1,101 +1,47 @@
# Example nginx config # Basic nginx config for tkr in subfolder
# for serving tkr as a subdfolder without SSL # e.g. https://your-domain.com/tkr
# e.g. http://my-domain.com/tkr # For use with included docker-compose.yml
#
# NOTE: Do not use in production.
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
server { server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server;
# replace localhost with your subdomain
# e.g. tkr.my-domain.com
server_name localhost; server_name localhost;
root /var/www/html; root /var/www/html;
index index.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 { location /tkr {
alias /var/www/tkr/public; alias /var/www/tkr/public;
index index.php; index index.php;
# Cache static files # Block access to sensitive directories
# Note that I don't actually serve most of this (just css) location ~ ^/tkr/(storage|src|templates|config) {
# but this prevents requests for static content from getting to the PHP handler. deny all;
#
# 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$ {
return 404; return 404;
} }
# forward other requests to the fallback block, # Block access to hidden files
# which sends them to php-fpm for handling location ~ /\. {
try_files $uri $uri/ @tkr_fallback; 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 {
location @tkr_fallback {
fastcgi_pass php:9000; fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php; fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params; include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method; fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri; fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string; 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 # Basic .htaccess for tkr on shared hosting
# for serving tkr as a subdirectory path # Place this file inside the tkr directory
# on shared hosting via .htaccess # e.g. if tkr is at /public_html/tkr/, this goes in /public_html/tkr/
#
# 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 # Enable mod_rewrite
RewriteEngine On RewriteEngine On
# Security headers # Set directory index
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 DirectoryIndex public/index.php
# Security: Block direct access to .php files (except through rewrites) # Block access to sensitive directories
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC] RewriteRule ^(storage|src|templates|config)(/.*)?$ - [F,L]
RewriteRule ^.*$ - [R=404,L]
# Security: Block access to sensitive directories # Block access to hidden files
RewriteRule ^(storage|src|templates|examples|config)(/.*)?$ - [F,L]
# Security: Block access to hidden files
RewriteRule ^\..*$ - [F,L] RewriteRule ^\..*$ - [F,L]
# Cache CSS files for 1 hour # Block access to setup script
<FilesMatch "\.css$"> RewriteRule ^tkr-setup\.php$ - [F,L]
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
# Serve the one static file that exists: css/tkr.css # Route everything else through the front controller
# (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
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ public/index.php [L] RewriteRule ^(.*)$ public/index.php [L]

View File

@ -1,39 +1,34 @@
# Example Apache VirtualHost # Basic Apache VirtualHost for tkr
# for serving tkr as a subdomain root with SSL # Replace "your-domain.com" with your actual domain
# e.g. https://tkr.my-domain.com/ # Replace "/var/www/tkr" with your installation path
#
# Use SSL in production. # HTTP - redirect to HTTPS
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
<VirtualHost *:80> <VirtualHost *:80>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com ServerName your-domain.com
ServerName localhost Redirect permanent / https://your-domain.com/
# 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>
# HTTPS
<VirtualHost *:443> <VirtualHost *:443>
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com ServerName your-domain.com
ServerName localhost
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr/public DocumentRoot /var/www/tkr/public
# SSL Configuration # SSL Configuration (using Let's Encrypt)
SSLEngine on SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
# Assumes you're using letsencrypt for cert generation SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem
# Replace with the actual paths to your cert and key
SSLCertificateFile /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem # Main directory - route everything through index.php
SSLCertificateKeyFile /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem <Directory "/var/www/tkr/public">
AllowOverride None
# Security headers Require all granted
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block" RewriteEngine On
Header always set X-Content-Type-Options "nosniff" RewriteCond %{REQUEST_FILENAME} !-f
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Block access to sensitive directories # Block access to sensitive directories
<Directory "/var/www/tkr/storage"> <Directory "/var/www/tkr/storage">
Require all denied Require all denied
@ -41,59 +36,13 @@
<Directory "/var/www/tkr/src"> <Directory "/var/www/tkr/src">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config"> <Directory "/var/www/tkr/config">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/templates">
# Block access to hidden files
<DirectoryMatch "^\.|/\.">
Require all denied 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> </Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log ErrorLog ${APACHE_LOG_DIR}/tkr_error.log
CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined
</VirtualHost> </VirtualHost>

View File

@ -1,91 +1,31 @@
# Example Apache VirtualHost # Basic Apache config for tkr in subfolder
# for serving tkr as a subdirectory path with SSL # e.g. https://your-domain.com/tkr
# e.g. https://www.my-domain.com/tkr # Add this to your existing VirtualHost configuration
#
# 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> # Alias for tkr subfolder
# CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com Alias /tkr /var/www/tkr/public
ServerName localhost
# CONFIG: Replace with your subdomain, e.g. tkr.my-domain.com
DocumentRoot /var/www/tkr/
# SSL Configuration <Directory "/var/www/tkr/public">
SSLEngine on AllowOverride None
Require all granted
# Front controller pattern
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /tkr/index.php [L]
</Directory>
# Assumes you're using letsencrypt for cert generation # Block access to sensitive directories
# Replace with the actual paths to your cert and key <Directory "/var/www/tkr/storage">
SSLCertificateFile /etc/letsencrypt/live/my-domain.com/fullchain.pem Require all denied
SSLCertificateKeyFile /etc/letsencrypt/live/my-domain.com/privkey.pem </Directory>
<Directory "/var/www/tkr/src">
# Security headers Require all denied
Header always set X-Frame-Options "SAMEORIGIN" </Directory>
Header always set X-XSS-Protection "1; mode=block" <Directory "/var/www/tkr/config">
Header always set X-Content-Type-Options "nosniff" Require all denied
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" </Directory>
<Directory "/var/www/tkr/templates">
# tkr Application at /tkr Require all denied
# NOTE: If you change the directory name, </Directory>
# 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>

View File

@ -1,117 +1,51 @@
# Example nginx config # Basic nginx config for tkr
# for serving tkr as a subdomain with SSL # Replace "your-domain.com" with your actual domain
# e.g. https://tkr.my-domain.com/ # Replace "/var/www/tkr" with your installation path
#
# Use SSL in production. # HTTP - redirect to HTTPS
# This is a minimal SSL confiuration server {
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/ listen 80;
listen [::]:80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
# HTTPS
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl; listen [::]:443 ssl;
# CONFIG: replace "localhost" with your subdomain (e.g. tkr.my-domain.com) server_name your-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; root /var/www/tkr/public;
index index.php; index index.php;
# CONFIG: # SSL Configuration (using Let's Encrypt)
# Assumes you're using letsencrypt for cert generation ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
# Replace with the actual paths to your cert and key ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem; # Block access to sensitive directories
location ~ ^/(storage|src|templates|config) {
# Security headers deny all;
# The first rule is to prevent including in a frame on a different domain. return 404;
# Remove it if you want to do that. }
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; # Block access to hidden files
add_header X-Content-Type-Options "nosniff" always;
# Deny access to hidden files
location ~ /\. { location ~ /\. {
deny all; 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 / { location / {
# Cache static files try_files $uri $uri/ @tkr;
# 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 # Handle PHP requests
location @tkr_fallback { location @tkr {
# CONFIG: fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version as needed
# 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; include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method; fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param REQUEST_URI $request_uri; fastcgi_param REQUEST_URI $request_uri;
fastcgi_param QUERY_STRING $query_string; 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 # Basic nginx config for tkr in subfolder
# for serving tkr as a subdfolder with SSL # e.g. https://your-domain.com/tkr
# e.g. https://my-domain.com/tkr # Add this location block to your existing server configuration
#
# 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 location /tkr {
server_name localhost; alias /var/www/tkr/public;
index index.php;
# CONFIG: # Handle PHP files
# Assumes you're using letsencrypt for cert generation location ~ \.php$ {
# Replace with the actual paths to your cert and key fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version/socket as needed
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; fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params; 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 # Block access to sensitive directories
location ~ ^/tkr/(storage|src|templates|uploads|config) { location ~ ^/tkr/(storage|src|templates|config) {
deny all; deny all;
return 404; return 404;
} }
# Block access to hidden files
location ~ /\. {
deny all;
}
# Front controller pattern
try_files $uri $uri/ @tkr;
} }
server { location @tkr {
listen 80 default_server; fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version as needed
listen [::]:80 default_server; fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
return 301 https://$host$request_uri; 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: darkgreen;
--color-flash-success-bg: honeydew; --color-flash-success-bg: honeydew;
--color-flash-success-border-left: forestgreen; --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-border: darkslateblue;
--color-mood-hover: lightsteelblue; --color-mood-hover: lightsteelblue;
--color-mood-selected: lightblue; --color-mood-selected: lightblue;
@ -324,6 +327,12 @@ summary:focus,
color: var(--color-flash-error); 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 { .fieldset-items {
margin-bottom: 14px; margin-bottom: 14px;
display: grid; display: grid;
@ -370,6 +379,11 @@ time {
font-size: 1.0em; font-size: 1.0em;
display: block; display: block;
} }
.tick-meta {
color: var(--color-log-muted);
font-size: 0.9em;
margin-bottom: 0.4em;
}
.tick-pagination a { .tick-pagination a {
margin: 0 5px; margin: 0 5px;

View File

@ -42,8 +42,15 @@ if (!$prerequisites->validateApplication()) {
// Get the working database connection from prerequisites // Get the working database connection from prerequisites
$db = $prerequisites->getDatabase(); $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) // 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 { try {
$user_count = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn(); $user_count = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn();
$settings = (new SettingsModel($db))->get(); $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>"; echo "<p>Please check your installation or contact your hosting provider.</p>";
exit; 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(); Session::generateCsrfToken();
// Remove the base path from the URL // Remove the base path from the URL
if (strpos($path, $app['settings']->basePath) === 0) { // If basePath isn't already set (i.e. we're not autodetecting it en route to tkr-setup),
$path = substr($path, strlen($app['settings']->basePath)); // 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 // 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, // if this is a POST and we aren't in setup,
// make sure there's a valid session // make sure there's a valid session
// if not, redirect to /login or die as appropriate // if not, redirect to /login or die as appropriate
if ($method === 'POST' && $path != 'setup') { if ($method === 'POST' && $path != 'tkr-setup') {
if ($path != 'login'){ if ($path != 'login'){
if (!Session::isValid($_POST['csrf_token'])) { if (!Session::isValid($_POST['csrf_token'])) {
// Invalid session - redirect to /login // 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')); header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'login'));
exit; exit;
} }

View File

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

View File

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

View File

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

View File

@ -10,9 +10,10 @@ declare(strict_types=1);
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiList = $emojiModel->getAll(); $emojiList = $emojiModel->getAll();
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Failed to load emoji list: " . $e->getMessage());
$emojiList = []; $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 = [ $vars = [
@ -49,23 +50,31 @@ declare(strict_types=1);
// TODO - log a warning if mbstring isn't loaded // TODO - log a warning if mbstring isn't loaded
$charCount = mb_strlen($emoji, 'UTF-8'); $charCount = mb_strlen($emoji, 'UTF-8');
if ($charCount !== 1) { 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; return false;
} }
} else {
Log::warning("mbstring extension not loaded. Skipping emoji character count validation.");
} }
// Validate the emoji is actually an emoji // 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'; $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)) { if (!preg_match($emojiPattern, $emoji)) {
// TODO - handle error $msg = "Character is not a valid emoji.";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false; return false;
} }
// emojis should have more bytes than characters // emojis should have more bytes than characters
$byteCount = strlen($emoji); $byteCount = strlen($emoji);
if ($byteCount <= 1) { 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; return false;
} }
@ -76,7 +85,7 @@ declare(strict_types=1);
global $app; global $app;
if (!$this->isValidEmoji($emoji)){ if (!$this->isValidEmoji($emoji)){
Session::setFlashMessage('error', 'Invalid emoji format'); // exceptions are handled in isValidEmoji
return; return;
} }
@ -84,10 +93,14 @@ declare(strict_types=1);
try { try {
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiModel->add($emoji, $description); $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) { } catch (Exception $e) {
Log::error("Failed to add emoji: " . $e->getMessage()); $msg = "Failed to add emoji.";
Session::setFlashMessage('error', 'Failed to add emoji'); Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
} }
} }
@ -100,10 +113,14 @@ declare(strict_types=1);
try { try {
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiModel->delete($ids); $emojiModel->delete($ids);
Session::setFlashMessage('success', 'Emoji deleted successfully'); $msg = "Emoji deleted.";
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Failed to delete emoji: " . $e->getMessage()); $msg = "Failed to delete emoji.";
Session::setFlashMessage('error', '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. // renders the homepage view.
public function index(){ public function index(){
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$data = $this->getHomeData($page); $vars = $this->getHomeData($page);
$this->render("home.php", $data); $this->render("home.php", $vars);
} }
public function getHomeData(int $page): array { public function getHomeData(int $page): array {

View File

@ -104,7 +104,7 @@ class LogController extends Controller {
} }
private function parseLogLine(string $line): ?array { 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+\[([^\]]+)\])? - (.+)$/'; $pattern = '/^\[([^\]]+)\] (\w+): ([^\s]+)(?:\s+\[([^\]]+)\])? - (.+)$/';
if (preg_match($pattern, $line, $matches)) { if (preg_match($pattern, $line, $matches)) {

View File

@ -2,22 +2,27 @@
declare(strict_types=1); declare(strict_types=1);
class TickController extends Controller{ 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; global $app;
$vars = ['settings' => $app['settings']];
Log::debug("Fetching tick with ID: {$id}"); Log::debug("Fetching tick with ID: {$id}");
try { try {
$tickModel = new TickModel($app['db'], $app['settings']); $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}"); Log::warning("Tick not found for ID: {$id}");
http_response_code(404); http_response_code(404);
echo '<h1>404 - Tick Not Found</h1>'; $this->render('tick-404.php', $vars);
return; return;
} }
$vars = array_merge($tick, $vars);
Log::info("Successfully loaded tick {$id}: " . substr($vars['tick'], 0, 50) . (strlen($vars['tick']) > 50 ? '...' : '')); Log::info("Successfully loaded tick {$id}: " . substr($vars['tick'], 0, 50) . (strlen($vars['tick']) > 50 ? '...' : ''));
$this->render('tick.php', $vars); $this->render('tick.php', $vars);
@ -30,32 +35,32 @@ class TickController extends Controller{
public function handleDelete(string $id){ public function handleDelete(string $id){
global $app; global $app;
$id = (int) $id; $id = (int) $id;
Log::debug("Attempting to delete tick with ID: {$id}"); Log::debug("Attempting to delete tick with ID: {$id}");
try { try {
$tickModel = new TickModel($app['db'], $app['settings']); $tickModel = new TickModel($app['db'], $app['settings']);
// TickModel->delete() handles validation and sets flash messages: // TickModel->delete() handles validation and sets flash messages:
// - "Tick not found" if tick doesn't exist // - "Tick not found" if tick doesn't exist
// - "Tick is too old to delete" if outside deletion window // - "Tick is too old to delete" if outside deletion window
// - "Deleted: '{content}'" on success // - "Deleted: '{content}'" on success
$success = $tickModel->delete($id); $success = $tickModel->delete($id);
if ($success) { if ($success) {
Log::info("Successfully deleted tick {$id}"); Log::info("Successfully deleted tick {$id}");
} else { } else {
Log::warning("Failed to delete tick {$id}"); Log::warning("Failed to delete tick {$id}");
} }
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Exception while deleting tick {$id}: " . $e->getMessage()); Log::error("Exception while deleting tick {$id}: " . $e->getMessage());
Session::setFlashMessage('error', 'An error occurred while deleting the tick'); Session::setFlashMessage('error', 'An error occurred while deleting the tick');
} }
// Redirect back to homepage // Redirect back to homepage
header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, '')); 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); Log::debug("Building Atom feed for " . $this->settings->siteTitle);
$feedTitle = Util::escape_xml($this->settings->siteTitle . " Atom Feed"); $feedTitle = Util::escape_xml($this->settings->siteTitle . " Atom Feed");
$siteUrl = Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath)); $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')); $updated = date(DATE_ATOM, strtotime($this->ticks[0]['timestamp'] ?? 'now'));
ob_start(); ob_start();
@ -41,7 +41,7 @@ class AtomGenerator extends FeedGenerator {
$tickUrl = Util::escape_xml($siteUrl . $tickPath); $tickUrl = Util::escape_xml($siteUrl . $tickPath);
$tickTime = date(DATE_ATOM, strtotime($tick['timestamp'])); $tickTime = date(DATE_ATOM, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']); $tickTitle = Util::escape_xml($tick['tick']);
$tickContent = Util::linkify($tickTitle); $tickContent = Util::escape_xml(Util::linkify(Util::escape_html($tick['tick'])));
?> ?>
<entry> <entry>
<title><?= $tickTitle ?></title> <title><?= $tickTitle ?></title>

View File

@ -23,7 +23,7 @@ class RssGenerator extends FeedGenerator {
<channel> <channel>
<title><?php echo Util::escape_xml($this->settings->siteTitle . ' RSS Feed') ?></title> <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> <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" rel="self"
type="application/rss+xml" /> type="application/rss+xml" />
<description><?php echo Util::escape_xml($this->settings->siteDescription) ?></description> <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'])); $tickUrl = Util::escape_xml($this->buildTickUrl($tick['id']));
$tickDate = date(DATE_RSS, strtotime($tick['timestamp'])); $tickDate = date(DATE_RSS, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']); $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> <item>
<title><?php echo $tickTitle ?></title> <title><?php echo $tickTitle; ?></title>
<link><?php echo $tickUrl; ?></link> <link><?php echo $tickUrl; ?></link>
<description><?php echo $tickDescription; ?></description> <description><?php echo $tickDescription; ?></description>
<pubDate><?php echo $tickDate; ?></pubDate> <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'; $dbFile = $this->baseDir . '/storage/db/tkr.sqlite';
// Test database connection (will create file if needed) // Test database connection (will create file if needed)
@ -442,7 +442,7 @@ class Prerequisites {
} }
} }
private function applyMigrations($db) { public function applyMigrations($db): bool {
try { try {
$migrator = new Migrator($db); $migrator = new Migrator($db);
$migrator->migrate(); $migrator->migrate();
@ -452,8 +452,6 @@ class Prerequisites {
true, true,
'All database migrations applied successfully' 'All database migrations applied successfully'
); );
return true;
} catch (Exception $e) { } catch (Exception $e) {
$this->addCheck( $this->addCheck(
'Database Migrations', 'Database Migrations',
@ -463,6 +461,8 @@ class Prerequisites {
); );
return false; return false;
} }
return true;
} }
// Validate system requirements that can't be fixed by the script // Validate system requirements that can't be fixed by the script
@ -511,6 +511,8 @@ class Prerequisites {
// Create missing application components // Create missing application components
public function createMissing(): bool { 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); $this->log("=== tkr setup started at " . date('Y-m-d H:i:s') . " ===", true);
if ($this->isCli) { if ($this->isCli) {
@ -526,8 +528,8 @@ class Prerequisites {
$this->generateCliSummary($results); $this->generateCliSummary($results);
} }
// Return true only if no errors occurred // Return true only if no NEW errors occurred
return count($this->errors) === 0; return count($this->errors) === $currentErrors;
} }
/** /**

View File

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

View File

@ -7,6 +7,15 @@ class Session {
// global $_SESSION associative array // global $_SESSION associative array
public static function start(): void{ public static function start(): void{
if (session_status() === PHP_SESSION_NONE) { 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; $existingSessionId = $_COOKIE['PHPSESSID'] ?? null;
session_start(); session_start();
@ -58,7 +67,6 @@ class Session {
// valid types are: // valid types are:
// - success // - success
// - error // - error
// - info
// - warning // - warning
public static function setFlashMessage(string $type, string $message): void { public static function setFlashMessage(string $type, string $message): void {
if (!isset($_SESSION['flash'][$type])){ if (!isset($_SESSION['flash'][$type])){

View File

@ -125,6 +125,13 @@ class Util {
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php'; $scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
$basePath = dirname($scriptName); $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 === '') { if ($basePath === '/' || $basePath === '.' || $basePath === '') {
$basePath = '/'; $basePath = '/';
} else { } else {
@ -132,10 +139,7 @@ class Util {
} }
// Construct full URL // Construct full URL
$fullUrl = $baseUrl; $fullUrl = $baseUrl . $basePath;
if ($basePath !== '/') {
$fullUrl .= ltrim($basePath, '/');
}
return [ return [
'baseUrl' => $baseUrl, 'baseUrl' => $baseUrl,

View File

@ -12,8 +12,7 @@ class SettingsModel {
public ?int $cssId = null; public ?int $cssId = null;
public bool $strictAccessibility = true; public bool $strictAccessibility = true;
public ?int $logLevel = null; public ?int $logLevel = null;
// not currently configurable public ?int $tickDeleteHours = null;
public int $tickDeleteHours = 1;
public function __construct(private PDO $db) {} public function __construct(private PDO $db) {}
@ -28,7 +27,8 @@ class SettingsModel {
items_per_page, items_per_page,
css_id, css_id,
strict_accessibility, strict_accessibility,
log_level log_level,
tick_delete_hours
FROM settings WHERE id=1"); FROM settings WHERE id=1");
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
@ -41,7 +41,8 @@ class SettingsModel {
$c->itemsPerPage = (int) $row['items_per_page']; $c->itemsPerPage = (int) $row['items_per_page'];
$c->cssId = (int) $row['css_id']; $c->cssId = (int) $row['css_id'];
$c->strictAccessibility = (bool) $row['strict_accessibility']; $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; return $c;
@ -51,6 +52,7 @@ class SettingsModel {
$settingsCount = (int) $this->db->query("SELECT COUNT(*) FROM settings")->fetchColumn(); $settingsCount = (int) $this->db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
if ($settingsCount === 0){ if ($settingsCount === 0){
Log::debug('Initializing settings');
$stmt = $this->db->prepare("INSERT INTO settings ( $stmt = $this->db->prepare("INSERT INTO settings (
id, id,
site_title, site_title,
@ -60,10 +62,12 @@ class SettingsModel {
items_per_page, items_per_page,
css_id, css_id,
strict_accessibility, strict_accessibility,
log_level log_level,
tick_delete_hours
) )
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)"); VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
} else { } else {
Log::debug('Updating settings');
$stmt = $this->db->prepare("UPDATE settings SET $stmt = $this->db->prepare("UPDATE settings SET
site_title=?, site_title=?,
site_description=?, site_description=?,
@ -72,10 +76,21 @@ class SettingsModel {
items_per_page=?, items_per_page=?,
css_id=?, css_id=?,
strict_accessibility=?, strict_accessibility=?,
log_level=? log_level=?,
tick_delete_hours=?
WHERE id=1"); 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, $stmt->execute([$this->siteTitle,
$this->siteDescription, $this->siteDescription,
$this->baseUrl, $this->baseUrl,
@ -83,7 +98,8 @@ class SettingsModel {
$this->itemsPerPage, $this->itemsPerPage,
$this->cssId, $this->cssId,
$this->strictAccessibility, $this->strictAccessibility,
$this->logLevel $this->logLevel,
$this->tickDeleteHours
]); ]);
return $this->get(); return $this->get();

View File

@ -7,14 +7,14 @@ class TickModel {
public function getPage(int $limit, int $offset = 0): array { public function getPage(int $limit, int $offset = 0): array {
$stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?"); $stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?");
$stmt->execute([$limit, $offset]); $stmt->execute([$limit, $offset]);
$ticks = $stmt->fetchAll(PDO::FETCH_ASSOC); $ticks = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(function($tick) { return array_map(function($tick) {
$tickTime = new DateTimeImmutable($tick['timestamp'], new DateTimeZone('UTC')); $tickTime = new DateTimeImmutable($tick['timestamp'], new DateTimeZone('UTC'));
$now = new DateTimeImmutable('now', new DateTimeZone('UTC')); $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600; $hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600;
$tick['can_delete'] = $hoursSinceCreation <= $this->settings->tickDeleteHours; $tick['can_delete'] = $hoursSinceCreation <= $this->settings->tickDeleteHours;
return $tick; return $tick;
}, $ticks); }, $ticks);
@ -41,7 +41,6 @@ class TickModel {
return [ return [
'tickTime' => $row['timestamp'], 'tickTime' => $row['timestamp'],
'tick' => $row['tick'], 'tick' => $row['tick'],
'settings' => $this->settings,
]; ];
} }
@ -50,26 +49,26 @@ class TickModel {
$stmt = $this->db->prepare("SELECT tick, timestamp FROM tick WHERE id=?"); $stmt = $this->db->prepare("SELECT tick, timestamp FROM tick WHERE id=?");
$stmt->execute([$id]); $stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false || empty($row)) { if ($row === false || empty($row)) {
Session::setFlashMessage('error', 'Tick not found'); Session::setFlashMessage('error', 'Tick not found');
return false; return false;
} }
// Check deletion window // Check deletion window
$tickTime = new DateTimeImmutable($row['timestamp'], new DateTimeZone('UTC')); $tickTime = new DateTimeImmutable($row['timestamp'], new DateTimeZone('UTC'));
$now = new DateTimeImmutable('now', new DateTimeZone('UTC')); $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600; $hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600;
if ($hoursSinceCreation > $this->settings->tickDeleteHours) { if ($hoursSinceCreation > $this->settings->tickDeleteHours) {
Session::setFlashMessage('error', 'Tick is too old to delete'); Session::setFlashMessage('error', 'Tick is too old to delete');
return false; return false;
} }
// Delete and set success message // Delete and set success message
$stmt = $this->db->prepare("DELETE FROM tick WHERE id=?"); $stmt = $this->db->prepare("DELETE FROM tick WHERE id=?");
$stmt->execute([$id]); $stmt->execute([$id]);
Session::setFlashMessage('success', "Deleted: '{$row['tick']}'"); Session::setFlashMessage('success', "Deleted: '{$row['tick']}'");
return true; return true;
} }

View File

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

View File

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

View File

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

View File

@ -7,9 +7,9 @@
<summary aria-haspopup="true">feeds</summary> <summary aria-haspopup="true">feeds</summary>
<div class="dropdown-items"> <div class="dropdown-items">
<a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?> <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; ?> <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> </div>
</details> </details>
<?php if (!Session::isLoggedIn()): ?> <?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 Date $tickTime */ ?>
<?php /** @var string $tick */ ?> <?php /** @var string $tick */ ?>
<h1>Tick from <?= $tickTime; ?></h1> <?php $displayTime = DateTimeImmutable::createFromformat('Y-m-d H:i:s', $tickTime) ?>
<p><?= Util::linkify(Util::escape_html($tick)) ?></p> <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->baseUrl = 'https://example.com';
$this->settings->basePath = '/tkr'; $this->settings->basePath = '/tkr';
$this->settings->itemsPerPage = 10; $this->settings->itemsPerPage = 10;
$this->settings->tickDeleteHours = 2;
$this->user = new UserModel($this->mockPdo); $this->user = new UserModel($this->mockPdo);
$this->user->username = 'testuser'; $this->user->username = 'testuser';
@ -119,7 +120,8 @@ class AdminControllerTest extends TestCase
'items_per_page' => 15, 'items_per_page' => 15,
'css_id' => null, 'css_id' => null,
'strict_accessibility' => true, 'strict_accessibility' => true,
'log_level' => 2 'log_level' => 2,
'tick_delete_hours' => 3
], ],
[ [
'username' => 'newuser', 'username' => 'newuser',
@ -153,7 +155,8 @@ class AdminControllerTest extends TestCase
'base_path' => '/updated', 'base_path' => '/updated',
'items_per_page' => 15, 'items_per_page' => 15,
'strict_accessibility' => 'on', 'strict_accessibility' => 'on',
'log_level' => 2 'log_level' => 2,
'tick_delete_hours' => 3
]; ];
$result = $controller->saveSettings($postData, false); $result = $controller->saveSettings($postData, false);
@ -177,7 +180,8 @@ class AdminControllerTest extends TestCase
'items_per_page' => 10, 'items_per_page' => 10,
'css_id' => null, 'css_id' => null,
'strict_accessibility' => true, 'strict_accessibility' => true,
'log_level' => 2 'log_level' => 2,
'tick_delete_hours' => 3
], ],
[ [
'username' => 'testuser', 'username' => 'testuser',

View File

@ -62,7 +62,7 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(123); $controller->index("123");
$output = ob_get_clean(); $output = ob_get_clean();
@ -96,12 +96,12 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(999); $controller->index("999");
$output = ob_get_clean(); $output = ob_get_clean();
// Should return 404 error // Should return 404 error
$this->assertStringContainsString('404 - Tick Not Found', $output); $this->assertStringContainsString('Tick Not Found', $output);
} }
public function testIndexWithEmptyTickData(): void public function testIndexWithEmptyTickData(): void
@ -125,12 +125,12 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(456); $controller->index("456");
$output = ob_get_clean(); $output = ob_get_clean();
// Should return 404 error for empty data // Should return 404 error for empty data
$this->assertStringContainsString('404 - Tick Not Found', $output); $this->assertStringContainsString('Tick Not Found', $output);
} }
public function testIndexWithDatabaseException(): void public function testIndexWithDatabaseException(): void
@ -145,7 +145,7 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(123); $controller->index("123");
$output = ob_get_clean(); $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('<title>Test Site Atom Feed</title>', $xml);
$this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml); $this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml);
$this->assertStringContainsString('<link rel="self"', $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('<id>https://example.com/tkr/</id>', $xml);
$this->assertStringContainsString('<author>', $xml); $this->assertStringContainsString('<author>', $xml);
$this->assertStringContainsString('<name>Test Site</name>', $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('<title>Test Site Atom Feed</title>', $xml);
$this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml); $this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml);
$this->assertStringContainsString('<link rel="self"', $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('<id>https://example.com/tkr/</id>', $xml);
$this->assertStringContainsString('<author>', $xml); $this->assertStringContainsString('<author>', $xml);
$this->assertStringContainsString('<name>Test Site</name>', $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('<rss version="2.0"', $xml);
$this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml); $this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml);
$this->assertStringContainsString('<link>https://example.com/tkr/</link>', $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->assertStringContainsString('<item>', $xml); $this->assertStringContainsString('<item>', $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('<rss version="2.0"', $xml);
$this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml); $this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml);
$this->assertStringContainsString('<link>https://example.com/tkr/</link>', $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->assertStringContainsString('</channel>', $xml); $this->assertStringContainsString('</channel>', $xml);
$this->assertStringEndsWith('</rss>' . "\n", $xml); $this->assertStringEndsWith('</rss>' . "\n", $xml);

View File

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