LGV_TZ_Lookup 1.4.2
Time zone lookup for PHP applications and HTTP clients
Loading...
Searching...
No Matches
update.php
Go to the documentation of this file.
1<?php
2/***************************************************************************************************************************/
3/**
4 © Copyright 2023-2026, [Little Green Viper Software Development LLC](https://littlegreenviper.com)
5
6 MIT License
7
8 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
9 files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
10 modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
11 Software is furnished to do so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
14
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
16 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17 IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
18 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19*/
20/***************************************************************************************************************************/
21/**
22 \file
23 \brief Report the latest boundary release, or replace an existing installation's boundaries.
24
25 Run through update.sh. Its shell trap removes downloads even after interruption or a PHP memory-limit failure.
26 New polygons load into a private staging table. MySQL publishes with one RENAME TABLE; PostgreSQL uses transactional
27 renames. Failed loading preserves the live table. Recovery removes this run's staging/old tables, never timezones.
28*/
29declare(strict_types=1);
30require_once __DIR__.'/Setup.php';
31
32/***************************************************************************************************************************/
33/** \brief Read the server's existing database settings without starting the HTTP endpoint. */
34function updateSettings(string $config): array {
35 if (!is_file($config) || !is_readable($config)) { throw new RuntimeException('The private configuration file is not readable.'); }
36 require $config;
37 if (!isset($g_dbName, $g_dbUserName, $g_dbPassword) || !is_string($g_dbName) || $g_dbName === '') {
38 throw new RuntimeException('The configuration must define g_dbName, g_dbUserName, and g_dbPassword.');
39 }
40 $driver = strtolower($g_dbType ?? 'mysql');
41 if (!in_array($driver, ['mysql', 'pgsql'], true)) { throw new RuntimeException('The database driver must be mysql or pgsql.'); }
42 return ['database' => $g_dbName, 'user' => $g_dbUserName, 'password' => $g_dbPassword,
43 'driver' => $driver, 'host' => $g_dbHost ?? '127.0.0.1', 'port' => $g_dbPort ?? ($driver === 'pgsql' ? 5432 : 3306)];
44}
45
46/***************************************************************************************************************************/
47/** \brief Connect and hold the same database lock used by the installer. */
48function updateConnection(array $settings): PDO {
49 $driver = $settings['driver'];
50 $dsn = $driver.':host='.$settings['host'].';port='.$settings['port'].';dbname='.$settings['database'];
51 $dsn .= $driver === 'mysql' ? ';charset=utf8mb4' : ';options=--client_encoding=UTF8';
52 $connection = new PDO($dsn, $settings['user'], $settings['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
53 $sql = $driver === 'pgsql' ? "SELECT pg_try_advisory_lock(hashtext('LGV_TZ_Lookup.install'))"
54 : "SELECT GET_LOCK(CONCAT('lgv_tz_', SHA2(DATABASE(), 224)), 0)";
55 if (!$connection->query($sql)->fetchColumn()) { throw new RuntimeException('Another timezone installation or update is using this database.'); }
56 return $connection;
57}
58
59/***************************************************************************************************************************/
60/** \brief Remove downloaded data and a recorded staging table after an interrupted load. */
61function updateCleanup(string $work): void {
62 foreach (['boundaries.zip', 'boundaries.json', 'release.json'] as $file) {
63 if (is_file($work.'/'.$file) && !unlink($work.'/'.$file)) { throw new RuntimeException('Could not remove downloaded '.$file); }
64 }
65 if (!is_file($work.'/update.json')) { return; }
66 $state = json_decode(file_get_contents($work.'/update.json'), true, 512, JSON_THROW_ON_ERROR);
67 if (!is_string($state['table'] ?? null) || !preg_match('/^lgv_tz_update_[a-f0-9]{16}$/D', $state['table'])) {
68 throw new RuntimeException('Invalid staging table record; refusing database cleanup.');
69 }
70 $connection = updateConnection(updateSettings($state['config']));
71 $connection->exec('DROP TABLE IF EXISTS '.$state['table']);
72 $connection->exec('DROP TABLE IF EXISTS lgv_tz_old_'.substr($state['table'], -16));
73 if (!unlink($work.'/update.json')) { throw new RuntimeException('Could not remove staging recovery information.'); }
74}
75
76/***************************************************************************************************************************/
77/** \brief Load into staging, then publish atomically. The caller retains the database lock throughout. */
78function updateLoad(PDO $connection, array $settings, string $json, string $table): int {
79 if (!preg_match('/^lgv_tz_update_[a-f0-9]{16}$/D', $table)) { throw new InvalidArgumentException('Invalid staging table name.'); }
80 $database = new class($settings, $table) extends LGV_TZ_Lookup_Database {
81 /** \brief Use the normal loader schema and storage format, with this run's private destination. */
82 public function __construct(array $settings, string $table) {
83 parent::__construct($settings['database'], $settings['user'], $settings['password'],
84 $settings['driver'], $settings['host'], $settings['port']);
85 $this->_load_table = $table;
86 }
87 };
88 $stream = fopen($json, 'rb');
89 if ($stream === false) { throw new RuntimeException('Could not open the downloaded boundary file.'); }
90 try {
91 $listener = new class($database) extends LGV_TZ_Lookup_Loader {
92 public bool $complete = false; ///< True only after the parser closes the entire JSON document.
93 /** \brief Reject truncated input even when the streaming parser silently reaches EOF. */
94 public function endDocument(): void { parent::endDocument(); $this->complete = true; }
95 };
96 (new JsonStreamingParser\Parser($stream, $listener))->parse();
97 if (!$listener->complete) { throw new RuntimeException('The boundary JSON is incomplete; existing boundaries were retained.'); }
98 } finally { fclose($stream); LGV_TZ_Lookup_Loader::$db_object = null; }
99 $count = (int)$connection->query('SELECT COUNT(*) FROM '.$table)->fetchColumn();
100 if ($count === 0) { throw new RuntimeException('No polygons were loaded; existing boundaries were retained.'); }
101 if ($settings['driver'] === 'pgsql') { $connection->exec('ANALYZE '.$table); }
102 $old = 'lgv_tz_old_'.substr($table, -16);
103 if ($settings['driver'] === 'pgsql') {
104 $connection->beginTransaction();
105 try {
106 $connection->exec('ALTER TABLE timezones RENAME TO '.$old);
107 $connection->exec('ALTER TABLE '.$table.' RENAME TO timezones');
108 $connection->exec('DROP TABLE '.$old);
109 $connection->commit();
110 } catch (Throwable $error) {
111 if ($connection->inTransaction()) { $connection->rollBack(); }
112 throw $error;
113 }
114 } else {
115 $connection->exec('RENAME TABLE timezones TO '.$old.', '.$table.' TO timezones');
116 // Publication already succeeded. A failure removing the old copy must not be described as a failed load.
117 try { $connection->exec('DROP TABLE '.$old); }
118 catch (Throwable $error) { fwrite(STDERR, 'Updated boundaries, but could not remove old table '.$old.'.'.PHP_EOL); }
119 }
120 return $count;
121}
122
123/***************************************************************************************************************************/
124/** \brief Discover, report, download, and load boundaries; --check stops before reading config or touching the database. */
125function updateRun(string $work, string $config, bool $check = false, ?callable $download = null): void {
126 if (!extension_loaded('curl')) { throw new RuntimeException('The PHP curl extension is required.'); }
127 $download = $download ?? [LGV_TZ_Lookup_Setup::class, 'download'];
128 $release = LGV_TZ_Lookup_Setup::latestBoundaries($work, $download);
129 echo 'Latest boundary release: '.$release['version'].PHP_EOL;
130 if ($check) { return; }
131 foreach (['zip', 'pdo', 'mbstring', 'ctype'] as $extension) {
132 if (!extension_loaded($extension)) { throw new RuntimeException('The PHP '.$extension.' extension is required.'); }
133 }
134 $config = realpath($config);
135 if ($config === false) { throw new RuntimeException('The private configuration file was not found.'); }
137 // Installed servers keep their Composer application next to config.php; manual copies use root dependencies.
138 foreach ([dirname($config).'/app/vendor/autoload.php', dirname($config).'/vendor/autoload.php',
139 dirname(__DIR__).'/vendor/autoload.php'] as $autoload) {
140 if (is_file($autoload)) { require_once $autoload; }
141 }
142 if (!class_exists('LGV_TZ_Lookup_Database')) {
143 require_once dirname(__DIR__).'/src/Sources/LGV_TZ_Lookup_Database.class.php';
144 }
145 if (!class_exists('LGV_TZ_Lookup_Loader')) {
146 require_once dirname(__DIR__).'/src/Sources/LGV_TZ_Lookup_Loader.class.php';
147 }
148 // Older installed packages cannot redirect the loader's writes; fail before loading rather than reset the live table.
149 if (!property_exists(LGV_TZ_Lookup_Database::class, '_load_table')) {
150 throw new RuntimeException('Update the installed Composer package to 1.4.1 or later before running this updater.');
151 }
153 $connection->query('SELECT id, tzname, east, west, north, south, polygon FROM timezones LIMIT 0')->closeCursor();
154 echo "Downloading full timezone boundaries with oceans...\n";
155 $download($release['asset']['browser_download_url'], $work.'/boundaries.zip');
156 LGV_TZ_Lookup_Setup::verifyBoundaries($work.'/boundaries.zip', $release['asset']);
157 LGV_TZ_Lookup_Setup::extract($work.'/boundaries.zip', $work.'/boundaries.json');
158 $table = 'lgv_tz_update_'.bin2hex(random_bytes(8));
159 if (file_put_contents($work.'/update.json', json_encode(['config' => $config, 'table' => $table], JSON_THROW_ON_ERROR)) === false ||
160 !chmod($work.'/update.json', 0600)) { throw new RuntimeException('Could not record staging recovery information.'); }
161 echo 'Loading boundary release '.$release['version']."...\n";
162 try {
163 $count = updateLoad($connection, $settings, $work.'/boundaries.json', $table);
164 echo 'Updated to boundary release '.$release['version'].'; loaded '.$count." polygons.\n";
165 } finally {
166 // On success the staging table was renamed; on failure it contains only this run's incomplete data.
167 $connection->exec('DROP TABLE IF EXISTS '.$table);
168 unlink($work.'/update.json');
169 }
170}
171
172if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !== __FILE__) { return; }
173$cleanup = ($argv[1] ?? '') === 'cleanup';
174$work = $argv[$cleanup ? 2 : 1] ?? '';
175if (PHP_SAPI !== 'cli' || $argc < 3 || !is_dir($work)) {
176 fwrite(STDERR, "Run ./update.sh [--check] [CONFIG_FILE].\n"); exit(2);
177}
178if (function_exists('pcntl_async_signals')) {
179 pcntl_async_signals(true);
180 foreach ([SIGHUP, SIGINT, SIGTERM] as $signal) {
181 pcntl_signal($signal, static function(int $signal): void { throw new RuntimeException('Update interrupted by signal '.$signal.'.'); });
182 }
183}
184try {
185 if ($cleanup) { updateCleanup($work); }
186 else { updateRun($work, $argv[2], ($argv[3] ?? '') === '1'); }
187} catch (Throwable $error) {
188 fwrite(STDERR, 'Update error: '.$error->getMessage().PHP_EOL); exit(1);
189}
static extract(string $archive, string $destination)
Extract exactly one GeoJSON entry without using archive paths.
Definition Setup.php:107
static latestBoundaries(string $directory, ?callable $download=null)
Discover the latest full boundary release with oceans.
Definition Setup.php:42
static verifyBoundaries(string $archive, array $asset)
Verify the archive against the release's size and optional SHA-256 digest.
Definition Setup.php:61
$connection
Definition composer.php:31
$database
Definition composer.php:52
$autoload
Definition composer.php:7
$driver
Definition databases.php:38
$dsn
$config
Definition server.php:43
if(!defined( '__CONFIG_FILE_'))
Definition index.php:51
$error
Definition demo.php:116
$work
Definition update.php:40
$settings
Definition update.php:34
if(realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !==__FILE__) $cleanup
Definition update.php:173
updateSettings(string $config)
Read the server's existing database settings without starting the HTTP endpoint.
Definition update.php:34
updateCleanup(string $work)
Remove downloaded data and a recorded staging table after an interrupted load.
Definition update.php:61
updateConnection(array $settings)
Connect and hold the same database lock used by the installer.
Definition update.php:48
updateLoad(PDO $connection, array $settings, string $json, string $table)
Load into staging, then publish atomically. The caller retains the database lock throughout.
Definition update.php:78
updateRun(string $work, string $config, bool $check=false, ?callable $download=null)
Discover, report, download, and load boundaries; –check stops before reading config or touching the d...
Definition update.php:125