* @copyright Copyright (c) 2022, Dale Davies * @license MIT */ namespace Jump; use \divineomega\array_undot; use \Jump\Exceptions\ConfigException; use \Jump\Exceptions\SiteNotFoundException; use \Jump\Exceptions\TagNotFoundException; /** * Loads, validates and caches the site data defined in sites.json * into an array of Site objects. */ class Sites { private array $default; private string $sitesfilelocation; private array $loadedsites; public array $tags; /** * Automatically load sites.json on instantiation. */ public function __construct(private Config $config, private Cache $cache) { $this->config = $config; $this->loadedsites = []; $this->sitesfilelocation = $this->config->get('sitesfile'); $this->cache = $cache; $this->default = [ 'icon' => null, 'nofollow' => false, 'newtab' => false, ]; // Retrieve sites from cache. Load all sites from json file and docker if not // cached or the cache has expired. $this->loadedsites = $this->cache->load(cachename: 'sites', callback: function() { // Load json file first to set defaults. return array_merge($this->load_sites_from_json(), $this->load_sites_from_docker()); }); // Enumerate a list of unique tags from loaded sites. Again will retrieve from // cache if available. $this->tags = $this->cache->load(cachename: 'tags', callback: function() { $uniquetags = []; foreach (array_column($this->get_sites(), 'tags') as $tags) { foreach ($tags as $tag) { $uniquetags[] = $tag; } } return array_values(array_unique($uniquetags)); }); } /** * Try to find a list of sites from correctly labelled docker containers. * * Throws an exception if the json response from docker cannot be * decoded. * * @return array Array of Site objects sites identified from docker. * @throws ConfigException If invalid response from docker. */ private function load_sites_from_docker(): array { // Get either dockerproxy or dockersocket config and return early if // neihter have been set. $dockerproxy = $this->config->get('dockerproxyurl'); $dockersocket = $this->config->get('dockersocket'); if (!$dockerproxy && !$dockersocket) { return []; } // Determine correct guzzle client and request options to use // for either a docker proxy or connecting directly to the socket, // prefer to use the proxy if both seem to have been given. $clientopts = ['timeout' => 2.0]; $requestopts = []; if ($dockerproxy) { $clientopts['base_uri'] = 'http://'.rtrim($dockerproxy, '/'); } else if (file_exists($dockersocket)) { $clientopts['base_uri'] = 'http://localhost'; $requestopts = [ 'curl' => [CURLOPT_UNIX_SOCKET_PATH => '/var/run/docker.sock'] ]; } // Make a request to docker for all containers. try { $response = (new \GuzzleHttp\Client($clientopts))->request('GET', '/containers/json', $requestopts); } catch (\GuzzleHttp\Exception\ConnectException $e) { throw new ConfigException('Did not get a response from Docker API endpoint'); } $containers = json_decode($response->getBody()); if (is_null($containers)) { throw new ConfigException('Docker returned an invalid json response for containers'); } // Build a new array of Site() objects based on labels that have been added to // containers returned by docker. $sites = []; foreach ($containers as $container) { $labels = (array) $container->Labels; // We can't build a Site() without at least a name and url. if (!isset($labels['jump.name'], $labels['jump.url'])) { continue; } // Convert dot-syntax labels into a proper multidimensional array // and just use the top-level key "jump" as our site array. $site = array_undot($labels)['jump']; // jump.tags will have been given as a comma separated string so make this // into an array. if (isset($site['tags'])) { // Explode the comma separated string into an array and trim any elements. $site['tags'] = array_map('trim', explode(',', $site['tags'])); } // Convert status array to an object and also explode list of allowed status codes to array. if (isset($site['status'])) { $site['status'] = (object) $site['status']; if (isset($site['status']->allowed_status_codes)) { $site['status']->allowed_status_codes = array_map('trim', explode(',', $site['status']->allowed_status_codes)); } } // Finally add this to the list of sites we will return. $sites[] = new Site($this->config, $this->cache, (array) $site, $this->default); } return $sites; } /** * Try to load the list of sites from sites.json. Sets defaults if any * are found, which will then apply to any docker sites. * * Throws an exception if the file cannot be loaded, is empty, or cannot * be decoded to an array, * * @return array Array of Site objects sites loaded from sites.json * @throws ConfigException If sites.json cannot be found. */ private function load_sites_from_json(): array { $docker = function() { $dockerproxy = $this->config->get('dockerproxyurl'); $dockersocket = $this->config->get('dockersocket'); if ($dockerproxy || $dockersocket) { return true; } return false; }; $allsites = []; // If we have been instructed to only look for sites via docker then // don't worry about loading a local sites.json file and just // return an empty $allsites array. if ($this->config->get('dockeronlysites')) { if (!$docker()) { throw new ConfigException('DOCKERONLYSITES is specified but no Docker endpoint has been provided'); } return $allsites; } // Try to load the sites.json file. $rawjson = @file_get_contents($this->sitesfilelocation); if ($rawjson === false) { throw new ConfigException('There was a problem loading the sites.json file'); } if ($rawjson === '') { throw new ConfigException('The sites.json file is empty, if this is intentional please delete it'); } // Do some checks to see if the JSON decodes into something // like what we expect to see... $decodedjson = json_decode($rawjson); // First we'll assume maybe the old format for sites.json. if (is_array($decodedjson)) { $allsites = $decodedjson; } // Handle not having any sites in sites.json or having docker integration set up. if (!isset($decodedjson->sites)) { if (!$docker()) { throw new ConfigException('The sites.json file is empty and docker integration is not set up either'); } } // Now check for the newer sites format from sites.json. if (is_array($decodedjson->sites)) { $allsites = $decodedjson->sites; } // Extract default site params into an array. if (isset($decodedjson->default)) { $this->default = (array) $decodedjson->default; } // Instantiate an actual Site() object for each element. foreach ($allsites as $key => $item) { $allsites[$key] = new Site($this->config, $this->cache, (array) $item, $this->default); } // Return the array of Site() objects, note we are in a callback // so the return is not from the outer function. return $allsites; } /** * Returns an array of all loaded Site objects. * * @return array Array of all loaded Site objects. */ public function get_sites(): array { return $this->loadedsites; } /** * Return array of tags sorted alphabetically, minus the home tag. * * @return array Array of tag names. */ public function get_tags_for_template(): array { $template_tags = []; foreach ($this->tags as $tag) { if ($tag === 'home') { continue; } $template_tags[] = $tag; } sort($template_tags); return $template_tags; } /** * Given a URL, does that site exist in our list of sites? * * @param string $url The URL to search for. * @return Site A matching Site object if found. * @throws SiteNotFoundException If a site with given URL does not exist. */ public function get_site_by_url(string $url): Site { $found = array_search($url, array_column($this->get_sites(), 'url')); if ($found === false) { throw new SiteNotFoundException($url); } return $this->loadedsites[$found]; } /** * Given a Site ID, does that site exist in our list of sites? * * @param string $id The Site ID to search for. * @return Site A matching Site object if found. * @throws SiteNotFoundException If a site with given Site ID does not exist. */ public function get_site_by_id(string $id): Site { $found = array_search($id, array_column($this->get_sites(), 'id')); if ($found === false) { throw new SiteNotFoundException($id); } return $this->loadedsites[$found]; } /** * Returns an array of Site objects with a given tag. * * @param string $tagname The tag to look look up sites. * @return array Array of Site objects with the given tag. * @throws TagNotFoundException If there are no sites tagged with $tagname. */ public function get_sites_by_tag(string $tagname): array { if (!in_array($tagname, $this->tags)) { throw new TagNotFoundException($tagname); } $found = []; foreach ($this->get_sites() as $site) { if (in_array($tagname, $site->tags)) { $found[] = $site; } } return $found; } /** * Get a list of cached sites from for use in the front end via JS. Some extra details * added to each site in the list may not be already saved in the main sites cache. * * @return array Array of stdClass objects containing required site details. */ public function get_sites_for_frontend(): array { $searchlist = []; foreach ($this->loadedsites as $loadedsite) { $site = new \stdClass(); $site->id = $loadedsite->id; $site->name = $loadedsite->name; $site->url = $loadedsite->url; $site->tags = $loadedsite->tags; $site->iconurl = '/api/icon?siteid='.$loadedsite->id; $site->status = $this->cache->load(cachename: 'sites/status', key: $site->url) ?? null; $searchlist[] = $site; } return $searchlist; } }