Initial commit

This commit is contained in:
Greg Sarjeant 2025-05-26 10:41:14 -04:00
commit c6a49dc6e8
17 changed files with 514 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.sqlite
*.txt

70
README.md Normal file
View File

@ -0,0 +1,70 @@
# tkr
A bare-bones status feed for self-hosted personal websites.
Currently very much a work in progress, but it's baically functional.
## How it works
Deploy the `/tkr` directory to a web server that supports php. It will work either as the root of a (sub)domain (e.g. tky.mydomain.com) or if served from a subdirectory (e.g. mydomain.com/tkr).
If you serve it from a subdirectory, set the value of `$basePath` in `/app/config.php` to the subdirectory name, excluding the trailing slash (e.g. `/tkr`)
It provides an rss feed at `/rss` relative to where it's being served (e.g. `/tkr/rss` if served from `/tkr/`). Each rss entry links to an individual post (which I call "ticks").
## Serving
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.
## Storage
Ticks are stored in files on the filesystem under `/tkr/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.
The file structure is `YYYY/MM/DD.txt`. That is, each day's ticks are located in a file whose full path is `/tkr/ticks/YEAR/MONTH/DAY.txt`. This is to prevent any single file from getting too large.
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.
For illustration, here's a sample from the file `/tkr/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
```
## Initial config
I'll write this up when I improve it.
## Sample images
Logged out
![tkr homepage - logged out](https://subcultureofone.org/images/tkr-logged-out.png)
Logged in
![tkr homepage - logged in](https://subcultureofone.org/images/tkr-logged-in.png)
RSS
![tkr rss feed](https://subcultureofone.org/images/tkr-rss.png)
Single tick
![tkr single post](https://subcultureofone.org/images/tkr-single.png)
## TODO
* An actual setup script
* Validate CSRF token on tick submission
* Let people set the time zone for ticks
* Support basic custom styling
* Do that config improvement I implied in the previous section
* See if I can get individual ticks to resolve as urls (e.g. /2025/05/26/00/12) rather than doing the query string thing
* Clean up the nginx configs
* Add an .htaccess example
* Maybe h-feed or JSON feed?
* Microformat support?
* A little more profile info?
* Probably a bunch of other stuff I'm not thinking of

12
docker-compose.yml Normal file
View File

@ -0,0 +1,12 @@
services:
web:
image: nginx:latest
ports:
- "80:80"
volumes:
- ./tkr-nginx-folder.conf:/etc/nginx/conf.d/default.conf
- ./tkr:/tkr
php:
image: php:fpm
volumes:
- ./tkr:/tkr

33
tkr-nginx-folder.conf Normal file
View File

@ -0,0 +1,33 @@
server {
listen 80 default_server;
root /usr/share/nginx/html;
location ^~ /tkr {
index index.php;
alias /tkr/public;
#location ~ "^/tkr/([0-9]{4}/[0-9]{2}/[0-9]{2}/[0-9]{2}/[0-9]{2}/[0-9]{2})$" {
# rewrite "^/tkr/([0-9/]{19})$ /tkr/tick.php?path=$1" break;
#}
location ~ ^/tkr(/.+\.php)$ {
fastcgi_pass php:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /tkr/public/$1;
fastcgi_param SCRIPT_NAME $uri;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
}
}
# Deny anything else
location / {
try_files $uri $uri/ =404;
}
# Deny access to hidden or stray files
location ~* \.(htaccess|env|ini|log|bak)$ {
deny all;
}
}

23
tkr-nginx.conf Normal file
View File

@ -0,0 +1,23 @@
server {
listen 80 default_server;
root /app/public;
index index.php index.html index.htm;
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
include fastcgi_params;
}
location / {
try_files $uri $uri/ =404;
}
location ~* \.(htaccess|env|ini|log|bak)$ {
deny all;
}
}

12
tkr/config.php Normal file
View File

@ -0,0 +1,12 @@
<?php
$dbLocation = __DIR__ . '/db/tkr.sqlite';
$tickLocation = __DIR__ . '/ticks';
$basePath = '/tkr';
try {
$pdo = new PDO("sqlite:$dbLocation");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}

56
tkr/create_db.php Normal file
View File

@ -0,0 +1,56 @@
<?php
// TODO: Replace this whole thing with a setup.php
function prompt($prompt) {
echo $prompt;
return trim(fgets(STDIN));
}
function promptSilent($prompt = "Enter Password: ") {
if (strncasecmp(PHP_OS, 'WIN', 3) === 0) {
// Windows doesn't support shell-based hidden input
echo "Warning: Password input not hidden on Windows.\n";
return prompt($prompt);
} else {
// Use shell to disable echo for password input
echo $prompt;
system('stty -echo');
$password = rtrim(fgets(STDIN), "\n");
system('stty echo');
echo "\n";
return $password;
}
}
$dbFile = __DIR__ . '/tkr.sqlite';
try {
$pdo = new PDO("sqlite:$dbFile");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die("Could not connect to DB: " . $e->getMessage() . "\n");
}
$pdo->exec("CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
)");
$username = prompt("Enter username: ");
$password = promptSilent("Enter password: ");
$confirm = promptSilent("Confirm password: ");
if ($password !== $confirm) {
die("Error: Passwords do not match.\n");
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt = $pdo->prepare("INSERT INTO user(username, password_hash) VALUES (?, ?)");
$stmt->execute([$username, $passwordHash]);
echo "User '$username' created successfully.\n";
} catch (PDOException $e) {
echo "Failed to create user: " . $e->getMessage() . "\n";
}

0
tkr/db/.gitkeep Normal file
View File

61
tkr/public/index.php Normal file
View File

@ -0,0 +1,61 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../'));
define('ITEMS_PER_PAGE', 25);
require APP_ROOT . '/config.php';
require APP_ROOT . '/session.php';
require_once APP_ROOT . '/stream_ticks.php';
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$limit = ITEMS_PER_PAGE;
$offset = ($page - 1) * $limit;
$ticks = iterator_to_array(stream_ticks($tickLocation, $limit, $offset));
?>
<!DOCTYPE html>
<html>
<head>
<title>My ticker</title>
<style>
body { font-family: sans-serif; margin: 2em; }
.tick { margin-bottom: 1em; }
.ticktime { color: gray; font-size: 0.9em; }
.ticktext {color: black; font-size: 1.0em; }
.pagination a { margin: 0 5px; text-decoration: none; }
</style>
</head>
<body>
<h2>Welcome! Here's what's ticking.</h2>
<?php foreach ($ticks as $tick): ?>
<div class="tick">
<spam class="ticktime"><?= $tick['timestamp'] ?></span>
<span class="ticktext"><?= $tick['tick'] ?></spam>
</div>
<?php endforeach; ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?= $page - 1 ?>">&laquo; Newer</a>
<?php endif; ?>
<?php if (count($ticks) === $limit): ?>
<a href="?page=<?= $page + 1 ?>">Older &raquo;</a>
<?php endif; ?>
</div>
<?php if (!$isLoggedIn): ?>
<p><a href="<?= $basePath ?>/login.php">Login</a></p>
<?php else: ?>
<form action="save_tick.php" method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<label for="tick">What's ticking?</label>
<input name="tick" id="tick" type="text">
<button type="submit">Tick</button>
</form>
<p><a href="<?= $basePath ?>/logout.php">Logout</a> <?= htmlspecialchars($_SESSION['username']) ?> </p>
<?php endif; ?>
</body>
</html>

50
tkr/public/login.php Normal file
View File

@ -0,0 +1,50 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../'));
require APP_ROOT . '/config.php';
require APP_ROOT . '/session.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!validateCsrfToken($_POST['csrf_token'])) {
die('Invalid CSRF token');
}
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$stmt = $pdo->prepare("SELECT id, username, password_hash FROM user WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
header('Location: ' . $basePath . '/');
exit;
} else {
$error = 'Invalid username or password';
}
}
$csrf_token = generateCsrfToken();
?>
<!DOCTYPE html>
<html>
<head><title>Login</title></head>
<body>
<h2>Login</h2>
<?php if ($error): ?>
<p style="color:red"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post" action="<?= $basePath ?>/login.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<label>Username: <input type="text" name="username" required></label><br>
<label>Password: <input type="password" name="password" required></label><br>
<button type="submit">Login</button>
</form>
</body>
</html>

11
tkr/public/logout.php Normal file
View File

@ -0,0 +1,11 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../'));
require APP_ROOT . '/config.php';
require APP_ROOT . '/session.php';
$_SESSION = [];
session_destroy();
header('Location: ' . $basePath . '/');
exit;

43
tkr/public/rss/index.php Normal file
View File

@ -0,0 +1,43 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../../'));
define('ITEMS_PER_PAGE', 25);
require APP_ROOT . '/config.php';
require_once APP_ROOT . '/stream_ticks.php';
header('Content-Type: application/rss+xml; charset=utf-8');
$ticks = iterator_to_array(stream_ticks($tickLocation, ITEMS_PER_PAGE));
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<rss version="2.0">
<channel>
<title>My tkr</title>
<link rel="alternate" type="application/rss+xml" title="Tick RSS" href="/tkr/rss/">
<description>My tkr</description>
<language>en-us</language>
<lastBuildDate><?php echo date(DATE_RSS); ?></lastBuildDate>
<?php foreach ($ticks as $tick):
[$date, $time] = explode(' ', $tick['timestamp']);
$dateParts = explode('-', $date);
$timeParts = explode(':', $time);
[$year, $month, $day] = $dateParts;
[$hour, $minute, $second] = $timeParts;
$tickPath = "$year/$month/$day/$hour/$minute/$second";
?>
<item>
<title><?php echo htmlspecialchars(date(DATE_RFC7231, strtotime($tick['timestamp']))); ?></title>
<link><?php echo htmlspecialchars("$basePath/tick.php?path=$tickPath"); ?></link>
<description><?php echo htmlspecialchars($tick['tick']); ?></description>
<pubDate><?php echo date(DATE_RSS, strtotime($tick['timestamp'])); ?></pubDate>
<guid><?php echo htmlspecialchars($tickPath); ?></guid>
</item>
<?php endforeach; ?>
</channel>
</rss>

44
tkr/public/save_tick.php Normal file
View File

@ -0,0 +1,44 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../'));
require APP_ROOT . '/config.php';
require APP_ROOT . '/session.php';
// ticks must be sent via POST
if ($_SERVER['REQUEST_METHOD'] === 'POST' and isset($_POST['tick'])) {
// csrf check
if (!validateCsrfToken($_POST['csrf_token'])) {
die('Invalid CSRF token');
}
$tick = htmlspecialchars($_POST['tick'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
} else {
// just go back to the index
header('Location: index.php');
exit;
}
# write the tick to a new entry
$date = new DateTime();
$year = $date->format('Y');
$month = $date->format('m');
$day = $date->format('d');
$time = $date->format('H:i:s');
// build the full path to the tick file
$dir = "$tickLocation/$year/$month";
$filename = "$dir/$day.txt";
// create the directory if it doesn't exist
if (!is_dir($dir)) {
mkdir($dir, 0770, true);
}
// write the tick to the file (the file will be created if it doesn't exist)
$content = $time . "|" . $tick . "\n";
file_put_contents($filename, $content, FILE_APPEND);
// go back to the index and show the latest tick
header('Location: index.php');
exit;

33
tkr/public/tick.php Normal file
View File

@ -0,0 +1,33 @@
<?php
require '/app/config.php';
$path = $_GET['path'] ?? '';
$parts = explode('/', $path);
if (count($parts) !== 6) {
http_response_code(400);
echo "Invalid tick path.";
exit;
}
[$y, $m, $d, $H, $i, $s] = $parts;
$timestamp = "$H:$i:$s";
$file = "$tickLocation/$y/$m/$d.txt";
if (!file_exists($file)) {
http_response_code(404);
echo "Tick not found.";
exit;
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with($line, $timestamp)) {
echo "<h1>Tick from $timestamp on $y-$m-$d</h1>";
echo "<p>" . htmlspecialchars(explode('|', $line)[1]) . "</p>";
exit;
}
}
http_response_code(404);
echo "Tick not found.";

17
tkr/session.php Normal file
View File

@ -0,0 +1,17 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$isLoggedIn = isset($_SESSION['username']);
function generateCsrfToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function validateCsrfToken($token) {
return hash_equals($_SESSION['csrf_token'], $token);
}

47
tkr/stream_ticks.php Normal file
View File

@ -0,0 +1,47 @@
<?php
// display the requested block of ticks
// without storing all ticks in an array
function stream_ticks(string $tickLocation, int $limit, int $offset = 0): Generator {
$tick_files = glob($tickLocation . '/*/*/*.txt');
usort($tick_files, fn($a, $b) => strcmp($b, $a)); // sort filenames in reverse chronological order
$count = 0;
foreach ($tick_files as $file) {
// read all the ticks from the current file and reverse the order
// so the most recent ones are first
//
// each file is a single day, so we never hold more than
// one day's ticks in memory
$lines = array_reverse(
file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)
);
$pathParts = explode('/', str_replace('\\', '/', $file));
$date = $pathParts[count($pathParts) - 3] . '-' .
$pathParts[count($pathParts) - 2] . '-' .
pathinfo($pathParts[count($pathParts) - 1], PATHINFO_FILENAME);
foreach ($lines as $line) {
// just keep skipping ticks until we get to the starting point
if ($offset > 0) {
$offset--;
continue;
}
// Ticks are pipe-delimited: timestamp|text
// But just in case a tick contains a pipe, only split on the first one that occurs
$tickParts = explode('|', $line, 2);
$time = $tickParts[0];
$tick = $tickParts[1];
yield [
'timestamp' => $date . ' ' . $time,
'tick' => $tick,
];
if (++$count >= $limit) {
return;
}
}
}
}

0
tkr/ticks/.gitkeep Normal file
View File