Compare commits

..

No commits in common. "66f40e5e97e6d13c68cfff8333fb20c275985ece" and "195de75b7816c26acfa626da70b1dbc28255081b" have entirely different histories.

54 changed files with 982 additions and 1150 deletions

3
.gitignore vendored
View File

@ -14,10 +14,9 @@ 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

225
README.md
View File

@ -2,78 +2,96 @@
![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 simple, HTML-only status feed for self-hosted personal websites. Written in PHP. Heavily inspired by [status.cafe](https://status.cafe). A lightweight, HTML-only status feed for self-hosted personal websites. Written in PHP. Heavily inspired by [status.cafe](https://status.cafe).
## LLM Disclosure ## Screenshots
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. ### Mobile
## Requirements <img src="https://subcultureofone.org/images/tkr/tkr-logged-out-mobile-v4.png"
alt="tkr logged out view - mobile"
width="40%" height="40%">
<img src="https://subcultureofone.org/images/tkr/tkr-logged-in-mobile-v4.png"
alt="tkr logged in view - mobile"
width="40%" height="40%">
* A server running Linux ### Desktop
* A web server, such as apache or nginx
* Web server configs are in the `examples` directory
* PHP 8.2+
* Required extensions:
* [PDO](https://www.php.net/pdo)
* [PDO_SQLITE](https://www.php.net/pdo-sqlite)
* Recommended extensions:
* [mbstring](https://www.php.net/mbstring)
* [fileinfo](https://www.php.net/fileinfo)
## Installation <img src="https://subcultureofone.org/images/tkr/tkr-logged-out-desktop-v4.png"
alt="tkr logged in view - desktop"
width="60%" height="60%">
1. Get the latest package from https://gitea.subcultureofone.org/greg/tkr/packages <img src="https://subcultureofone.org/images/tkr/tkr-logged-in-desktop-v4.png"
1. Copy it to your web server alt="tkr logged in view - desktop"
1. Set up the directory permissions: width="60%" height="60%">
* `tkr/storage` must be writable by the web server account
* `www-data` on debian-based systems
* `apache` on redhat-based systems
* All other `tkr` directories should be *readable* by the web server account.
* For example, if you're on debian:
```sh
# Make 'root' the owner of everything under 'tkr'
chown -R root:root tkr
# Make 'www-data' the owner of everything under 'tkr/storage'
chown -R www-data:www-data tkr/storage
# 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`
If any prerequisites are missing (required PHP extensions, directory permissions)
## Features ## Features
* RSS `/rss` and Atom `/atom` feeds
* HTML and CSS implementation. No Javascript. * HTML and CSS implementation. No Javascript.
* Accessible HTML, with strict accessibility settng to aid tab navigation * Accessible by default
* RSS `/feed/rss` and Atom `/feed/atom` feeds
* CSS uploads for custom theming * CSS uploads for custom theming
* Custom emoji to personalize moods * 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! 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
1. Download the latest tkr archive from [the packages page](https://gitea.subcultureofone.org/greg/tkr/packages)
1. Copy the `.tgz` file to your server and extract it
1. Copy the `tkr` directory to the location you want to serve it from
* on debian-based systems, `/var/www/tkr` is recommended
1. Make the `storage` directory writable by the web server account.
```sh
chown www-data:www-data /path/to/tkr/storage
chmod 0770 /path/to/tkr/storage
```
1. Add the necessary web server configuration.
* Examples for common scenarios can be found in the [examples](./examples) directory.
* Apache VPS, subdomain (e.g. `https://tkr.your-domain.com`): [examples/apache/vps/root](./examples/apache/vps/root)
* Apache VPS, subfolder (e.g. `https://your-domain.com/tkr`): [examples/apache/vps/subfolder](./examples/apache/vps/subfolder)
* Nginx VPS, subdomain (e.g. `https://tkr.your-domain.com`): [examples/nginx/root](./examples/nginx/root)
* Nginx VPS, subfolder (e.g. `https://your-domain.com/tkr`): [examples/nginx/subfolder](./examples/nginx/subfolder)
* Any values that need to be configured for your environment are labeled with `CONFIG`.
* The SSL configurations are basic, but should work. For more robust SSL configurations, see https://ssl-config.mozilla.org
## Initial configuration
1. Run `php tkr/prerequisites.php`. This will confirm that:
1. PHP 8.2+ is installed
1. All required PHP extensions are installed
1. PDO
1. PDO::sqlite
1. All required directories exist
1. The `tkr/storage` directory exists and is writable
1. If `tkr/storage` is writable, then it will create the required subdirectories
1. `tkr/storage/db`
1. `tkr/storage/upload`
1. The script will write a summary to stdout and will save a log at `tkr/storage/prerequisite-check.log`
1. Edit `config/init.php` to set the domain and base path correctly for your configuration.
* subdirectory installation (e.g. https://my-domain.com/tkr)
```
'base_url' => 'https://my-domain.com',
'base_path' => '/tkr/',
```
* subdomain installation (e.g. https://tkr.my-domain.com)
```
'base_url' => 'https://tkr.my-domain.com',
'base_path' => '/',
```
1. Browse to your tkr URL. You'll be presented with the setup screen to complete initial configuration.
![tkr setup page](https://subcultureofone.org/images/tkr/tkr-setup.png)
### Server configuration notes ### Server configuration notes
The document root should be `/PATH/TO/tkr/public`. This will ensure that only the files that need to be accessible from the internet are served by your web server. The document root should be `/PATH/TO/tkr/public`. This will ensure that only the files that need to be accessible from the internet are served by your web server.
@ -90,7 +108,8 @@ 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.
@ -98,62 +117,44 @@ 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) 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. 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.
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
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. 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.
There are 3 subdirectories: 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.
* `tkr/storage/db`: 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.
* 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
### SQLite Database Note For illustration, here's a sample from the file `/tkr/storage/ticks/2025/05/25` on my test system.
```sh
# cat /tkr/ticks/2025/05/25.txt
23:27:37|some stuff
23:27:45|some more, stuff
```
### SQLite Database
tkr stores profile information, custom emojis, and uploaded css metadata in a SQLite database located at `tkr/storage/db/tkr.sqlite`.
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.
## Backup and restore ## FAQ
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. ### Why don't I see the right IPs in the logs?
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). This can happen for a few reasons. Some common ones are:
## Screenshots **Docker Development:** If running via Docker, you may see `192.168.65.1` (Docker Desktop gateway). This is normal for development.
### Mobile **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.
<img src="https://subcultureofone.org/images/tkr/tkr-logged-out-mobile-1.0.png" - **For accurate IP logging:** Configure your web server to trust proxy headers. See your proxy provider's documentation for the required nginx/Apache configuration.
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
@ -161,4 +162,30 @@ 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,23 +5,15 @@ 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');
// Storage subdirectories
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css');
define('DATA_DIR', STORAGE_DIR . '/db');
define('TEMPLATES_DIR', APP_ROOT . '/templates'); define('TEMPLATES_DIR', APP_ROOT . '/templates');
// Database file define('DATA_DIR', STORAGE_DIR . '/db');
define('DB_FILE', DATA_DIR . '/tkr.sqlite'); define('DB_FILE', DATA_DIR . '/tkr.sqlite');
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css');
// 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

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

View File

@ -1,22 +0,0 @@
# 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

@ -1,22 +0,0 @@
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,22 +1,49 @@
# Basic .htaccess for tkr on shared hosting # Example Apache VirtualHost
# For use with included docker-compose.yml # 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 # Enable mod_rewrite
RewriteEngine On RewriteEngine On
# Set directory index # 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 DirectoryIndex public/index.php
# Block access to sensitive directories # Security: Block direct access to .php files (except through rewrites)
RewriteRule ^(storage|src|templates|config)(/.*)?$ - [F,L] RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Block access to hidden files # Security: Block access to sensitive directories
RewriteRule ^(storage|src|templates|examples|config)(/.*)?$ - [F,L]
# Security: Block access to hidden files
RewriteRule ^\..*$ - [F,L] RewriteRule ^\..*$ - [F,L]
# Block access to setup script # Cache CSS files for 1 hour
RewriteRule ^tkr-setup\.php$ - [F,L] <FilesMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
# Route everything else through public/index.php # Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ public/css/tkr.css [L]
# 404 all other static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
RewriteRule \.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$ - [R=404,L]
# Everything else goes to the front controller
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,7 +10,6 @@ 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,7 +10,6 @@ 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,21 +1,19 @@
# Basic Apache VirtualHost for tkr # Example Apache VirtualHost
# For use with included docker-compose.yml # for serving tkr as a subdomain root without SSL
# 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
# Main directory - route everything through index.php # Security headers
<Directory "/var/www/tkr/public"> Header always set X-Frame-Options "SAMEORIGIN"
AllowOverride None Header always set X-XSS-Protection "1; mode=block"
Require all granted Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Block access to sensitive directories # Block access to sensitive directories
<Directory "/var/www/tkr/storage"> <Directory "/var/www/tkr/storage">
@ -24,13 +22,59 @@
<Directory "/var/www/tkr/src"> <Directory "/var/www/tkr/src">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates"> <Directory "/var/www/tkr/templates">
Require all denied Require all denied
</Directory> </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/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ css/tkr.css [L]
# Everything else to front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log 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,7 +10,6 @@ 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,38 +1,72 @@
# Basic Apache config for tkr in subfolder # Example Apache VirtualHost
# e.g. https://your-domain.com/tkr # for serving tkr as a subdirectory path without SSL
# For use with included docker-compose.yml # e.g. http://www.my-domain.com/tkr
#
# Alias for tkr subfolder # 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/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
<Directory "/var/www/tkr/public"> # Block access to sensitive TKR directories
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/config">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates"> <Directory "/var/www/tkr/templates">
Require all denied Require all denied
</Directory> </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 # Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log ErrorLog ${APACHE_LOG_DIR}/my-domain_error.log
CustomLog ${APACHE_LOG_DIR}/tkr_access.log combined CustomLog ${APACHE_LOG_DIR}/my-domain_access.log combined
</VirtualHost> </VirtualHost>

View File

@ -20,7 +20,6 @@ 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,45 +1,100 @@
# Basic nginx config for tkr # Example nginx config
# Replace "your-domain.com" with your actual domain # for serving tkr as a subdomain without SSL
# Replace "/var/www/tkr" with your installation path # 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)
server { server {
listen 80 default_server; listen 80;
server_name localhost; server_name localhost;
root /var/www/tkr/public; root /var/www/tkr/public;
index index.php; index index.php;
# Block access to sensitive directories # Security headers
location ~ ^/(storage|src|templates|config) { # The first rule is to prevent including in a frame on a different domain.
deny all; # Remove it if you want to do that.
return 404; add_header X-Frame-Options "SAMEORIGIN" always;
} add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
# Block access to hidden files # Deny access to hidden files
location ~ /\. { location ~ /\. {
deny all; deny all;
access_log off;
log_not_found off;
} }
# Handle PHP files # PHP routing - everything goes through index.php
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php;
include fastcgi_params;
}
# Front controller pattern - route everything else through index.php
location / { location / {
try_files $uri $uri/ @tkr; # 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;
} }
# Handle PHP requests # Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr { 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;
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,7 +20,6 @@ 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,47 +1,101 @@
# Basic nginx config for tkr in subfolder # Example nginx config
# e.g. https://your-domain.com/tkr # for serving tkr as a subdfolder without SSL
# For use with included docker-compose.yml # e.g. http://my-domain.com/tkr
#
# NOTE: Do not use in production.
# This is provided for docker compose
# (The included docker-compose file will mount it in the container image)
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;
# Block access to sensitive directories # Cache static files
location ~ ^/tkr/(storage|src|templates|config) { # Note that I don't actually serve most of this (just css)
deny all; # but this prevents requests for static content from getting to the PHP handler.
return 404; #
# 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;
} }
# Block access to hidden files # Other static assets - 1 year cache
location ~ /\. { location ~* ^/tkr/.+\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
deny all; expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
} }
# Handle PHP files # index.php is the entry point
location ~ \.php$ { # 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_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_URI $request_uri;
fastcgi_param QUERY_STRING $query_string;
} }
# Front controller pattern # Block attempts to access all other .php files directly
# Send everything else to index.php # (these are bots and scanners)
try_files $uri $uri/ @tkr; 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;
} }
location @tkr { # Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
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,23 +1,49 @@
# Basic .htaccess for tkr on shared hosting # Example Apache VirtualHost
# Place this file inside the tkr directory # for serving tkr as a subdirectory path
# e.g. if tkr is at /public_html/tkr/, this goes in /public_html/tkr/ # 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
# Set directory index # 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 DirectoryIndex public/index.php
# Block access to sensitive directories # Security: Block direct access to .php files (except through rewrites)
RewriteRule ^(storage|src|templates|config)(/.*)?$ - [F,L] RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Block access to hidden files # Security: Block access to sensitive directories
RewriteRule ^(storage|src|templates|examples|config)(/.*)?$ - [F,L]
# Security: Block access to hidden files
RewriteRule ^\..*$ - [F,L] RewriteRule ^\..*$ - [F,L]
# Block access to setup script # Cache CSS files for 1 hour
RewriteRule ^tkr-setup\.php$ - [F,L] <FilesMatch "\.css$">
Header set Cache-Control "public, max-age=3600"
</FilesMatch>
# Route everything else through the front controller # Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ public/css/tkr.css [L]
# 404 all other static files (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
RewriteRule \.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$ - [R=404,L]
# Everything else goes to the front controller
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,34 +1,39 @@
# Basic Apache VirtualHost for tkr # Example Apache VirtualHost
# Replace "your-domain.com" with your actual domain # for serving tkr as a subdomain root with SSL
# Replace "/var/www/tkr" with your installation path # e.g. https://tkr.my-domain.com/
#
# HTTP - redirect to HTTPS # Use SSL in production.
# This is a minimal SSL confiuration
# For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
<VirtualHost *:80> <VirtualHost *:80>
ServerName your-domain.com # CONFIG: Replace localhost with your subdomain, e.g. tkr.my-domain.com
Redirect permanent / https://your-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>
# HTTPS
<VirtualHost *:443> <VirtualHost *:443>
ServerName your-domain.com # 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 DocumentRoot /var/www/tkr/public
# SSL Configuration (using Let's Encrypt) # SSL Configuration
SSLEngine on SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem # Assumes you're using letsencrypt for cert generation
# Replace with the actual paths to your cert and key
# Main directory - route everything through index.php SSLCertificateFile /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem
<Directory "/var/www/tkr/public"> SSLCertificateKeyFile /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem
AllowOverride None
Require all granted # Security headers
Header always set X-Frame-Options "SAMEORIGIN"
RewriteEngine On Header always set X-XSS-Protection "1; mode=block"
RewriteCond %{REQUEST_FILENAME} !-f Header always set X-Content-Type-Options "nosniff"
RewriteCond %{REQUEST_FILENAME} !-d Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
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
@ -36,13 +41,59 @@
<Directory "/var/www/tkr/src"> <Directory "/var/www/tkr/src">
Require all denied Require all denied
</Directory> </Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates"> <Directory "/var/www/tkr/templates">
Require all denied Require all denied
</Directory> </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/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/css/custom/
RewriteRule ^css/tkr\.css$ css/tkr.css [L]
# Everything else to front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/tkr_error.log 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,31 +1,91 @@
# Basic Apache config for tkr in subfolder # Example Apache VirtualHost
# e.g. https://your-domain.com/tkr # for serving tkr as a subdirectory path with SSL
# Add this to your existing VirtualHost configuration # 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>
# Alias for tkr subfolder <VirtualHost *:443>
Alias /tkr /var/www/tkr/public # 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/
<Directory "/var/www/tkr/public"> # SSL Configuration
AllowOverride None SSLEngine on
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 # Assumes you're using letsencrypt for cert generation
<Directory "/var/www/tkr/storage"> # Replace with the actual paths to your cert and key
Require all denied SSLCertificateFile /etc/letsencrypt/live/my-domain.com/fullchain.pem
</Directory> SSLCertificateKeyFile /etc/letsencrypt/live/my-domain.com/privkey.pem
<Directory "/var/www/tkr/src">
Require all denied # Security headers
</Directory> Header always set X-Frame-Options "SAMEORIGIN"
<Directory "/var/www/tkr/config"> Header always set X-XSS-Protection "1; mode=block"
Require all denied Header always set X-Content-Type-Options "nosniff"
</Directory> Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
<Directory "/var/www/tkr/templates">
Require all denied # tkr Application at /tkr
</Directory> # NOTE: If you change the directory name,
# remember to update all instances of /var/www/tkr in this file to match
Alias /tkr /var/www/tkr/public
# Block access to sensitive TKR directories
<Directory "/var/www/tkr/storage">
Require all denied
</Directory>
<Directory "/var/www/tkr/src">
Require all denied
</Directory>
<Directory "/var/www/tkr/templates">
Require all denied
</Directory>
<Directory "/var/www/tkr/config">
Require all denied
</Directory>
# 404 all non-css static files in /tkr (images, js, fonts, etc.)
# so those requests don't hit the PHP app
# (this is to reduce load on the PHP app from bots and scanners)
<LocationMatch "^/tkr/.*\.(js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|mp3|mp4|avi|mov)$">
<RequireAll>
Require all denied
</RequireAll>
</LocationMatch>
# tkr application directory
<Directory "/var/www/tkr/public">
Options -Indexes
AllowOverride None
Require all granted
RewriteEngine On
# Block direct PHP access
RewriteCond %{THE_REQUEST} \s/[^?\s]*\.php[\s?] [NC]
RewriteRule ^.*$ - [R=404,L]
# Serve the one static file that exists: css/tkr.css
# (Pass requests to css/custom/ through to the PHP app)
RewriteCond %{REQUEST_URI} !^/tkr/css/custom/
RewriteRule ^css/tkr\.css$ css/tkr.css [L]
# Send everything else to the front controller
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</Directory>
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/my-domain_error.log
CustomLog ${APACHE_LOG_DIR}/my-domain_access.log combined
</VirtualHost>

View File

@ -1,51 +1,117 @@
# Basic nginx config for tkr # Example nginx config
# Replace "your-domain.com" with your actual domain # for serving tkr as a subdomain with SSL
# Replace "/var/www/tkr" with your installation path # e.g. https://tkr.my-domain.com/
#
# HTTP - redirect to HTTPS # Use SSL in production.
server { # This is a minimal SSL confiuration
listen 80; # For more robust SSL configuration, refer to https://ssl-config.mozilla.org/
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;
server_name your-domain.com; # 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; root /var/www/tkr/public;
index index.php; index index.php;
# SSL Configuration (using Let's Encrypt) # CONFIG:
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; # Assumes you're using letsencrypt for cert generation
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; # Replace with the actual paths to your cert and key
ssl_certificate /etc/letsencrypt/live/tkr.my-domain.com/fullchain.pem;
# Block access to sensitive directories ssl_certificate_key /etc/letsencrypt/live/tkr.my-domain.com/privkey.pem;
location ~ ^/(storage|src|templates|config) {
deny all; # Security headers
return 404; # 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;
# Block access to hidden files add_header X-XSS-Protection "1; mode=block" always;
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;
} }
# Front controller pattern - route everything through index.php # PHP routing - everything goes through index.php
location / { location / {
try_files $uri $uri/ @tkr; # 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;
} }
# Handle PHP requests # Fallback for /tkr routing - all non-file requests (e.g. /login) go to index.php
location @tkr { location @tkr_fallback {
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version as needed # 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; 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,38 +1,118 @@
# Basic nginx config for tkr in subfolder # Example nginx config
# e.g. https://your-domain.com/tkr # for serving tkr as a subdfolder with SSL
# Add this location block to your existing server configuration # 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;
location /tkr { # CONFIG: Replace localhost with your domain e.g. my-domain.com
alias /var/www/tkr/public; server_name localhost;
index index.php;
# Handle PHP files # CONFIG:
location ~ \.php$ { # Assumes you're using letsencrypt for cert generation
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust PHP version/socket as needed # Replace with the actual paths to your cert and key
fastcgi_param SCRIPT_FILENAME /var/www/tkr/public/index.php; ssl_certificate /etc/letsencrypt/live/my-domain.com/fullchain.pem;
include fastcgi_params; 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;
} }
# Block access to sensitive directories # PHP routing - everything under /tkr goes through index.php
location ~ ^/tkr/(storage|src|templates|config) { 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; deny all;
return 404; return 404;
} }
# Block access to hidden files
location ~ /\. {
deny all;
}
# Front controller pattern
try_files $uri $uri/ @tkr;
} }
location @tkr { 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; listen [::]:80 default_server;
include fastcgi_params;
fastcgi_param REQUEST_METHOD $request_method; return 301 https://$host$request_uri;
fastcgi_param REQUEST_URI $request_uri; }
fastcgi_param QUERY_STRING $query_string;
}

View File

@ -1,49 +0,0 @@
# 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

@ -1,91 +0,0 @@
# 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

@ -1,99 +0,0 @@
# 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

@ -1,117 +0,0 @@
# 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

@ -1,118 +0,0 @@
# 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,9 +12,6 @@
--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;
@ -327,12 +324,6 @@ 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;
@ -379,11 +370,6 @@ 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,15 +42,8 @@ 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)
// Skip the setup check for the default css if (!(preg_match('/tkr-setup$/', $path))) {
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();
@ -73,10 +66,6 @@ if (!(preg_match('/tkr-setup$/', $path) || preg_match('/default.css$/', $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'];
} }
/* /*
@ -97,11 +86,8 @@ Session::start();
Session::generateCsrfToken(); Session::generateCsrfToken();
// Remove the base path from the URL // Remove the base path from the URL
// If basePath isn't already set (i.e. we're not autodetecting it en route to tkr-setup), if (strpos($path, $app['settings']->basePath) === 0) {
// set it to the value from settings $path = substr($path, strlen($app['settings']->basePath));
$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
@ -114,11 +100,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 != 'tkr-setup') { if ($method === 'POST' && $path != '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::warning('Attempt to POST with invalid session. Redirecting to login.'); Log::info('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['REQUEST_URI']); header('Location: ' . $_SERVER['PHP_SELF']);
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['REQUEST_URI']); header('Location: ' . $_SERVER['PHP_SELF']);
exit; exit;
} }
@ -91,7 +91,6 @@ 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'] ?? '';
@ -153,7 +152,6 @@ 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['REQUEST_URI']); header('Location: ' . $_SERVER['PHP_SELF']);
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['REQUEST_URI']); header('Location: ' . $_SERVER['PHP_SELF']);
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['REQUEST_URI']); header('Location: ' . $_SERVER['PHP_SELF']);
exit; exit;
} }
} }

View File

@ -20,63 +20,32 @@ 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);
$msg = "Custom css file not in database: {$filename}"; exit("CSS file not found: $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);
$msg = "Custom css file not found or not readable: {$filePath}"; exit("CSS file not found: $filePath");
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Make sure the file has a .css extension // This shouldn't be possible, but I'm being extra paranoid
// 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);
$msg = "Invalid file type requested: {$ext}"; exit("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;
} }
@ -95,7 +64,7 @@ class CssController extends Controller {
} }
// redirect after handling to avoid resubmitting form // redirect after handling to avoid resubmitting form
header('Location: ' . $_SERVER['REQUEST_URI']); header('Location: ' . $_SERVER['PHP_SELF']);
exit; exit;
} }
@ -105,24 +74,18 @@ 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);
$msg = "Cannot delete default theme."; exit("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 = (int) $_POST['selectCssFile']; $cssId = $_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);
$msg = "No entry found for css id {$cssId}."; exit("No entry found for css id $cssId");
Log::warning($msg);
Session::setFlashMessage('warning', $msg);
return;
} }
// get the filename // get the filename
@ -130,11 +93,8 @@ 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(500); http_response_code(400);
$msg = "Error deleting theme {$cssId}."; exit("Error deleting theme");
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Build the full path to the file // Build the full path to the file
@ -143,42 +103,33 @@ 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);
$msg = "CSS file not found: {$filePath}"; exit("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(500); http_response_code(400);
$msg = "Error deleting file: {$filePath}"; exit("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();
$msg = "Theme {$cssFilename} deleted."; Session::setFlashMessage('success', 'Theme ' . $cssFilename . ' deleted.');
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) { } catch (Exception $e) {
$msg = "Failed to update config after deleting theme."; Log::error("Failed to update config after deleting theme: " . $e->getMessage());
Log::error($msg . ' ' . $e->getMessage()); Session::setFlashMessage('error', 'Theme deleted but failed to update settings');
Session::setFlashMessage('error', $msg);
} }
} }
private function handleSetTheme(): void { private function handleSetTheme() {
global $app; global $app;
try { try {
if ($_POST['selectCssFile']){ if ($_POST['selectCssFile']){
// Set custom theme // Set custom theme
$app['settings']->cssId = (int)($_POST['selectCssFile']); $app['settings']->cssId = $_POST['selectCssFile'];
} else { } else {
// Set default theme // Set default theme
$app['settings']->cssId = null; $app['settings']->cssId = null;
@ -193,7 +144,7 @@ class CssController extends Controller {
} }
} }
private function handleUpload(): void { private function handleUpload() {
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) {
@ -248,11 +199,12 @@ 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): void { private function validateCssContent($content) {
// Remove comments // Remove comments
$content = preg_replace('/\/\*.*?\*\//s', '', $content); $content = preg_replace('/\/\*.*?\*\//s', '', $content);
@ -273,7 +225,7 @@ class CssController extends Controller {
} }
} }
private function scanForMaliciousContent($content, $fileName): void { private function scanForMaliciousContent($content, $fileName) {
// Check for suspicious patterns // Check for suspicious patterns
$suspiciousPatterns = [ $suspiciousPatterns = [
'/javascript:/i', '/javascript:/i',
@ -312,11 +264,12 @@ class CssController extends Controller {
} }
} }
private function generateSafeFileName($originalName): string { private function generateSafeFileName($originalName) {
// 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,10 +10,9 @@ 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 = [];
$msg = "Failed to load emoji list."; Session::setFlashMessage('error', 'Failed to load custom emoji');
Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
} }
$vars = [ $vars = [
@ -50,31 +49,23 @@ 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) {
$msg = "Emoji must be a single UTF-8 encoded character."; // TODO - handle error
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)) {
$msg = "Character is not a valid emoji."; // TODO - handle error
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) {
$msg = "Character is not a valid emoji (too few bytes)."; // TODO - handle error
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false; return false;
} }
@ -85,7 +76,7 @@ declare(strict_types=1);
global $app; global $app;
if (!$this->isValidEmoji($emoji)){ if (!$this->isValidEmoji($emoji)){
// exceptions are handled in isValidEmoji Session::setFlashMessage('error', 'Invalid emoji format');
return; return;
} }
@ -93,14 +84,10 @@ declare(strict_types=1);
try { try {
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiModel->add($emoji, $description); $emojiModel->add($emoji, $description);
$msg = "Emoji added: {$emoji} - {$description}"; Session::setFlashMessage('success', 'Emoji added successfully');
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) { } catch (Exception $e) {
$msg = "Failed to add emoji."; Log::error("Failed to add emoji: " . $e->getMessage());
Log::error($msg . " " . $e->getMessage()); Session::setFlashMessage('error', 'Failed to add emoji');
Session::setFlashMessage('error', $msg);
} }
} }
@ -113,14 +100,10 @@ declare(strict_types=1);
try { try {
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiModel->delete($ids); $emojiModel->delete($ids);
$msg = "Emoji deleted."; Session::setFlashMessage('success', 'Emoji deleted successfully');
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) { } catch (Exception $e) {
$msg = "Failed to delete emoji."; Log::error("Failed to delete emoji: " . $e->getMessage());
Log::error($msg . " " . $e->getMessage()); Session::setFlashMessage('error', 'Failed to delete emoji');
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;
$vars = $this->getHomeData($page); $data = $this->getHomeData($page);
$this->render("home.php", $vars); $this->render("home.php", $data);
} }
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 admin/settings] - message // Parse format: [2025-01-31 08:30:15] DEBUG: 192.168.1.100 [GET feed/rss] - message
$pattern = '/^\[([^\]]+)\] (\w+): ([^\s]+)(?:\s+\[([^\]]+)\])? - (.+)$/'; $pattern = '/^\[([^\]]+)\] (\w+): ([^\s]+)(?:\s+\[([^\]]+)\])? - (.+)$/';
if (preg_match($pattern, $line, $matches)) { if (preg_match($pattern, $line, $matches)) {

View File

@ -2,27 +2,22 @@
declare(strict_types=1); declare(strict_types=1);
class TickController extends Controller{ class TickController extends Controller{
public function index(string $id){ public function index(int $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']);
$tick = $tickModel->get($id); $vars = $tickModel->get($id);
if (empty($tick) || !isset($tick['tick'])) { if (empty($vars) || !isset($vars['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);
$this->render('tick-404.php', $vars); echo '<h1>404 - Tick Not Found</h1>';
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);
@ -35,32 +30,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, 'atom')); $feedUrl = Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'feed/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::escape_xml(Util::linkify(Util::escape_html($tick['tick']))); $tickContent = Util::linkify($tickTitle);
?> ?>
<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, 'rss'))?>" <atom:link href="<?php echo Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'feed/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,11 +35,10 @@ 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::escape_xml(Util::linkify(Util::escape_html($tick['tick']))); $tickDescription = Util::linkify($tickTitle);
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(): bool { private function createDatabase() {
$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 {
} }
} }
public function applyMigrations($db): bool { private function applyMigrations($db) {
try { try {
$migrator = new Migrator($db); $migrator = new Migrator($db);
$migrator->migrate(); $migrator->migrate();
@ -452,6 +452,8 @@ 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',
@ -461,8 +463,6 @@ 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,8 +511,6 @@ 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) {
@ -528,8 +526,8 @@ class Prerequisites {
$this->generateCliSummary($results); $this->generateCliSummary($results);
} }
// Return true only if no NEW errors occurred // Return true only if no errors occurred
return count($this->errors) === $currentErrors; return count($this->errors) === 0;
} }
/** /**

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'],
['rss', 'FeedController@rss'], ['feed/rss', 'FeedController@rss'],
['atom', 'FeedController@atom'], ['feed/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,7 +27,6 @@ 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,15 +7,6 @@ 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();
@ -67,6 +58,7 @@ 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,13 +125,6 @@ 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 {
@ -139,7 +132,10 @@ class Util {
} }
// Construct full URL // Construct full URL
$fullUrl = $baseUrl . $basePath; $fullUrl = $baseUrl;
if ($basePath !== '/') {
$fullUrl .= ltrim($basePath, '/');
}
return [ return [
'baseUrl' => $baseUrl, 'baseUrl' => $baseUrl,

View File

@ -12,7 +12,8 @@ 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;
public ?int $tickDeleteHours = null; // not currently configurable
public int $tickDeleteHours = 1;
public function __construct(private PDO $db) {} public function __construct(private PDO $db) {}
@ -27,8 +28,7 @@ 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,8 +41,7 @@ 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 = (int) ($row['log_level'] ?? 2); $c->logLevel = $row['log_level'];
$c->tickDeleteHours = (int) ($row['tick_delete_hours'] ?? 1);
} }
return $c; return $c;
@ -52,7 +51,6 @@ 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,
@ -62,12 +60,10 @@ 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=?,
@ -76,21 +72,10 @@ 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,
@ -98,8 +83,7 @@ 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,6 +41,7 @@ class TickModel {
return [ return [
'tickTime' => $row['timestamp'], 'tickTime' => $row['timestamp'],
'tick' => $row['tick'], 'tick' => $row['tick'],
'settings' => $this->settings,
]; ];
} }
@ -49,26 +50,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 (Session::isLoggedIn() && $tick['can_delete']): ?> <?php if ($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)?>rss/"> href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>feed/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)?>atom/"> href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>feed/atom/">
</head> </head>
<body> <body>
<?php include TEMPLATES_DIR . '/partials/navbar.php'?> <?php include TEMPLATES_DIR . '/partials/navbar.php'?>

View File

@ -1,16 +1,12 @@
<?php /** @var SettingsModel $settings */ ?> <?php /** @var SettingsModel $settings */ ?>
<?php /** @var UserModel $user */ ?> <?php /** @var UserModel $user */ ?>
<?php /** @var isSetup bool */ ?> <?php /** @var isSetup bool */ ?>
<?php <h1><?php if ($isSetup): ?>Setup<?php else: ?>Admin<?php endif; ?></h1>
$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, $urlPath) ?>" action="<?php echo Util::buildRelativeUrl($settings->basePath, ($isSetup ? 'setup' : 'admin')) ?>"
method="post"> method="post">
<input type="hidden" name="csrf_token" value="<?php echo Util::escape_html($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset> <fieldset>
<legend>User settings</legend> <legend>User settings</legend>
<div class="fieldset-items"> <div class="fieldset-items">
@ -18,19 +14,19 @@
<input type="text" <input type="text"
id="username" id="username"
name="username" name="username"
value="<?php echo Util::escape_html($user->username) ?>" value="<?= 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="<?php echo Util::escape_html($user->displayName) ?>" value="<?= 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="<?php echo Util::escape_html($user->website) ?>"> value="<?= Util::escape_html($user->website) ?>">
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
@ -40,48 +36,43 @@
<input type="text" <input type="text"
id="site_title" id="site_title"
name="site_title" name="site_title"
value="<?php echo Util::escape_html($settings->siteTitle) ?>" value="<?= 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="<?php echo Util::escape_html($settings->siteDescription) ?>"> value="<?= 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="<?php echo Util::escape_html($settings->baseUrl) ?>" value="<?= 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="<?php echo Util::escape_html($settings->basePath) ?>" value="<?= 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="<?php echo $settings->itemsPerPage ?>" min="1" max="50" value="<?= $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="log_level">Log Level</label> <label for="strict_accessibility">Log Level</label>
<select id="log_level" name="log_level"> <select id="log_level" name="log_level">
<option value="1" <?php echo ($settings->logLevel ?? 2) == 1 ? 'selected' : '' ?>>DEBUG</option> <option value="1" <?= ($settings->logLevel ?? 2) == 1 ? 'selected' : '' ?>>DEBUG</option>
<option value="2" <?php echo ($settings->logLevel ?? 2) == 2 ? 'selected' : '' ?>>INFO</option> <option value="2" <?= ($settings->logLevel ?? 2) == 2 ? 'selected' : '' ?>>INFO</option>
<option value="3" <?php echo ($settings->logLevel ?? 2) == 3 ? 'selected' : '' ?>>WARNING</option> <option value="3" <?= ($settings->logLevel ?? 2) == 3 ? 'selected' : '' ?>>WARNING</option>
<option value="4" <?php echo ($settings->logLevel ?? 2) == 4 ? 'selected' : '' ?>>ERROR</option> <option value="4" <?= ($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, 'rss')) ?>">rss</a> href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'feed/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, 'atom')) ?>">atom</a> href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'feed/atom')) ?>">atom</a>
</div> </div>
</details> </details>
<?php if (!Session::isLoggedIn()): ?> <?php if (!Session::isLoggedIn()): ?>

View File

@ -1,4 +0,0 @@
<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,14 +1,4 @@
<?php /** @var Date $tickTime */ ?> <?php /** @var Date $tickTime */ ?>
<?php /** @var string $tick */ ?> <?php /** @var string $tick */ ?>
<?php $displayTime = DateTimeImmutable::createFromformat('Y-m-d H:i:s', $tickTime) ?> <h1>Tick from <?= $tickTime; ?></h1>
<div class="tick-container"> <p><?= Util::linkify(Util::escape_html($tick)) ?></p>
<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,7 +22,6 @@ 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';
@ -120,8 +119,7 @@ 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',
@ -155,8 +153,7 @@ 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);
@ -180,8 +177,7 @@ 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('Tick Not Found', $output); $this->assertStringContainsString('404 - 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('Tick Not Found', $output); $this->assertStringContainsString('404 - 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/atom"', $xml); $this->assertStringContainsString('href="https://example.com/tkr/feed/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/atom"', $xml); $this->assertStringContainsString('href="https://example.com/tkr/feed/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/rss"', $xml); $this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/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/rss"', $xml); $this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/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,7 +177,6 @@ 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();
@ -185,7 +184,7 @@ try {
// Create admin user // Create admin user
$userModel = new UserModel($db); $userModel = new UserModel($db);
$userModel->username = $adminUsername; $userModel->username = $adminUsername;
$userModel->displayName = $adminUsername; $userModel->display_name = $adminUsername;
$userModel->website = ''; $userModel->website = '';
$userModel->mood = ''; $userModel->mood = '';
$user = $userModel->save(); $user = $userModel->save();