Atlas is now a plain library plus a tiny web app with no framework: - p3k\Atlas\App routes the seven endpoints, p3k\Atlas\View renders the plain PHP templates, p3k\Atlas\Response carries the result. Controllers are ported line for line from the Slim closures. - p3k\geo\StaticMap\render() returns the image bytes and content type instead of sending headers and echoing, so other apps can embed it. generate() remains as a wrapper. The tile size constant is namespaced. - Fix Airports::from_code() (the file handle was never kept) and the reference to a Geocoder class that never existed. - composer: type library, PHP 8.1+, ext-gd required; lib/helpers.php is no longer autoloaded so nothing global leaks into consumers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeX8DR83QNwGUBJDAXj8fimain
| @ -1,67 +0,0 @@ | |||||
| <?php | |||||
| $app->get('/api/geocode', function() use($app) { | |||||
| $params = $app->request()->params(); | |||||
| if( | |||||
| (k($params, 'latitude') !== null && k($params, 'longitude') !== null) | |||||
| || k($params, 'input') !== null | |||||
| ) { | |||||
| $response = [ | |||||
| 'latitude' => null, | |||||
| 'longitude' => null, | |||||
| 'locality' => null, | |||||
| 'region' => null, | |||||
| 'country' => null, | |||||
| 'best_name' => null, | |||||
| 'full_name' => null, | |||||
| 'postal-code' => null, | |||||
| 'timezone' => null, | |||||
| 'offset' => null, | |||||
| 'seconds' => null, | |||||
| 'localtime' => null, | |||||
| ]; | |||||
| if(k($params, 'input')) { | |||||
| $adr = p3k\Geocoder::geocode($params['input']); | |||||
| } else { | |||||
| $lat = (float)$params['latitude']; | |||||
| $lng = (float)$params['longitude']; | |||||
| $response['latitude'] = $lat; | |||||
| $response['longitude'] = $lng; | |||||
| $adr = p3k\Geocoder::adrFromLocation($lat, $lng); | |||||
| } | |||||
| if($adr) { | |||||
| $response['latitude'] = $adr->latitude; | |||||
| $response['longitude'] = $adr->longitude; | |||||
| $response['locality'] = $adr->localityName; | |||||
| $response['region'] = $adr->regionName; | |||||
| $response['country'] = $adr->countryName; | |||||
| $response['best_name'] = $adr->bestName; | |||||
| $response['full_name'] = $adr->fullName; | |||||
| $response['postal-code'] = $adr->postalCode; | |||||
| } | |||||
| $tz = p3k\Timezone::timezone_for_location($response['latitude'], $response['longitude'], k($params,'date')); | |||||
| $timezone = false; | |||||
| if($tz) { | |||||
| $timezone = new p3k\timezone\Result($tz, k($params, 'date')); | |||||
| } | |||||
| if($timezone) { | |||||
| $response['timezone'] = $timezone->name; | |||||
| $response['offset'] = $timezone->offset; | |||||
| $response['seconds'] = $timezone->seconds; | |||||
| $response['localtime'] = $timezone->localtime; | |||||
| } | |||||
| json_response($app, $response); | |||||
| } else { | |||||
| json_response($app, [ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Request was missing parameters' | |||||
| ], 400); | |||||
| } | |||||
| }); | |||||
| @ -1,21 +0,0 @@ | |||||
| <?php | |||||
| use \Slim\Savant; | |||||
| $app->get('/', function() use($app) { | |||||
| return Savant\render('index'); | |||||
| }); | |||||
| $app->get('/static-maps', function() use($app) { | |||||
| return Savant\render('static-maps'); | |||||
| }); | |||||
| $app->get('/map', function() use($app) { | |||||
| return Savant\render('map'); | |||||
| }); | |||||
| $app->map('/map/img', function() use($app) { | |||||
| $params = $app->request()->params(); | |||||
| $app->response['Content-type'] = 'image/png'; | |||||
| $assetPath = dirname(__FILE__) . '/../public/map-images'; | |||||
| $map = p3k\geo\StaticMap\generate($params, null, $assetPath, is_authenticated($params)); | |||||
| })->via('GET','POST'); | |||||
| @ -1,66 +0,0 @@ | |||||
| <?php | |||||
| function timezone_for_location($lat, $lng) { | |||||
| $tz = \p3k\Timezone::timezone_for_location($lat, $lng); | |||||
| $timezone = false; | |||||
| if($tz) { | |||||
| $timezone = new p3k\timezone\Result($tz); | |||||
| } | |||||
| if($timezone) { | |||||
| return [ | |||||
| 'timezone' => $timezone->name, | |||||
| 'offset' => $timezone->offset, | |||||
| 'seconds' => $timezone->seconds, | |||||
| 'localtime' => $timezone->localtime | |||||
| ]; | |||||
| } else { | |||||
| return [ | |||||
| 'error' => 'not_found', | |||||
| 'error_description' => 'No timezone was found for the requested location' | |||||
| ]; | |||||
| } | |||||
| } | |||||
| $app->get('/api/timezone', function() use($app) { | |||||
| $params = $app->request()->params(); | |||||
| if(k($params, 'latitude') !== null && k($params, 'longitude') !== null) { | |||||
| $lat = (float)$params['latitude']; | |||||
| $lng = (float)$params['longitude']; | |||||
| $result = timezone_for_location($lat, $lng); | |||||
| json_response($app, $result); | |||||
| } elseif(k($params, 'airport')) { | |||||
| $code = $params['airport']; | |||||
| $airport = \p3k\Airports::from_code($code); | |||||
| if($airport) { | |||||
| $result = timezone_for_location($airport['latitude'], $airport['longitude']); | |||||
| if(!isset($result['error'])) { | |||||
| $result['airport'] = $airport; | |||||
| } | |||||
| json_response($app, $result); | |||||
| } else { | |||||
| json_response($app, [ | |||||
| 'error' => 'not_found', | |||||
| 'error_description' => 'The airport code was not found' | |||||
| ]); | |||||
| } | |||||
| } else { | |||||
| json_response($app, [ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Request was missing parameters' | |||||
| ], 400); | |||||
| } | |||||
| }); | |||||
| @ -1,28 +0,0 @@ | |||||
| <?php | |||||
| $app->get('/api/weather', function() use($app) { | |||||
| $params = $app->request()->params(); | |||||
| if(k($params, 'latitude') !== null && k($params, 'longitude') !== null && k($params, 'apikey') !== null) { | |||||
| $lat = (float)$params['latitude']; | |||||
| $lng = (float)$params['longitude']; | |||||
| $key = $params['apikey']; | |||||
| $weather = \p3k\Weather::weather_for_location($lat, $lng, $key); | |||||
| if($weather) { | |||||
| json_response($app, $weather); | |||||
| } else { | |||||
| json_response($app, [ | |||||
| 'error' => 'not_found', | |||||
| 'error_description' => 'No weather information was found for the requested location, or you used an invalid API key' | |||||
| ]); | |||||
| } | |||||
| } else { | |||||
| json_response($app, [ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Request was missing parameters' | |||||
| ], 400); | |||||
| } | |||||
| }); | |||||
| @ -1,45 +0,0 @@ | |||||
| <?php | |||||
| error_reporting(E_ALL ^ E_DEPRECATED); | |||||
| function json_response(&$app, $response, $code=200) { | |||||
| $app->response()->status($code); | |||||
| $app->response()['Content-Type'] = 'application/json'; | |||||
| $app->response()->body(json_encode($response)); | |||||
| } | |||||
| function k($a, $k, $default=null) { | |||||
| if(is_array($k)) { | |||||
| $result = true; | |||||
| foreach($k as $key) { | |||||
| $result = $result && array_key_exists($key, $a); | |||||
| } | |||||
| return $result; | |||||
| } else { | |||||
| if(is_array($a) && array_key_exists($k, $a) && $a[$k]) | |||||
| return $a[$k]; | |||||
| elseif(is_object($a) && property_exists($a, $k) && $a->$k) | |||||
| return $a->$k; | |||||
| else | |||||
| return $default; | |||||
| } | |||||
| } | |||||
| function is_authenticated($params) { | |||||
| if(!isset($params['token'])) | |||||
| return false; | |||||
| $token_file = __DIR__.'/../data/apikeys.txt'; | |||||
| if(!file_exists($token_file)) | |||||
| return false; | |||||
| $valid_tokens = array_filter(file($token_file)); | |||||
| array_walk($valid_tokens, function(&$val) { | |||||
| $val = trim($val); | |||||
| }); | |||||
| if(in_array($params['token'], $valid_tokens)) | |||||
| return true; | |||||
| return false; | |||||
| } | |||||
| @ -0,0 +1,108 @@ | |||||
| <?php | |||||
| namespace p3k\Atlas; | |||||
| use p3k\Atlas\Controllers\Main; | |||||
| use p3k\Atlas\Controllers\Timezone; | |||||
| use p3k\Atlas\Controllers\Geocode; | |||||
| use p3k\Atlas\Controllers\Weather; | |||||
| /** | |||||
| * The Atlas web app: a route table, request parameters and the shared | |||||
| * helpers the controllers use. No framework. | |||||
| */ | |||||
| class App { | |||||
| private $root; | |||||
| private $view; | |||||
| public function __construct($root) { | |||||
| $this->root = rtrim($root, '/'); | |||||
| $this->view = new View($this->root . '/views'); | |||||
| } | |||||
| public function root() { | |||||
| return $this->root; | |||||
| } | |||||
| public function view() { | |||||
| return $this->view; | |||||
| } | |||||
| /** | |||||
| * [methods, path, [class, method]] | |||||
| */ | |||||
| public function routes() { | |||||
| return [ | |||||
| [['GET'], '/', [Main::class, 'index']], | |||||
| [['GET'], '/static-maps', [Main::class, 'staticMaps']], | |||||
| [['GET'], '/map', [Main::class, 'map']], | |||||
| [['GET', 'POST'], '/map/img', [Main::class, 'mapImage']], | |||||
| [['GET'], '/api/timezone', [Timezone::class, 'lookup']], | |||||
| [['GET'], '/api/geocode', [Geocode::class, 'lookup']], | |||||
| [['GET'], '/api/weather', [Weather::class, 'lookup']], | |||||
| ]; | |||||
| } | |||||
| public function run() { | |||||
| $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; | |||||
| $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; | |||||
| if($path !== '/') { | |||||
| $path = rtrim($path, '/'); | |||||
| } | |||||
| $params = array_merge($_GET, $_POST); | |||||
| $this->dispatch($method, $path, $params)->send(); | |||||
| } | |||||
| public function dispatch($method, $path, $params) { | |||||
| if($method === 'HEAD') { | |||||
| $method = 'GET'; | |||||
| } | |||||
| foreach($this->routes() as list($methods, $route, $handler)) { | |||||
| if($route === $path) { | |||||
| if(!in_array($method, $methods)) { | |||||
| return Response::text('Method Not Allowed', 405); | |||||
| } | |||||
| list($class, $fn) = $handler; | |||||
| return $class::$fn($this, $params); | |||||
| } | |||||
| } | |||||
| return Response::text('Not Found', 404); | |||||
| } | |||||
| /** | |||||
| * The value of $a[$k] when it is set and truthy, else $default. The | |||||
| * controllers have always treated "" and "0" as missing. | |||||
| */ | |||||
| public static function k($a, $k, $default = null) { | |||||
| if(is_array($k)) { | |||||
| $result = true; | |||||
| foreach($k as $key) { | |||||
| $result = $result && array_key_exists($key, $a); | |||||
| } | |||||
| return $result; | |||||
| } | |||||
| if(is_array($a) && array_key_exists($k, $a) && $a[$k]) | |||||
| return $a[$k]; | |||||
| elseif(is_object($a) && property_exists($a, $k) && $a->$k) | |||||
| return $a->$k; | |||||
| else | |||||
| return $default; | |||||
| } | |||||
| /** | |||||
| * Requests that carry one of the API keys listed in data/apikeys.txt may | |||||
| * use custom tile URLs and external marker icons. | |||||
| */ | |||||
| public function isAuthenticated($params) { | |||||
| if(!isset($params['token'])) | |||||
| return false; | |||||
| $tokenFile = $this->root . '/data/apikeys.txt'; | |||||
| if(!file_exists($tokenFile)) | |||||
| return false; | |||||
| $validTokens = array_map('trim', array_filter(file($tokenFile))); | |||||
| return in_array($params['token'], $validTokens, true); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,75 @@ | |||||
| <?php | |||||
| namespace p3k\Atlas\Controllers; | |||||
| use p3k\Atlas\App; | |||||
| use p3k\Atlas\Response; | |||||
| class Geocode { | |||||
| public static function lookup(App $app, $params) { | |||||
| if( | |||||
| (App::k($params, 'latitude') !== null && App::k($params, 'longitude') !== null) | |||||
| || App::k($params, 'input') !== null | |||||
| ) { | |||||
| $response = [ | |||||
| 'latitude' => null, | |||||
| 'longitude' => null, | |||||
| 'locality' => null, | |||||
| 'region' => null, | |||||
| 'country' => null, | |||||
| 'best_name' => null, | |||||
| 'full_name' => null, | |||||
| 'postal-code' => null, | |||||
| 'timezone' => null, | |||||
| 'offset' => null, | |||||
| 'seconds' => null, | |||||
| 'localtime' => null, | |||||
| ]; | |||||
| if(App::k($params, 'input')) { | |||||
| $adr = \p3k\Geocoder::geocode($params['input']); | |||||
| } else { | |||||
| $lat = (float)$params['latitude']; | |||||
| $lng = (float)$params['longitude']; | |||||
| $response['latitude'] = $lat; | |||||
| $response['longitude'] = $lng; | |||||
| $adr = \p3k\Geocoder::adrFromLocation($lat, $lng); | |||||
| } | |||||
| if($adr) { | |||||
| $response['latitude'] = $adr->latitude; | |||||
| $response['longitude'] = $adr->longitude; | |||||
| $response['locality'] = $adr->localityName; | |||||
| $response['region'] = $adr->regionName; | |||||
| $response['country'] = $adr->countryName; | |||||
| $response['best_name'] = $adr->bestName; | |||||
| $response['full_name'] = $adr->fullName; | |||||
| $response['postal-code'] = $adr->postalCode; | |||||
| } | |||||
| $timezone = false; | |||||
| if($response['latitude'] !== null && $response['longitude'] !== null) { | |||||
| $tz = \p3k\Timezone::timezone_for_location($response['latitude'], $response['longitude']); | |||||
| if($tz) { | |||||
| $timezone = new \p3k\timezone\Result($tz, App::k($params, 'date')); | |||||
| } | |||||
| } | |||||
| if($timezone) { | |||||
| $response['timezone'] = $timezone->name; | |||||
| $response['offset'] = $timezone->offset; | |||||
| $response['seconds'] = $timezone->seconds; | |||||
| $response['localtime'] = $timezone->localtime; | |||||
| } | |||||
| return Response::json($response); | |||||
| } else { | |||||
| return Response::json([ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Request was missing parameters' | |||||
| ], 400); | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,27 @@ | |||||
| <?php | |||||
| namespace p3k\Atlas\Controllers; | |||||
| use p3k\Atlas\App; | |||||
| use p3k\Atlas\Response; | |||||
| class Main { | |||||
| public static function index(App $app, $params) { | |||||
| return Response::html($app->view()->render('index')); | |||||
| } | |||||
| public static function staticMaps(App $app, $params) { | |||||
| return Response::html($app->view()->render('static-maps')); | |||||
| } | |||||
| public static function map(App $app, $params) { | |||||
| return Response::html($app->view()->render('map')); | |||||
| } | |||||
| public static function mapImage(App $app, $params) { | |||||
| $assetPath = $app->root() . '/public/map-images'; | |||||
| $image = \p3k\geo\StaticMap\render($params, $assetPath, $app->isAuthenticated($params)); | |||||
| return Response::image($image['data'], $image['contentType']); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,69 @@ | |||||
| <?php | |||||
| namespace p3k\Atlas\Controllers; | |||||
| use p3k\Atlas\App; | |||||
| use p3k\Atlas\Response; | |||||
| class Timezone { | |||||
| public static function forLocation($lat, $lng) { | |||||
| $tz = \p3k\Timezone::timezone_for_location($lat, $lng); | |||||
| $timezone = false; | |||||
| if($tz) { | |||||
| $timezone = new \p3k\timezone\Result($tz); | |||||
| } | |||||
| if($timezone) { | |||||
| return [ | |||||
| 'timezone' => $timezone->name, | |||||
| 'offset' => $timezone->offset, | |||||
| 'seconds' => $timezone->seconds, | |||||
| 'localtime' => $timezone->localtime | |||||
| ]; | |||||
| } else { | |||||
| return [ | |||||
| 'error' => 'not_found', | |||||
| 'error_description' => 'No timezone was found for the requested location' | |||||
| ]; | |||||
| } | |||||
| } | |||||
| public static function lookup(App $app, $params) { | |||||
| if(App::k($params, 'latitude') !== null && App::k($params, 'longitude') !== null) { | |||||
| $lat = (float)$params['latitude']; | |||||
| $lng = (float)$params['longitude']; | |||||
| return Response::json(self::forLocation($lat, $lng)); | |||||
| } elseif(App::k($params, 'airport')) { | |||||
| $code = $params['airport']; | |||||
| $airport = \p3k\Airports::from_code($code); | |||||
| if($airport) { | |||||
| $result = self::forLocation($airport['latitude'], $airport['longitude']); | |||||
| if(!isset($result['error'])) { | |||||
| $result['airport'] = $airport; | |||||
| } | |||||
| return Response::json($result); | |||||
| } else { | |||||
| return Response::json([ | |||||
| 'error' => 'not_found', | |||||
| 'error_description' => 'The airport code was not found' | |||||
| ]); | |||||
| } | |||||
| } else { | |||||
| return Response::json([ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Request was missing parameters' | |||||
| ], 400); | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,34 @@ | |||||
| <?php | |||||
| namespace p3k\Atlas\Controllers; | |||||
| use p3k\Atlas\App; | |||||
| use p3k\Atlas\Response; | |||||
| class Weather { | |||||
| public static function lookup(App $app, $params) { | |||||
| if(App::k($params, 'latitude') !== null && App::k($params, 'longitude') !== null && App::k($params, 'apikey') !== null) { | |||||
| $lat = (float)$params['latitude']; | |||||
| $lng = (float)$params['longitude']; | |||||
| $key = $params['apikey']; | |||||
| $weather = \p3k\Weather::weather_for_location($lat, $lng, $key); | |||||
| if($weather) { | |||||
| return Response::json($weather); | |||||
| } else { | |||||
| return Response::json([ | |||||
| 'error' => 'not_found', | |||||
| 'error_description' => 'No weather information was found for the requested location, or you used an invalid API key' | |||||
| ]); | |||||
| } | |||||
| } else { | |||||
| return Response::json([ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Request was missing parameters' | |||||
| ], 400); | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,44 @@ | |||||
| <?php | |||||
| namespace p3k\Atlas; | |||||
| /** | |||||
| * A minimal HTTP response: status, headers and a body, sent with send(). | |||||
| */ | |||||
| class Response { | |||||
| public $status; | |||||
| public $headers; | |||||
| public $body; | |||||
| public function __construct($body = '', $status = 200, $headers = []) { | |||||
| $this->body = $body; | |||||
| $this->status = $status; | |||||
| $this->headers = $headers; | |||||
| } | |||||
| public static function html($html, $status = 200) { | |||||
| return new self($html, $status, ['Content-Type' => 'text/html; charset=utf-8']); | |||||
| } | |||||
| public static function json($data, $status = 200) { | |||||
| return new self(json_encode($data), $status, ['Content-Type' => 'application/json']); | |||||
| } | |||||
| public static function image($data, $contentType) { | |||||
| return new self($data, 200, [ | |||||
| 'Content-Type' => $contentType, | |||||
| 'Cache-Control' => 'max-age=' . (60*60*24*30) . ', public', | |||||
| ]); | |||||
| } | |||||
| public static function text($text, $status = 200) { | |||||
| return new self($text, $status, ['Content-Type' => 'text/plain; charset=utf-8']); | |||||
| } | |||||
| public function send() { | |||||
| http_response_code($this->status); | |||||
| foreach($this->headers as $name => $value) { | |||||
| header($name . ': ' . $value); | |||||
| } | |||||
| echo $this->body; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,35 @@ | |||||
| <?php | |||||
| namespace p3k\Atlas; | |||||
| /** | |||||
| * Plain PHP templates: the page is rendered first, then wrapped in layout.php | |||||
| * which receives it as $content. Templates get the variables passed in. | |||||
| */ | |||||
| class View { | |||||
| private $directory; | |||||
| public function __construct($directory) { | |||||
| $this->directory = rtrim($directory, '/'); | |||||
| } | |||||
| public function render($page, $vars = []) { | |||||
| if(!preg_match('/^[a-z0-9_-]+$/i', $page)) { | |||||
| throw new \InvalidArgumentException('Invalid template name'); | |||||
| } | |||||
| $content = $this->fetch($page, $vars); | |||||
| return $this->fetch('layout', ['content' => $content] + $vars); | |||||
| } | |||||
| public function fetch($page, $vars = []) { | |||||
| $file = $this->directory . '/' . $page . '.php'; | |||||
| if(!is_file($file)) { | |||||
| throw new \RuntimeException('Template not found: ' . $page); | |||||
| } | |||||
| ob_start(); | |||||
| (function($__file, $__vars) { | |||||
| extract($__vars, EXTR_SKIP); | |||||
| require $__file; | |||||
| })($file, $vars); | |||||
| return ob_get_clean(); | |||||
| } | |||||
| } | |||||
| @ -1,12 +1,4 @@ | |||||
| <?php | <?php | ||||
| chdir('..'); | |||||
| require 'vendor/autoload.php'; | |||||
| require __DIR__ . '/../vendor/autoload.php'; | |||||
| \Slim\Savant\init(); | |||||
| require 'controllers/main.php'; | |||||
| require 'controllers/timezone.php'; | |||||
| require 'controllers/geocode.php'; | |||||
| require 'controllers/weather.php'; | |||||
| $app->run(); | |||||
| (new p3k\Atlas\App(dirname(__DIR__)))->run(); | |||||
| @ -0,0 +1,4 @@ | |||||
| <header> | |||||
| <h1>Atlas Static Maps</h1> | |||||
| <p>See the <a href="/static-maps">Static Maps API docs</a>.</p> | |||||
| </header> | |||||