refactor-storage (#19)
Move ticks from filesystem into database. I'm going to preserve the filesystem and just delete it manually. I'll add a login warning, but I'm pretty sure I'm the only person who'll ever be affected by this. Co-authored-by: Greg Sarjeant <1686767+gsarjeant@users.noreply.github.com> Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/19
This commit is contained in:
		
							parent
							
								
									7b7f8d205d
								
							
						
					
					
						commit
						9338332536
					
				
							
								
								
									
										31
									
								
								config/migrations/000_init.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								config/migrations/000_init.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| CREATE TABLE IF NOT EXISTS user ( | ||||
|     id INTEGER PRIMARY KEY, | ||||
|     username TEXT NOT NULL, | ||||
|     display_name TEXT NOT NULL, | ||||
|     password_hash TEXT NULL, | ||||
|     about TEXT NULL, | ||||
|     website TEXT NULL, | ||||
|     mood TEXT NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE IF NOT EXISTS settings ( | ||||
|     id INTEGER PRIMARY KEY, | ||||
|     site_title TEXT NOT NULL, | ||||
|     site_description TEXT NULL, | ||||
|     base_url TEXT NOT NULL, | ||||
|     base_path TEXT NOT NULL, | ||||
|     items_per_page INTEGER NOT NULL, | ||||
|     css_id INTEGER NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE IF NOT EXISTS css ( | ||||
|     id INTEGER PRIMARY KEY, | ||||
|     filename TEXT UNIQUE NOT NULL, | ||||
|     description TEXT NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE IF NOT EXISTS emoji( | ||||
|     id INTEGER PRIMARY KEY, | ||||
|     emoji TEXT UNIQUE NOT NULL, | ||||
|     description TEXT NOT NULL | ||||
| ); | ||||
							
								
								
									
										5
									
								
								config/migrations/003_add_tick_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								config/migrations/003_add_tick_table.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| CREATE TABLE IF NOT EXISTS tick ( | ||||
|     id INTEGER PRIMARY KEY, | ||||
|     timestamp TEXT NOT NULL, | ||||
|     tick TEXT NOT NULL | ||||
| ); | ||||
							
								
								
									
										1
									
								
								config/migrations/004_add_tick_timestamp_index.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								config/migrations/004_add_tick_timestamp_index.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| CREATE INDEX idx_tick_timestamp ON tick (timestamp DESC); | ||||
| @ -1,7 +1,5 @@ | ||||
| <?php | ||||
| class FeedController extends Controller { | ||||
|     private ConfigModel $config; | ||||
|     private array $ticks; | ||||
|     private array $vars; | ||||
| 
 | ||||
|     protected function render(string $templateFile, array $vars = []) { | ||||
| @ -16,11 +14,13 @@ class FeedController extends Controller { | ||||
|     } | ||||
| 
 | ||||
|     public function __construct(){ | ||||
|         $this->config = ConfigModel::load(); | ||||
|         $this->ticks = iterator_to_array(TickModel::streamTicks($this->config->itemsPerPage)); | ||||
|         $config = ConfigModel::load(); | ||||
|         $tickModel = new TickModel(); | ||||
|         $ticks = iterator_to_array($tickModel->stream($config->itemsPerPage)); | ||||
| 
 | ||||
|         $this->vars = [ | ||||
|             'config' => $this->config, | ||||
|             'ticks' => $this->ticks, | ||||
|             'config' => $config, | ||||
|             'ticks' => $ticks, | ||||
|         ]; | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -7,9 +7,10 @@ class HomeController extends Controller { | ||||
|         global $config; | ||||
|         global $user; | ||||
| 
 | ||||
|         $tickModel = new TickModel(); | ||||
|         $limit = $config->itemsPerPage; | ||||
|         $offset = ($page - 1) * $limit; | ||||
|         $ticks = iterator_to_array(TickModel::streamTicks($limit, $offset)); | ||||
|         $ticks = iterator_to_array($tickModel->stream($limit, $offset)); | ||||
| 
 | ||||
|         $view = new HomeView(); | ||||
|         $tickList = $view->renderTicksSection($config->siteDescription, $ticks, $page, $limit); | ||||
| @ -29,7 +30,8 @@ class HomeController extends Controller { | ||||
|         if ($_SERVER['REQUEST_METHOD'] === 'POST' and isset($_POST['new_tick'])) { | ||||
|             // save the tick
 | ||||
|             if (trim($_POST['new_tick'])){ | ||||
|                 TickModel::save($_POST['new_tick'], $_POST['tick_mood']); | ||||
|                 $tickModel = new TickModel(); | ||||
|                 $tickModel->insert($_POST['new_tick']); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| <?php | ||||
| 
 | ||||
| class TickController extends Controller{ | ||||
|     // every tick is identified by its timestamp
 | ||||
|     public function index(string $year, string $month, string $day, string $hour, string $minute, string $second){ | ||||
|         $model = new TickModel(); | ||||
|         $tick = $model->get($year, $month, $day, $hour, $minute, $second); | ||||
|         $this->render('tick.php', $tick); | ||||
|     //public function index(string $year, string $month, string $day, string $hour, string $minute, string $second){
 | ||||
|     public function index(int $id){ | ||||
|         $tickModel = new TickModel(); | ||||
|         $vars = $tickModel->get($id); | ||||
|         $this->render('tick.php', $vars); | ||||
|     } | ||||
| } | ||||
| @ -27,10 +27,12 @@ class Database{ | ||||
|     // The database version will just be an int
 | ||||
|     // stored as PRAGMA user_version. It will
 | ||||
|     // correspond to the most recent migration file applied to the db.
 | ||||
|     //
 | ||||
|     // I'm starting from 0, so if the user_version is NULL, I'll return -1.
 | ||||
|     private function getVersion(): int { | ||||
|         $db = self::get(); | ||||
| 
 | ||||
|         return $db->query("PRAGMA user_version")->fetchColumn() ?? 0; | ||||
|         return $db->query("PRAGMA user_version")->fetchColumn() ?? -1; | ||||
|     } | ||||
| 
 | ||||
|     private function migrationNumberFromFile(string $filename): int { | ||||
| @ -77,7 +79,7 @@ class Database{ | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         $db = get_db(); | ||||
|         $db = self::get(); | ||||
|         $db->beginTransaction(); | ||||
| 
 | ||||
|         try { | ||||
|  | ||||
| @ -6,6 +6,7 @@ class Filesystem { | ||||
|         $this->validateStorageDir(); | ||||
|         $this->validateStorageSubdirs(); | ||||
|         $this->migrateTickFiles(); | ||||
|         $this->moveTicksToDatabase(); | ||||
|     } | ||||
| 
 | ||||
|     // Make sure the storage/ directory exists and is writable
 | ||||
| @ -74,6 +75,8 @@ class Filesystem { | ||||
|         $lines = file($filepath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); | ||||
|         $modified = false; | ||||
| 
 | ||||
|         // &$line creates a reference to the line in the array ($lines)
 | ||||
|         // so I can modify it in place
 | ||||
|         foreach ($lines as &$line) { | ||||
|             $fields = explode('|', $line); | ||||
|             if (count($fields) === 2) { | ||||
| @ -82,10 +85,69 @@ class Filesystem { | ||||
|                 $modified = true; | ||||
|             } | ||||
|         } | ||||
|         unset($line); | ||||
| 
 | ||||
|         if ($modified) { | ||||
|             file_put_contents($filepath, implode("\n", $lines) . "\n"); | ||||
|             // TODO: log properly
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // TODO: Delete this sometime before 1.0
 | ||||
|     // Move ticks into database
 | ||||
|     private function moveTicksToDatabase(){ | ||||
|         // It's a temporary migration function, so I'm not going to sweat the
 | ||||
|         // order of operations to let me reuse the global database.
 | ||||
|         $db = Database::get(); | ||||
|         $count = $db->query("SELECT COUNT(*) FROM tick")->fetchColumn(); | ||||
| 
 | ||||
|         // Only migrate from filesystem if there are no ticks already in the database.
 | ||||
|         if ($count !== 0){ | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         $files = new RecursiveIteratorIterator( | ||||
|             new RecursiveDirectoryIterator(TICKS_DIR, RecursiveDirectoryIterator::SKIP_DOTS) | ||||
|         ); | ||||
| 
 | ||||
|         foreach ($files as $file) { | ||||
|             if ($file->isFile() && str_ends_with($file->getFilename(), '.txt')) { | ||||
|                 // Construct the date from the path and filename
 | ||||
|                 $dir = pathinfo($file, PATHINFO_DIRNAME); | ||||
|                 $dir_parts = explode('/', trim($dir, '/')); | ||||
|                 [$year, $month] = array_slice($dir_parts, -2); | ||||
|                 $day = pathinfo($file, PATHINFO_FILENAME); | ||||
| 
 | ||||
|                 $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); | ||||
|                 foreach ($lines as $line) { | ||||
|                     // Get the time and the text, but discard the mood.
 | ||||
|                     // I've decided against using it
 | ||||
|                     $fields = explode('|', $line); | ||||
|                     $time = $fields[0]; | ||||
|                     $tick = $fields[2]; | ||||
| 
 | ||||
|                     $dateTime = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', "$year-$month-$day $time"); | ||||
|                     $tickDateTimeUTC = $dateTime->format('Y-m-d H:i:s'); | ||||
| 
 | ||||
|                     $ticks[] = [$tickDateTimeUTC, $tick]; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Sort the ticks by dateTime
 | ||||
|         usort($ticks, function($a, $b) { | ||||
|             return strcmp($a[0], $b[0]); | ||||
|         }); | ||||
| 
 | ||||
|         // Save ticks to database
 | ||||
|         foreach ($ticks as $tick){ | ||||
|             // Yes, silly, but I'm testing out the datetime/string SQLite conversion
 | ||||
|             $dateTime = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', "$tick[0]"); | ||||
|             $timestamp = $dateTime->format('Y-m-d H:i:s'); | ||||
|             $tickText = $tick[1]; | ||||
| 
 | ||||
|             $stmt = $db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)"); | ||||
|             $stmt->execute([$timestamp, $tickText]); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -21,7 +21,7 @@ class Router { | ||||
|         ['mood', 'MoodController@handlePost', ['POST']], | ||||
|         ['setup', 'AdminController@showSetup'], | ||||
|         ['setup', 'AdminController@handleSetup', ['POST']], | ||||
|         ['tick/{y}/{m}/{d}/{h}/{i}/{s}', 'TickController'], | ||||
|         ['tick/{id}', 'TickController'], | ||||
|         ['css/custom/{filename}.css', 'CssController@serveCustomCss'], | ||||
|     ]; | ||||
| 
 | ||||
|  | ||||
| @ -1,105 +1,41 @@ | ||||
| <?php | ||||
| class TickModel { | ||||
|     // Everything in this class just reads from and writes to the filesystem
 | ||||
|     // It doesn't maintain state, so everything's just a static function
 | ||||
|     public static function streamTicks(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
 | ||||
|     public function stream(int $limit, int $offset = 0): Generator { | ||||
|         global $db; | ||||
| 
 | ||||
|         $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) | ||||
|             ); | ||||
|         $stmt = $db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?"); | ||||
|         $stmt->execute([$limit, $offset]); | ||||
| 
 | ||||
|             // split the path to the current file into the date components
 | ||||
|             $pathParts = explode('/', str_replace('\\', '/', $file)); | ||||
| 
 | ||||
|             // assign the different components to the appropriate part of the date
 | ||||
|             $year = $pathParts[count($pathParts) - 3]; | ||||
|             $month = $pathParts[count($pathParts) - 2]; | ||||
|             $day = 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
 | ||||
|                 list($time, $mood, $tick) = explode('|', $line, 3); | ||||
| 
 | ||||
|                 // Build the timestamp from the date and time
 | ||||
|                 // Ticks are always stored in UTC
 | ||||
|                 $timestampUTC = "$year-$month-$day $time"; | ||||
|                 yield [ | ||||
|                     'timestamp' => $timestampUTC, | ||||
|                     'mood' => $mood, | ||||
|                     'tick' => $tick, | ||||
|                 ]; | ||||
| 
 | ||||
|                 if (++$count >= $limit) { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|         while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { | ||||
|             yield [ | ||||
|                 'id' => $row['id'], | ||||
|                 'timestamp' => $row['timestamp'], | ||||
|                 'tick' => $row['tick'], | ||||
|             ]; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static function save(string $tick, string $mood=''): void { | ||||
|         // build the tick path and filename from the current time
 | ||||
|         $now = new DateTime('now', new DateTimeZone('UTC')); | ||||
|     public function insert(string $tick, ?DateTimeImmutable $datetime = null): void { | ||||
|         global $db; | ||||
|         $datetime ??= new DateTimeImmutable('now', new DateTimeZone('UTC')); | ||||
|         $timestamp = $datetime->format('Y-m-d H:i:s'); | ||||
| 
 | ||||
|         $year = $now->format('Y'); | ||||
|         $month = $now->format('m'); | ||||
|         $day = $now->format('d'); | ||||
|         $time = $now->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 . '|' . $mood . '|' . $tick . "\n"; | ||||
|         file_put_contents($filename, $content, FILE_APPEND); | ||||
|         $stmt = $db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)"); | ||||
|         $stmt->execute([$timestamp, $tick]); | ||||
|     } | ||||
| 
 | ||||
|     public static function get(string $y, string $m, string $d, string $H, string $i, string $s): array{ | ||||
|         $tickTime = new DateTime("$y-$m-$d $H:$i:$s"); | ||||
|         $timestamp = "$H:$i:$s"; | ||||
|         $file = TICKS_DIR . "/$y/$m/$d.txt"; | ||||
|     public function get(int $id): array { | ||||
|         global $db; | ||||
| 
 | ||||
|         if (!file_exists($file)) { | ||||
|             http_response_code(404); | ||||
|             echo "Tick not found: $file."; | ||||
|             exit; | ||||
|         } | ||||
|         $stmt = $db->prepare("SELECT timestamp, tick FROM tick WHERE id=?"); | ||||
|         $stmt->execute([$id]); | ||||
|         $row = $stmt->fetch(PDO::FETCH_ASSOC); | ||||
| 
 | ||||
|         $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); | ||||
|         foreach ($lines as $line) { | ||||
|             if (str_starts_with($line, $timestamp)) { | ||||
|                 echo $line; | ||||
|                 exit; | ||||
|                 list($time, $emoji, $tick) = explode('|', $line, 3); | ||||
| 
 | ||||
|                 return [ | ||||
|                     'tickTime' => $tickTime, | ||||
|                     'emoji' => $emoji, | ||||
|                     'tick' => $tick, | ||||
|                     'config' => ConfigModel::load(), | ||||
|                 ]; | ||||
|             } | ||||
|         } | ||||
|         // TODO: Test for existence of row and handle absence.
 | ||||
|         return [ | ||||
|             'tickTime' => $row['timestamp'], | ||||
|             'tick' => $row['tick'], | ||||
|             'config' => ConfigModel::load(), | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -13,9 +13,6 @@ class HomeView { | ||||
|                 ?>
 | ||||
|                 <li class="tick" tabindex="0"> | ||||
|                     <time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
 | ||||
|                     <?php if ($config->showTickMood): ?>
 | ||||
|                         <span><?php echo $tick['mood'] ?></span>
 | ||||
|                     <?php endif; ?>
 | ||||
|                     <span class="tick-text"><?php echo Util::linkify(Util::escape_html($tick['tick'])) ?></span>
 | ||||
|                 </li> | ||||
|             <?php endforeach; ?>
 | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								storage/db/tkr.sqlite.bak
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								storage/db/tkr.sqlite.bak
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| @ -22,17 +22,8 @@ echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"; | ||||
|         <name><?= Util::escape_xml($config->siteTitle) ?></name>
 | ||||
|   </author> | ||||
| <?php foreach ($ticks as $tick): | ||||
|     // decompose the tick timestamp into the date/time parts
 | ||||
|     [$date, $time] = explode(' ', $tick['timestamp']); | ||||
| 
 | ||||
|     $dateParts = explode('-', $date); | ||||
|     [$year, $month, $day] = $dateParts; | ||||
| 
 | ||||
|     $timeParts = explode(':', $time); | ||||
|     [$hour, $minute, $second] = $timeParts; | ||||
| 
 | ||||
|     // build the tick entry components
 | ||||
|     $tickPath = "tick/$year/$month/$day/$hour/$minute/$second"; | ||||
|     $tickPath = "tick/" . $tick['id']; | ||||
|     $tickUrl = Util::escape_xml($siteUrl . $basePath . $tickPath); | ||||
|     $tickTime = date(DATE_ATOM, strtotime($tick['timestamp'])); | ||||
|     $tickTitle = Util::escape_xml($tick['tick']); | ||||
|  | ||||
| @ -19,17 +19,9 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; | ||||
|     <language>en-us</language> | ||||
|     <lastBuildDate><?php echo date(DATE_RSS); ?></lastBuildDate>
 | ||||
| <?php foreach ($ticks as $tick): | ||||
|     // decompose the tick timestamp into the date/time parts
 | ||||
|     [$date, $time] = explode(' ', $tick['timestamp']); | ||||
| 
 | ||||
|     $dateParts = explode('-', $date); | ||||
|     [$year, $month, $day] = $dateParts; | ||||
| 
 | ||||
|     $timeParts = explode(':', $time); | ||||
|     [$hour, $minute, $second] = $timeParts; | ||||
| 
 | ||||
|     // build the tick entry components
 | ||||
|     $tickPath = "tick/$year/$month/$day/$hour/$minute/$second"; | ||||
|     //$tickPath = "tick/$year/$month/$day/$hour/$minute/$second";
 | ||||
|     $tickPath = "tick/" . $tick['id']; | ||||
|     $tickUrl = Util::escape_xml($config->baseUrl . $config->basePath . $tickPath); | ||||
|     $tickDate = date(DATE_RSS, strtotime($tick['timestamp'])); | ||||
|     $tickTitle = Util::escape_xml($tick['tick']); | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var Date $tickTime */ ?>
 | ||||
| <?php /** @var string $tick */ ?>
 | ||||
|         <h1>Tick from <?= $tickTime->format('Y-m-d H:i:s'); ?></h1>
 | ||||
|         <h1>Tick from <?= $tickTime; ?></h1>
 | ||||
|         <p><?= Util::linkify(Util::escape_html($tick)) ?></p>
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user