Initial commit
This commit is contained in:
commit
c6a49dc6e8
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*.sqlite
|
||||
*.txt
|
70
README.md
Normal file
70
README.md
Normal 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
|
||||
|
||||

|
||||
|
||||
Logged in
|
||||
|
||||

|
||||
|
||||
RSS
|
||||
|
||||

|
||||
|
||||
Single tick
|
||||
|
||||

|
||||
|
||||
## 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
12
docker-compose.yml
Normal 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
33
tkr-nginx-folder.conf
Normal 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
23
tkr-nginx.conf
Normal 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
12
tkr/config.php
Normal 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
56
tkr/create_db.php
Normal 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
0
tkr/db/.gitkeep
Normal file
61
tkr/public/index.php
Normal file
61
tkr/public/index.php
Normal 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 ?>">« Newer</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (count($ticks) === $limit): ?>
|
||||
<a href="?page=<?= $page + 1 ?>">Older »</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
50
tkr/public/login.php
Normal 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
11
tkr/public/logout.php
Normal 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
43
tkr/public/rss/index.php
Normal 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
44
tkr/public/save_tick.php
Normal 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
33
tkr/public/tick.php
Normal 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
17
tkr/session.php
Normal 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
47
tkr/stream_ticks.php
Normal 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
0
tkr/ticks/.gitkeep
Normal file
Loading…
x
Reference in New Issue
Block a user