From b1311e336cc9c8fe3599726022c9d5f0fbb57be9 Mon Sep 17 00:00:00 2001 From: flashwave Date: Sun, 3 Jul 2022 22:41:44 +0000 Subject: [PATCH] Initial import --- .gitattributes | 1 + .gitignore | 2 + lib/database/database.php | 49 +++ lib/database/databasestatement.php | 67 ++++ lib/db.php | 24 ++ lib/mediatype.php | 107 ++++++ lib/stopwatch.php | 55 +++ lib/uri.php | 182 +++++++++ public/index.php | 585 +++++++++++++++++++++++++++++ 9 files changed, 1072 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 lib/database/database.php create mode 100644 lib/database/databasestatement.php create mode 100644 lib/db.php create mode 100644 lib/mediatype.php create mode 100644 lib/stopwatch.php create mode 100644 lib/uri.php create mode 100644 public/index.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1193f95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.debug +/config.php diff --git a/lib/database/database.php b/lib/database/database.php new file mode 100644 index 0000000..9ddbdf8 --- /dev/null +++ b/lib/database/database.php @@ -0,0 +1,49 @@ +pdo = new PDO($dsn, $username, $password, $options); + } + + public function getPDO(): PDO { + return $this->pdo; + } + + public function queries(): int { + return (int)$this->query('SHOW SESSION STATUS LIKE "Questions"')->fetchColumn(1); + } + + public function exec(string $stmt): int { + return $this->pdo->exec($stmt); + } + + public function prepare(string $stmt, array $options = []): DatabaseStatement { + $encodedOptions = serialize($options); + + if(empty($this->stmts[$stmt][$encodedOptions])) { + $this->stmts[$stmt][$encodedOptions] = $this->pdo->prepare($stmt, $options); + } + + return new DatabaseStatement($this->stmts[$stmt][$encodedOptions], $this->pdo, false); + } + + public function query(string $stmt, ?int $fetchMode = null, ...$args): DatabaseStatement { + if($fetchMode === null) { + $pdoStmt = $this->pdo->query($stmt); + } else { + $pdoStmt = $this->pdo->query($stmt, $fetchMode, ...$args); + } + + return new DatabaseStatement($pdoStmt, $this->pdo, true); + } + + public function lastId(): int { + return $this->pdo->lastInsertId(); + } +} diff --git a/lib/database/databasestatement.php b/lib/database/databasestatement.php new file mode 100644 index 0000000..5d1fd24 --- /dev/null +++ b/lib/database/databasestatement.php @@ -0,0 +1,67 @@ +stmt = $stmt; + $this->pdo = $pdo; + $this->isQuery = $isQuery; + } + + public function bind($param, $value, int $dataType = PDO::PARAM_STR): DatabaseStatement { + $this->stmt->bindValue($param, $value, $dataType); + return $this; + } + + public function execute(array $params = []): bool { + return count($params) ? $this->stmt->execute($params) : $this->stmt->execute(); + } + + public function executeGetId(array $params = []): int { + return $this->execute($params) ? $this->pdo->lastInsertId() : 0; + } + + public function fetch($default = []) { + $out = $this->isQuery || $this->execute() ? $this->stmt->fetch(PDO::FETCH_ASSOC) : false; + return $out ? $out : $default; + } + + public function fetchAll($default = []) { + $out = $this->isQuery || $this->execute() ? $this->stmt->fetchAll(PDO::FETCH_ASSOC) : false; + return $out ? $out : $default; + } + + public function fetchColumn(int $num = 0, $default = null) { + $out = $this->isQuery || $this->execute() ? $this->stmt->fetchColumn($num) : false; + return $out ? $out : $default; + } + + public function fetchObject(string $className = 'stdClass', ?array $args = null, $default = null) { + $out = false; + + if($this->isQuery || $this->execute()) { + $out = $args === null ? $this->stmt->fetchObject($className) : $this->stmt->fetchObject($className, $args); + } + + return $out !== false ? $out : $default; + } + + public function fetchObjects(string $className = 'stdClass', ?array $args = null): array { + $objects = []; + + if($this->isQuery || $this->execute()) { + while(($object = ($args === null ? $this->stmt->fetchObject($className) : $this->stmt->fetchObject($className, $args))) !== false) { + $objects[] = $object; + } + } + + return $objects; + } +} diff --git a/lib/db.php b/lib/db.php new file mode 100644 index 0000000..3104ff1 --- /dev/null +++ b/lib/db.php @@ -0,0 +1,24 @@ + PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET SESSION time_zone = \'+00:00\'' + . ', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\'', + ]; + + public static function init(...$args) { + self::$instance = new Database(...$args); + } + + public static function __callStatic(string $name, array $args) { + return self::$instance->{$name}(...$args); + } +} diff --git a/lib/mediatype.php b/lib/mediatype.php new file mode 100644 index 0000000..3363f3a --- /dev/null +++ b/lib/mediatype.php @@ -0,0 +1,107 @@ +type = $matches[1]; + + $subTypeSplit = explode('+', $matches[2], 2); + $this->subtype = $subTypeSplit[0]; + if(isset($subTypeSplit[1])) + $this->suffix = $subTypeSplit[1]; + + if(isset($matches[3])) { + $params = explode(';', $matches[3]); + foreach($params as $param) { + $parts = explode('=', trim($param), 2); + if(!isset($parts[1])) + continue; + $this->params[$parts[0]] = $parts[1]; + } + } + } + + public function getType(): string { + return $this->type; + } + + public function getSubtype(): string { + return $this->subtype; + } + + public function getSuffix(): string { + return $this->subtype; + } + + public function getParams(): array { + return $this->params; + } + public function getParam(string $name, int $filter = FILTER_DEFAULT, $options = null) { + if(!isset($this->params[$name])) + return null; + return filter_var($this->params[$name], $filter, $options); + } + + public function getCharset(): string { + return $this->getParam('charset', FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH) ?? 'utf-8'; + } + public function getQuality(): float { + return max(min(round($this->getParam('q', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION) ?? 1, 2), 1), 0); + } + + public function match($other): bool { + if(is_string($other)) + return $this->matchPattern($other); + if($other instanceof self) + return $this->matchType($other); + return false; + } + public function matchPattern(string $pattern): bool { + try { + $mediaType = new static($pattern); + } catch(InvalidArgumentException $ex) { + return false; + } + return $this->matchType($mediaType); + } + public function matchType(MediaType $other): bool { + return ($other->getType() === '*' && $other->getSubtype() === '*') + || ( + ($other->getType() === $this->getType()) + && ($other->getSubtype() === '*' || $other->getSubtype() === $this->getSubtype()) + ); + } + + public function __toString() { + $string = $this->type . '/'; + $string .= $this->subtype; + if(!empty($this->suffix)) + $string .= '+' . $this->suffix; + if(!empty($this->params)) + foreach($this->params as $key => $value) { + $string .= ';'; + if(!empty($key)) + $string .= $key . '='; + $string .= $value; + } + return $string; + } + + public function jsonSerialize(): stdClass { + $obj = new stdClass; + $obj->string = (string)$this; + $obj->type = $this->type; + $obj->subtype = $this->subtype; + if(!empty($this->suffix)) + $obj->suffix = $this->suffix; + if(!empty($this->params)) + $obj->params = $this->params; + return $obj; + } +} diff --git a/lib/stopwatch.php b/lib/stopwatch.php new file mode 100644 index 0000000..a2a2868 --- /dev/null +++ b/lib/stopwatch.php @@ -0,0 +1,55 @@ +{'_' . $name}(...$args); + } + + public static function __callStatic(string $name, array $args) { + if($name[0] === '_') + return null; + if(self::$instance === null) + self::$instance = new static; + return self::$instance->{'_' . $name}(...$args); + } + + private static function time() { + return microtime(true); + } + + public function _start(): void { + $this->startTime = self::time(); + } + + public function _lap(string $text): void { + $this->laps[$text] = self::time(); + } + + public function _stop(): void { + $this->stopTime = self::time(); + } + + public function _reset(): void { + $this->laps = []; + $this->startTime = 0; + $this->stopTime = 0; + } + + public function _elapsed(): float { + return $this->stopTime - $this->startTime; + } + + public function _laps(): array { + $laps = []; + foreach($this->laps as $name => $time) + $laps[$name] = $time - $this->startTime; + return $laps; + } +} diff --git a/lib/uri.php b/lib/uri.php new file mode 100644 index 0000000..9bb0146 --- /dev/null +++ b/lib/uri.php @@ -0,0 +1,182 @@ +originalString = $uriString; + + if(!empty($uriString)) { + $uri = parse_url($uriString); + + if($uri === false) + throw new InvalidArgumentException('URI cannot be parsed.'); + + $this->setScheme($uri['scheme'] ?? ''); + $this->setUserInfo($uri['user'] ?? '', $uri['pass'] ?? null); + $this->setHost($uri['host'] ?? ''); + $this->setPort($uri['port'] ?? null); + $this->setPath($uri['path'] ?? ''); + $this->setQuery($uri['query'] ?? ''); + $this->setFragment($uri['fragment'] ?? ''); + } + } + + public function getHash(): string { + return hash('sha256', (string)$this); + } + + public function jsonSerialize(): stdClass { + $obj = new stdClass; + $obj->uri = (string)$this; + if($this->scheme !== '') + $obj->scheme = $this->scheme; + if($this->user !== '') + $obj->user = $this->user; + if($this->password !== null) + $obj->password = $this->password; + if($this->host !== '') + $obj->host = $this->host; + if($this->port !== null) + $obj->port = $this->port; + if($this->path !== '') + $obj->path = $this->path; + if($this->query !== '') + $obj->query = $this->query; + if($this->fragment !== '') + $obj->fragment = $this->fragment; + //$obj->hash = $this->getHash(); + return $obj; + } + + public function getOriginalString(): string { + return $this->originalString; + } + + public function getScheme() { + return $this->scheme; + } + public function setScheme(string $scheme): self { + $this->scheme = $scheme; + return $this; + } + + public function getAuthority() { + $authority = ''; + + if(!empty($userInfo = $this->getUserInfo())) + $authority .= $userInfo . '@'; + + $authority .= $this->getHost(); + + if(($port = $this->getPort()) !== null) + $authority .= ':' . $port; + + return $authority; + } + + public function getUserInfo() { + $userInfo = $this->user; + + if(!empty($this->password)) + $userInfo .= ':' . $this->password; + + return $userInfo; + } + public function setUserInfo(string $user, ?string $password = null): self { + $this->user = $user; + $this->password = $password; + return $this; + } + + public function getHost() { + return $this->host; + } + public function setHost(string $host): self { + $this->host = $host; + return $this; + } + + public function getPort() { + return $this->port; + } + public function setPort(?int $port): self { + if($port !== null && ($port < 1 || $port > 0xFFFF)) + throw new InvalidArgumentException('Invalid port.'); + + $this->port = $port; + return $this; + } + + public function getPath() { + return $this->path; + } + public function setPath(string $path): self { + $this->path = $path; + return $this; + } + + public function getQuery() { + return $this->query; + } + public function setQuery(string $query): self { + $this->query = $query; + return $this; + } + + public function getFragment() { + return $this->fragment; + } + public function setFragment(string $fragment): self { + $this->fragment = $fragment; + return $this; + } + + public function __toString() { + $string = ''; + + if(!empty($scheme = $this->getScheme())) + $string .= $scheme . ':'; + + $authority = $this->getAuthority(); + $hasAuthority = !empty($authority); + + if($hasAuthority) + $string .= '//' . $authority; + + $path = $this->getPath(); + $hasPath = !empty($path); + + if($hasAuthority && (!$hasPath || $path[0] !== '/')) + $string .= '/'; + elseif(!$hasAuthority && $path[1] === '/') + $path = '/' . trim($path, '/'); + + $string .= $path; + + if(!empty($query = $this->getQuery())) { + $string .= '?'; + $queryParts = explode('&', $query); + foreach($queryParts as $queryPart) { + $kvp = explode('=', $queryPart, 2); + $string .= rawurlencode($kvp[0]); + if(isset($kvp[1])) + $string .= '=' . rawurlencode($kvp[1]); + $string .= '&'; + } + $string = substr($string, 0, -1); + } + + if(!empty($fragment = $this->getFragment())) + $string .= '#' . rawurlencode($fragment); + + return $string; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..30ae2ad --- /dev/null +++ b/public/index.php @@ -0,0 +1,585 @@ +type = 'eeprom:file'; + $resp->color = '#8559a5'; + $resp->eeprom_file_id = $eepromFileId; + $curl = curl_init("https://eeprom.{$domain}.net/uploads/{$resp->eeprom_file_id}.json"); + curl_setopt_array($curl, [ + CURLOPT_AUTOREFERER => false, + CURLOPT_CERTINFO => false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + ], + ]); + $eepromResp = curl_exec($curl); + curl_close($curl); + $resp->eeprom_file_info = json_decode($eepromResp); + if(isset($resp->eeprom_file_info->name)) + $resp->title = $resp->eeprom_file_info->name; + if(isset($resp->eeprom_file_info->thumb)) + $resp->image = $resp->eeprom_file_info->thumb; + $resp->site_name = 'Flashii EEPROM'; +} + +if(!is_dir(UIH_SEM_PATH)) + mkdir(UIH_SEM_PATH, 0777, true); + +require_once __DIR__ . '/../config.php'; + +header('X-Powered-By: Uiharu'); + +ini_set('display_errors', UIH_DEBUG ? 'on' : 'off'); +error_reporting(UIH_DEBUG ? -1 : 0); + +set_include_path(realpath(__DIR__ . '/../lib/') . PATH_SEPARATOR . get_include_path()); +spl_autoload_extensions('.php'); +spl_autoload_register(); + +DB::init(UIH_PDO_DSN, UIH_PDO_USER, UIH_PDO_PASS, DB::ATTRS); +DB::exec('DELETE FROM `uih_metadata_cache` WHERE `metadata_created` < NOW() - INTERVAL 7 DAY'); + +$reqMethod = filter_input(INPUT_SERVER, 'REQUEST_METHOD', FILTER_SANITIZE_STRING); +$reqPath = '/' . trim(parse_url(filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_STRING), PHP_URL_PATH), '/'); +$reqHead = false; + +if($reqMethod == 'HEAD') { + $reqMethod = 'GET'; + $reqHead = true; +} + +if(!empty($_SERVER['HTTP_ORIGIN'])) { + $originLast12 = substr($_SERVER['HTTP_ORIGIN'], -12, 12); + $originLast10 = substr($_SERVER['HTTP_ORIGIN'], -10, 10); + if($originLast12 !== '/flashii.net' && $originLast12 !== '.flashii.net' + && $originLast10 !== '/edgii.net' && $originLast10 !== '.edgii.net' + && $_SERVER['HTTP_ORIGIN'] !== 'https://flashii.net' + && $_SERVER['HTTP_ORIGIN'] !== 'http://flashii.net' + && $_SERVER['HTTP_ORIGIN'] !== 'https://edgii.net' + && $_SERVER['HTTP_ORIGIN'] !== 'http://edgii.net') { + http_response_code(403); + return; + } + + header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']); + header('Vary: Origin'); +} + +if($reqMethod === 'OPTIONS') { + http_response_code(204); + //header('Access-Control-Allow-Credentials: true'); + //header('Access-Control-Allow-Headers: Authorization'); + header('Access-Control-Allow-Methods: OPTIONS, GET, POST'); + return; +} + +if($reqPath === '/metadata') { + // Allow using POST for ridiculous urls. + if($reqMethod !== 'GET' && $reqMethod !== 'POST') { + http_response_code(405); + return; + } + + header('Content-Type: application/json; charset=utf-8'); + if($reqHead) + return; + + Stopwatch::start(); + $resp = new stdClass; + + if($reqMethod === 'POST') { + $targetUrl = substr((string)file_get_contents('php://input'), 0, 1000); + } else { + $targetUrl = (string)filter_input(INPUT_GET, 'url'); + } + + try { + $resp->uri = $parsedUrl = new Uri($targetUrl); + } catch(InvalidArgumentException $ex) { + http_response_code(400); + $resp->error = 'metadata:uri'; + echo json_encode($resp); + return; + } + + // if no scheme is specified, try https + if($parsedUrl->getScheme() === '') + $parsedUrl = new Uri('https://' . (string)$parsedUrl); + + $urlHash = $parsedUrl->getHash(); + + try { + $semPath = UIH_SEM_PATH . DIRECTORY_SEPARATOR . $urlHash; + if(!is_file($semPath)) + touch($semPath); + $ftok = ftok($semPath, UIH_SEM_NAME); + $semaphore = sem_get($ftok, 1); + while(!sem_acquire($semaphore)) usleep(100); + + if(UIH_CACHE) { + $loadCache = DB::prepare('SELECT `metadata_resp` AS `resp` FROM `uih_metadata_cache` WHERE `metadata_url` = UNHEX(:hash) AND `metadata_created` > NOW() - INTERVAL 10 MINUTE') + ->bind('hash', $urlHash) + ->fetchObject(); + if(isset($loadCache->resp)) { + $cacheResp = json_decode($loadCache->resp); + if($cacheResp !== null) + $resp = $cacheResp; + } + } + + if(empty($resp->type)) { + $urlScheme = strtolower($parsedUrl->getScheme()); + $urlHost = strtolower($parsedUrl->getHost()); + $urlPath = '/' . trim($parsedUrl->getPath(), '/'); + + if($urlScheme === 'eeprom') { + if(preg_match('#^([A-Za-z0-9-_]+)$#', $parsedUrl->getPath(), $matches)) { + $resp->uri = $parsedUrl = new Uri('https://i.fii.moe/' . $matches[1]); + $continueRaw = true; + uih_eeprom_lookup($resp, $matches[1]); + } + } elseif($urlScheme === 'devrom') { + if(preg_match('#^([A-Za-z0-9-_]+)$#', $parsedUrl->getPath(), $matches)) { + $resp->uri = $parsedUrl = new Uri('https://i.edgii.net/' . $matches[1]); + $continueRaw = true; + uih_eeprom_lookup($resp, $matches[1], 'edgii'); + } + } elseif($urlScheme === 'http' || $urlScheme === 'https') { + switch($urlHost) { + case 'i.flashii.net': + case 'i.fii.moe': + $eepromFileId = substr($urlPath, 1); + case 'eeprom.flashii.net': + if(!isset($eepromFileId) && preg_match('#^/uploads/([A-Za-z0-9-_]+)/?$#', $urlPath, $matches)) + $eepromFileId = $matches[1]; + + if(!empty($eepromFileId)) { + $continueRaw = true; + uih_eeprom_lookup($resp, $eepromFileId); + } + break; + + case 'i.edgii.net': + $eepromFileId = substr($urlPath, 1); + case 'eeprom.edgii.net': + if(!isset($eepromFileId) && preg_match('#^/uploads/([A-Za-z0-9-_]+)/?$#', $urlPath, $matches)) + $eepromFileId = $matches[1]; + + if(!empty($eepromFileId)) { + $continueRaw = true; + uih_eeprom_lookup($resp, $eepromFileId, 'edgii'); + } + break; + + case 'twitter.com': case 'www.twitter.com': + case 'm.twitter.com': case 'mobile.twitter.com': + case 'nitter.net': case 'www.nitter.net': + if(preg_match('#^/@?(?:[A-Za-z0-9_]{1,20})/status(?:es)?/([0-9]+)/?$#', $urlPath, $matches)) { + $resp->type = 'twitter:tweet'; + $resp->color = '#1da1f2'; + $resp->tweet_id = strval($matches[1] ?? '0'); + $curl = curl_init("https://api.twitter.com/2/tweets?ids={$resp->tweet_id}&expansions=attachments.media_keys,author_id,entities.mentions.username,referenced_tweets.id,referenced_tweets.id.author_id&media.fields=height,width,media_key,preview_image_url,url,type&tweet.fields=attachments,conversation_id,text,source,possibly_sensitive,created_at&user.fields=id,name,profile_image_url,protected,username,verified"); + curl_setopt_array($curl, [ + CURLOPT_AUTOREFERER => false, + CURLOPT_CERTINFO => false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . TWITTER_API_TOKEN, + 'Accept: application/json', + ], + ]); + $tweetResp = curl_exec($curl); + curl_close($curl); + $resp->tweet_info = json_decode($tweetResp); + if(isset($resp->tweet_info->includes->users[0]->name)) + $resp->title = $resp->tweet_info->includes->users[0]->name; + if(isset($resp->tweet_info->includes->users[0]->profile_image_url)) + $resp->image = $resp->tweet_info->includes->users[0]->profile_image_url; + if(isset($resp->tweet_info->data[0]->text)) + $resp->description = $resp->tweet_info->data[0]->text; + $resp->site_name = 'Twitter'; + break; + } + + if(preg_match('#^/@?([A-Za-z0-9_]{1,20})/?$#', $urlPath, $matches)) { + $resp->type = 'twitter:user'; + $resp->color = '#1da1f2'; + $resp->twitter_user_name = strval($matches[1] ?? ''); + $curl = curl_init("https://api.twitter.com/2/users/by?usernames={$resp->twitter_user_name}&user.fields=description,entities,id,name,profile_image_url,protected,url,username,verified"); + curl_setopt_array($curl, [ + CURLOPT_AUTOREFERER => false, + CURLOPT_CERTINFO => false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . TWITTER_API_TOKEN, + 'Accept: application/json', + ], + ]); + $twitUserResp = curl_exec($curl); + curl_close($curl); + $resp->twitter_user_info = json_decode($twitUserResp); + if(isset($resp->twitter_user_info->data[0]->name)) + $resp->title = $resp->twitter_user_info->data[0]->name; + if(isset($resp->twitter_user_info->data[0]->profile_image_url)) + $resp->image = $resp->twitter_user_info->data[0]->profile_image_url; + if(isset($resp->twitter_user_info->data[0]->description)) + $resp->description = $resp->twitter_user_info->data[0]->description; + $resp->site_name = 'Twitter'; + break; + } + break; + + case 'youtu.be': case 'www.youtu.be': // www. doesn't work for this, but may as well cover it + $youtubeVideoId = substr($urlPath, 1); + case 'youtube.com': case 'www.youtube.com': + case 'youtube-nocookie.com': case 'www.youtube-nocookie.com': + parse_str($parsedUrl->getQuery(), $queryString); + + if(!isset($youtubeVideoId) && $urlPath === '/watch') + $youtubeVideoId = $queryString['v'] ?? null; + + if(!empty($youtubeVideoId)) { + $resp->type = 'youtube:video'; + $resp->color = '#f00'; + $resp->youtube_video_id = $youtubeVideoId; + + if(isset($queryString['t'])) + $resp->youtube_start_time = $queryString['t']; + if(isset($queryString['list'])) + $resp->youtube_playlist = $queryString['list']; + if(isset($queryString['index'])) + $resp->youtube_playlist_index = $queryString['index']; + + $curl = curl_init("https://www.googleapis.com/youtube/v3/videos?part=snippet%2CcontentDetails%2Cstatistics&id={$resp->youtube_video_id}&key=" . GOOGLE_API_KEY); + curl_setopt_array($curl, [ + CURLOPT_AUTOREFERER => false, + CURLOPT_CERTINFO => false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + ], + ]); + $youtubeResp = curl_exec($curl); + curl_close($curl); + $resp->youtube_video_info = json_decode($youtubeResp); + if(isset($resp->youtube_video_info->items[0]->snippet->title)) + $resp->title = $resp->youtube_video_info->items[0]->snippet->title; + if(isset($resp->youtube_video_info->items[0]->snippet->thumbnails->medium->url)) + $resp->image = $resp->youtube_video_info->items[0]->snippet->thumbnails->medium->url; + if(isset($resp->youtube_video_info->items[0]->snippet->description)) + $resp->description = $resp->youtube_video_info->items[0]->snippet->description; + $resp->site_name = 'YouTube'; + } + break; + } + } else { + http_response_code(404); + $resp->error = 'metadata:scheme'; + } + + if((empty($resp->type) || isset($continueRaw)) && in_array($parsedUrl->getScheme(), ['http', 'https'])) { + $curl = curl_init((string)$parsedUrl); + curl_setopt_array($curl, [ + CURLOPT_AUTOREFERER => true, + CURLOPT_CERTINFO => false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_PATH_AS_IS => true, + CURLOPT_NOBODY => true, + CURLOPT_HEADER => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_DEFAULT_PROTOCOL => 'https', + CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible) Uiharu/' . UIH_VERSION, + CURLOPT_HTTPHEADER => [ + 'Accept: text/html,application/xhtml+xml', + ], + ]); + $headers = curl_exec($curl); + + if($headers === false) { + $resp->error = 'metadata:timeout'; + $resp->errorMessage = curl_error($curl); + } else { + $headersRaw = explode("\r\n", trim($headers)); + $statusCode = 200; + $headers = []; + foreach($headersRaw as $header) { + if(empty($header)) + continue; + if(strpos($header, ':') === false) { + $headParts = explode(' ', $header); + if(isset($headParts[1]) && is_numeric($headParts[1])) + $statusCode = (int)$headParts[1]; + $headers = []; + continue; + } + $headerParts = explode(':', $header, 2); + $headerParts[0] = mb_strtolower($headerParts[0]); + if(isset($headers[$headerParts[0]])) + $headers[$headerParts[0]] .= ', ' . trim($headerParts[1] ?? ''); + else + $headers[$headerParts[0]] = trim($headerParts[1] ?? ''); + } + + try { + $contentType = new MediaType($headers['content-type'] ?? ''); + } catch(InvalidArgumentException $ex) { + $contentType = new MediaType('application/octet-stream'); + } + + $resp->content_type = $contentType; + + $isHTML = $contentType->match('text/html'); + $isXHTML = $contentType->match('application/xhtml+xml'); + + if($isHTML || $isXHTML) { + curl_setopt_array($curl, [ + CURLOPT_NOBODY => false, + CURLOPT_HEADER => false, + ]); + + $body = curl_exec($curl); + curl_close($curl); + + $document = new DOMDocument; + if($isXHTML) { + $document->loadXML($body); + } else { + @$document->loadHTML('getCharset() . '">' . $body); + foreach($document->childNodes as $child) + if($child->nodeType === XML_PI_NODE) { + $document->removeChild($child); + break; + } + $document->encoding = $contentType->getCharset(); + } + + $charSet = $document->encoding; + + $resp->type = 'website'; + $resp->title = ''; + + $isMetaTitle = false; + $titleTag = $document->getElementsByTagName('title'); + foreach($titleTag as $tag) { + $resp->title = trim(mb_convert_encoding($tag->textContent, 'utf-8', $charSet)); + break; + } + + $metaTags = $document->getElementsByTagName('meta'); + foreach($metaTags as $tag) { + $nameAttr = $tag->hasAttribute('name') ? $tag->getAttribute('name') : ( + $tag->hasAttribute('property') ? $tag->getAttribute('property') : '' + ); + $valueAttr = $tag->hasAttribute('value') ? $tag->getAttribute('value') : ( + $tag->hasAttribute('content') ? $tag->getAttribute('content') : '' + ); + $nameAttr = trim(mb_convert_encoding($nameAttr, 'utf-8', $charSet)); + $valueAttr = trim(mb_convert_encoding($valueAttr, 'utf-8', $charSet)); + if(empty($nameAttr) || empty($valueAttr)) + continue; + + switch($nameAttr) { + case 'og:title': + case 'twitter:title': + if(!$isMetaTitle) { + $isMetaTitle = true; + $resp->title = $valueAttr; + } + break; + + case 'description': + case 'og:description': + case 'twitter:description': + if(!isset($resp->description)) + $resp->description = $valueAttr; + break; + + case 'og:site_name': + $resp->site_name = $valueAttr; + break; + + case 'og:image': + case 'twitter:image': + $resp->image = $valueAttr; + break; + + case 'theme-color': + $resp->color = $valueAttr; + break; + + case 'og:type': + $resp->type = $valueAttr; + break; + } + } + } else { + $resp->is_image = $isImage = $contentType->match('image/*'); + $resp->is_audio = $isAudio = $contentType->match('audio/*'); + $resp->is_video = $isVideo = $contentType->match('video/*'); + + if($isImage || $isAudio || $isVideo) { + curl_close($curl); + $resp->media = new stdClass; + $ffmpeg = json_decode(shell_exec(sprintf('ffprobe -show_streams -show_format -print_format json -v quiet -i %s', escapeshellarg((string)$parsedUrl)))); + + if(!empty($ffmpeg)) { + if(!empty($ffmpeg->format)) { + $resp->media->confidence = empty($ffmpeg->format->probe_score) ? 0 : (intval($ffmpeg->format->probe_score) / 100); + if(!empty($ffmpeg->format->duration)) + $resp->media->duration = floatval($ffmpeg->format->duration); + if(!empty($ffmpeg->format->size)) + $resp->media->size = intval($ffmpeg->format->size); + if(!empty($ffmpeg->format->bit_rate)) + $resp->media->bitrate = intval($ffmpeg->format->bit_rate); + + if($isVideo || $isImage) { + if(!empty($ffmpeg->streams)) { + foreach($ffmpeg->streams as $stream) { + if(($stream->codec_type ?? null) !== 'video') + continue; + + $resp->width = intval($stream->coded_width ?? $stream->width ?? -1); + $resp->height = intval($stream->coded_height ?? $stream->height ?? -1); + + if(!empty($stream->display_aspect_ratio)) + $resp->media->aspect_ratio = $stream->display_aspect_ratio; + + if($isImage) + break; + } + } + } + + if($isAudio) { + function eat_tags(stdClass $dest, stdClass $source): void { + if(!empty($source->title) || !empty($source->TITLE)) + $dest->title = $source->title ?? $source->TITLE; + if(!empty($source->artist) || !empty($source->ARTIST)) + $dest->artist = $source->artist ?? $source->ARTIST; + if(!empty($source->album) || !empty($source->ALBUM)) + $dest->album = $source->album ?? $source->ALBUM; + if(!empty($source->date) || !empty($source->DATE)) + $dest->date = $source->date ?? $source->DATE; + if(!empty($source->comment) || !empty($source->COMMENT)) + $dest->comment = $source->comment ?? $source->COMMENT; + if(!empty($source->genre) || !empty($source->GENRE)) + $dest->genre = $source->genre ?? $source->GENRE; + } + + if(!empty($ffmpeg->format->tags)) { + $resp->media->tags = new stdClass; + eat_tags($resp->media->tags, $ffmpeg->format->tags); + } elseif(!empty($ffmpeg->streams)) { + // iterate over streams, fuck ogg + $resp->media->tags = new stdClass; + foreach($ffmpeg->streams as $stream) { + if(($stream->codec_type ?? null) === 'audio' && !empty($stream->tags)) { + eat_tags($resp->media->tags, $stream->tags); + if(!empty($resp->media->tags)) + break; + } + } + } + + if(empty($resp->title)) { + $audioTitle = ''; + if(!empty($resp->media->tags->artist)) + $audioTitle .= $resp->media->tags->artist . ' - '; + if(!empty($resp->media->tags->title)) + $audioTitle .= $resp->media->tags->title; + if(!empty($resp->media->tags->date)) + $audioTitle .= ' (' . $resp->media->tags->date . ')'; + if(!empty($audioTitle)) + $resp->title = $audioTitle; + } + + if(empty($resp->description) && !empty($resp->media->tags->comment)) + $resp->description = $resp->media->tags->comment; + } + } + } + + if(UIH_INCLUDE_RAW) + $resp->ffmpeg = $ffmpeg; + } else curl_close($curl); + } + } + } + + Stopwatch::stop(); + $resp->took = Stopwatch::elapsed(); + $respJson = json_encode($resp); + DB::prepare('REPLACE INTO `uih_metadata_cache` (`metadata_url`, `metadata_resp`) VALUES (UNHEX(:hash), :resp)') + ->bind('hash', $urlHash) + ->bind('resp', $respJson) + ->execute(); + } + } finally { + if(!empty($semaphore)) + sem_release($semaphore); + if(is_file($semPath)) + unlink($semPath); + } + + echo $respJson ?? json_encode($resp); + return; +} + +if($reqPath === '/') { + if($reqMethod !== 'GET') { + http_response_code(405); + return; + } + + header('Content-Type: text/plain'); + + if($reqHead) + return; + + echo 'Metadata lookup service - OK'; + return; +} + +http_response_code(404);