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 | |||
| 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> | |||