Add setup.php. Add lib dir. Move functions to lib dir.

This commit is contained in:
Greg Sarjeant 2025-05-27 19:58:49 -04:00
parent c6a49dc6e8
commit 5ad15a478d
13 changed files with 277 additions and 134 deletions

83
tkr/bootstrap.php Normal file
View File

@ -0,0 +1,83 @@
<?php
define('APP_ROOT', dirname(__FILE__));
define('LIB_ROOT', APP_ROOT . '/lib');
define('TICKS_DIR', APP_ROOT . '/storage/ticks');
define('DATA_DIR', APP_ROOT . '/storage/db');
define('DB_FILE', DATA_DIR . '/tkr.sqlite');
function verify_data_dir(string $dir, bool $allow_create = false): void {
if (!is_dir($dir)) {
if ($allow_create) {
if (!mkdir($dir, 0770, true)) {
http_response_code(500);
echo "Failed to create required directory: $dir";
exit;
}
} else {
http_response_code(500);
echo "Required directory does not exist: $dir";
exit;
}
}
if (!is_writable($dir)) {
http_response_code(500);
echo "Directory is not writable: $dir";
exit;
}
}
// Verify that setup is complete (i.e. the databse is populated).
// Redirect to setup.php if it isn't.
function confirm_setup(): void {
$db = get_db();
// Ensure required tables exist
$db->exec("CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL
)");
$db->exec("CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY,
site_title TEXT NOT NULL,
site_description TEXT NULL,
base_path TEXT NOT NULL,
items_per_page INTEGER NOT NULL
)");
// See if there's any data in the tables
$user_count = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn();
$settings_count = (int) $db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
// If either table has no records and we aren't on setup.php, redirect to setup.php
if ($user_count === 0 || $settings_count === 0){
if (basename($_SERVER['PHP_SELF']) !== 'setup.php'){
header('Location: setup.php');
exit;
}
} else {
// If setup is complete and we are on setup.php, redirect to index.php.
if (basename($_SERVER['PHP_SELF']) === 'setup.php'){
header('Location: index.php');
exit;
}
};
}
function get_db(): PDO {
verify_data_dir(DATA_DIR, true);
try {
$db = new PDO("sqlite:" . DB_FILE);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
return $db;
}

View File

@ -1,12 +0,0 @@
<?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());
}

View File

@ -1,56 +0,0 @@
<?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";
}

25
tkr/lib/config.php Normal file
View File

@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
confirm_setup();
class Config {
public string $siteTitle = 'My tkr';
public string $siteDescription = '';
public string $basePath = '/';
public int $itemsPerPage = 25;
public static function load(): self {
$db = get_db();
$stmt = $db->query("SELECT site_title, site_description, base_path, items_per_page FROM settings WHERE id=1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$c = new self();
if ($row) {
$c->siteTitle = $row['site_title'];
$c->siteDescription = $row['site_description'];
$c->basePath = $row['base_path'];
$c->itemsPerPage = (int) $row['items_per_page'];
}
return $c;
}
}

View File

@ -1,8 +1,45 @@
<?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');
require_once __DIR__ . '/../bootstrap.php';
function escape_and_linkify_tick(string $tick): string {
// escape dangerous characters, but preserve quotes
$safe = htmlspecialchars($tick, ENT_NOQUOTES | ENT_HTML5, 'UTF-8');
// convert URLs to links
$safe = preg_replace_callback(
'~(https?://[^\s<>"\'()]+)~i',
fn($matches) => '<a href="' . htmlspecialchars($matches[1], ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $matches[1] . '</a>',
$safe
);
return $safe;
}
function save_tick(string $tick): void {
// build the tick path and filename from the current time
$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 = TICKS_DIR . "/$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);
}
function stream_ticks(int $limit, int $offset = 0): Generator {
$tick_files = glob(TICKS_DIR . '/*/*/*.txt');
usort($tick_files, fn($a, $b) => strcmp($b, $a)); // sort filenames in reverse chronological order
$count = 0;

View File

@ -1,21 +1,24 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../'));
define('ITEMS_PER_PAGE', 25);
require_once __DIR__ . '/../bootstrap.php';
require APP_ROOT . '/config.php';
require APP_ROOT . '/session.php';
require_once APP_ROOT . '/stream_ticks.php';
confirm_setup();
require LIB_ROOT . '/config.php';
require LIB_ROOT . '/session.php';
require LIB_ROOT . '/ticks.php';
$config = Config::load();
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$limit = ITEMS_PER_PAGE;
$limit = $config->itemsPerPage;
$offset = ($page - 1) * $limit;
$ticks = iterator_to_array(stream_ticks($tickLocation, $limit, $offset));
$ticks = iterator_to_array(stream_ticks($limit, $offset));
?>
<!DOCTYPE html>
<html>
<head>
<title>My ticker</title>
<title><?= $config->siteTitle ?></title>
<style>
body { font-family: sans-serif; margin: 2em; }
.tick { margin-bottom: 1em; }
@ -25,37 +28,38 @@ $ticks = iterator_to_array(stream_ticks($tickLocation, $limit, $offset));
</style>
</head>
<body>
<h2>Welcome! Here's what's ticking.</h2>
<h2><?= $config->siteDescription ?></h2>
<?php foreach ($ticks as $tick): ?>
<div class="tick">
<spam class="ticktime"><?= $tick['timestamp'] ?></span>
<span class="ticktext"><?= $tick['tick'] ?></spam>
<span class="ticktime"><?= htmlspecialchars($tick['timestamp']) ?></span>
<span class="ticktext"><?= escape_and_linkify_tick($tick['tick']) ?></span>
</div>
<?php endforeach; ?>
<div class="pagination">
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?= $page - 1 ?>">&laquo; Newer</a>
<a href="?page=<?= $page - 1 ?>">&laquo; Newer</a>
<?php endif; ?>
<?php if (count($ticks) === $limit): ?>
<a href="?page=<?= $page + 1 ?>">Older &raquo;</a>
<a href="?page=<?= $page + 1 ?>">Older &raquo;</a>
<?php endif; ?>
</div>
</div>
<div>
<?php if (!$isLoggedIn): ?>
<p><a href="<?= $basePath ?>/login.php">Login</a></p>
<p><a href="<?= $config->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>
<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="<?= $config->basePath ?>logout.php">Logout</a> <?= htmlspecialchars($_SESSION['username']) ?> </p>
<?php endif; ?>
</div>
</body>
</html>

View File

@ -1,8 +1,10 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../'));
require_once __DIR__ . '/../bootstrap.php';
require APP_ROOT . '/config.php';
require APP_ROOT . '/session.php';
require LIB_ROOT . '/config.php';
require LIB_ROOT . '/session.php';
$config = Config::load();
$error = '';
@ -14,7 +16,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$stmt = $pdo->prepare("SELECT id, username, password_hash FROM user WHERE username = ?");
$db = get_db();
$stmt = $db->prepare("SELECT id, username, password_hash FROM user WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
@ -22,7 +25,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
header('Location: ' . $basePath . '/');
header('Location: ' . $config->basePath);
exit;
} else {
$error = 'Invalid username or password';
@ -40,7 +43,7 @@ $csrf_token = generateCsrfToken();
<?php if ($error): ?>
<p style="color:red"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post" action="<?= $basePath ?>/login.php">
<form method="post" action="<?= $config->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>

View File

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

View File

@ -1,44 +1,30 @@
<?php
define('APP_ROOT', realpath(__DIR__ . '/../'));
require_once __DIR__ . '/../bootstrap.php';
require APP_ROOT . '/config.php';
require APP_ROOT . '/session.php';
require LIB_ROOT . '/config.php';
require LIB_ROOT . '/session.php';
require LIB_ROOT . '/ticks.php';
confirm_setup();
// ticks must be sent via POST
if ($_SERVER['REQUEST_METHOD'] === 'POST' and isset($_POST['tick'])) {
// csrf check
// ensure that the session is valid before proceeding
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
// just go back to the index if it's not a POST
header('Location: index.php');
exit;
}
# write the tick to a new entry
$date = new DateTime();
// get the config
$config = Config::load();
$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);
// save the tick
save_tick($_POST['tick']);
// go back to the index and show the latest tick
header('Location: index.php');
header('Location: ' . $config->basePath);
exit;

72
tkr/public/setup.php Normal file
View File

@ -0,0 +1,72 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
confirm_setup();
// If we got past confirm_setup(), then setup isn't complete.
$db = get_db();
// Handle submitted form
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$display_name = trim($_POST['display_name'] ?? '');
$password = $_POST['password'] ?? '';
$site_title = trim($_POST['site_title']) ?? '';
$site_description = trim($_POST['site_description']) ?? '';
$base_path = trim($_POST['base_path'] ?? '/');
$items_per_page = (int) ($_POST['items_per_page'] ?? 25);
// Sanitize base path
if (substr($base_path, -1) !== '/') {
$base_path .= '/';
}
// Validate
$errors = [];
if (!$username || !$password) {
$errors[] = "Username and password are required.";
}
if (!$display_name) {
$errors[] = "Display name is required.";
}
if (!$site_title) {
$errors[] = "Site title is required.";
}
if (!preg_match('#^/[^?<>:"|\\*]*$#', $base_path)) {
$errors[] = "Base path must look like a valid URL path (e.g. / or /tkr/).";
}
if ($items_per_page < 1 || $items_per_page > 50) {
$errors[] = "Items per page must be a number between 1 and 50.";
}
if (empty($errors)) {
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare("INSERT INTO user (username, display_name, password_hash) VALUES (?, ?, ?)");
$stmt->execute([$username, $display_name, $hash]);
$stmt = $db->prepare("INSERT INTO settings (id, site_title, site_description, base_path, items_per_page) VALUES (1, ?, ?, ?, ?)");
$stmt->execute([$site_title, $site_description, $base_path, $items_per_page]);
header("Location: index.php");
exit;
}
}
?>
<h1>Lets Set Up Your tkr</h1>
<form method="post">
<h3>User settings</h3>
<label>Username: <input type="text" name="username" required></label><br>
<label>Display name: <input type="text" name="display_name" required></label><br>
<label>Password: <input type="password" name="password" required></label><br>
<br/><br/>
<h3>Site settings</h3>
<label>Title: <input type="text" name="site_title" value="My tkr" required></label><br>
<label>Description: <input type="text" name="site_description"></label><br>
<label>Base path: <input type="text" name="base_path" value="/" required></label><br>
<label>Items per page (max 50): <input type="number" name="items_per_page" value="25" min="1" max="50" required></label><br>
<br/>
<button type="submit">Complete Setup</button>
</form>

View File