diff --git a/ansible/Makefile b/ansible/Makefile index 0163a5c..3078579 100644 --- a/ansible/Makefile +++ b/ansible/Makefile @@ -6,5 +6,7 @@ all: $(ANSIBLE_ROOT) lb: $(ANSIBLE_ROOT) --tags "lb" +website: + $(ANSIBLE_ROOT) --tags "website" dns: $(ANSIBLE_ROOT) --tags "dns" diff --git "a/ansible/\\" "b/ansible/\\" deleted file mode 100644 index 677a61d..0000000 --- "a/ansible/\\" +++ /dev/null @@ -1,106 +0,0 @@ - - - - mydns - - - - - - - - -
-
- -
-
-

- this is my anonymous dns server. you are free to use it, but just letting you know, this is mine, so it only really has features i care about. -

-

- the only thing i record are long-term metrics, for the health of the service. -

-

- it's meant to preserve my privacy (along with anyone who is using its). - in large, my goal is to avoid my dns data being sold to advertisers. -

-
-
-

endpoints

-

- - - - - - - - - - - - - - - - - - - - - - - -
typeendpointadditional info
- DoH/DoH3 - - https://mydns.gay/dns-query - - via ip4/ ipv6. -
- DNS / DoT (IPv4) - - 172.232.13.191 - - SNI for TLS is mydns.gay -
- DNS / DoT (IPv6) - - 2600:3c06::f03c:94ff:fe68:afad - - SNI for TLS is mydns.gay -
-

-
- -
-

information

-

- the server is in linode ORD datacenter. if i believe that linode one day is unfit to run this service, i will switch. -

-

- i'm really lazy, so the truth is, this is made from patching together a bunch of open source tools -

-

- -
-
- - - diff --git a/ansible/assets/api/.gitignore b/ansible/assets/api/.gitignore new file mode 100644 index 0000000..eab82dc --- /dev/null +++ b/ansible/assets/api/.gitignore @@ -0,0 +1 @@ +.php* diff --git a/ansible/assets/api/health.php b/ansible/assets/api/health.php new file mode 100644 index 0000000..51f3da4 --- /dev/null +++ b/ansible/assets/api/health.php @@ -0,0 +1,24 @@ +florm = new Florm("/var/www/data/health.sqlite", [ + "create table if not exists healthcheck ( + timestamp integer, + job text, + success integer, + data blob + )", + "create index idx_healthcheck_timestamp_job on healthcheck(timestamp, job)", + "create index idx_healthcheck_timestamp on healthcheck(timestamp)", + ]); + } + function florm(): Florm { + return $this->florm; + } +} + +?> diff --git a/ansible/assets/api/http.php b/ansible/assets/api/http.php new file mode 100644 index 0000000..dac3b3d --- /dev/null +++ b/ansible/assets/api/http.php @@ -0,0 +1,47 @@ +withUri($_msg->getUri()->withPath(trim_prefix( + $_msg->getUri()->getPath(), + "/api") +)); + +function msg():Message { + global $_msg; + return $_msg; +} + +function _route($path,$handler,$filter=null) { + global $_routing_table; + $_routing_table[] = [$path,$filter,$handler]; +} +function _methodfn($method) { + return function($path, $handler) use ($method) { + _route( + $path, + $handler, + fn() => mb_strtolower(msg()->getMethod()) == mb_strtolower($method), + ); + }; +} +function dispatch() { + global $_routing_table; + foreach ($_routing_table as $value) { + if ((!is_null($value[1])) && (!$value[1]())) { continue; } + if (preg_match("|" . $value[0] . "|i", + msg()->getUri()->getPath(), + )) { + try { + $value[2](); + } catch (Exception $e) { + echo 'exceptional error: ', $e->getMessage(), "\n"; + } + break; + } + http_response_code(404); + } +} + +?> diff --git a/ansible/assets/api/index.php b/ansible/assets/api/index.php new file mode 100644 index 0000000..98156b5 --- /dev/null +++ b/ansible/assets/api/index.php @@ -0,0 +1,26 @@ +headers); + printf("path: %s\nmethod: %s\nuser-agent: %s\n", + msg()->getUri()->getPath(), + msg()->getMethod(), + msg()->getHeaderLine("user-agent"), + ); +}); + +dispatch(); +?> diff --git a/ansible/assets/api/lib/MessageInterface.php b/ansible/assets/api/lib/MessageInterface.php new file mode 100644 index 0000000..a83c985 --- /dev/null +++ b/ansible/assets/api/lib/MessageInterface.php @@ -0,0 +1,187 @@ +getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return string[][] Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders(): array; + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader(string $name): bool; + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader(string $name): array; + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine(string $name): string; + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader(string $name, $value): MessageInterface; + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader(string $name, $value): MessageInterface; + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader(string $name): MessageInterface; + + /** + * Gets the body of the message. + * + * @return StreamInterface Returns the body as a stream. + */ + public function getBody(): StreamInterface; + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(StreamInterface $body): MessageInterface; +} diff --git a/ansible/assets/api/lib/RequestInterface.php b/ansible/assets/api/lib/RequestInterface.php new file mode 100644 index 0000000..33f85e5 --- /dev/null +++ b/ansible/assets/api/lib/RequestInterface.php @@ -0,0 +1,130 @@ + + * [user-info@]host[:port] + * + * + * If the port component is not set or is the standard port for the current + * scheme, it SHOULD NOT be included. + * + * @see https://tools.ietf.org/html/rfc3986#section-3.2 + * @return string The URI authority, in "[user-info@]host[:port]" format. + */ + public function getAuthority(): string; + + /** + * Retrieve the user information component of the URI. + * + * If no user information is present, this method MUST return an empty + * string. + * + * If a user is present in the URI, this will return that value; + * additionally, if the password is also present, it will be appended to the + * user value, with a colon (":") separating the values. + * + * The trailing "@" character is not part of the user information and MUST + * NOT be added. + * + * @return string The URI user information, in "username[:password]" format. + */ + public function getUserInfo(): string; + + /** + * Retrieve the host component of the URI. + * + * If no host is present, this method MUST return an empty string. + * + * The value returned MUST be normalized to lowercase, per RFC 3986 + * Section 3.2.2. + * + * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 + * @return string The URI host. + */ + public function getHost(): string; + + /** + * Retrieve the port component of the URI. + * + * If a port is present, and it is non-standard for the current scheme, + * this method MUST return it as an integer. If the port is the standard port + * used with the current scheme, this method SHOULD return null. + * + * If no port is present, and no scheme is present, this method MUST return + * a null value. + * + * If no port is present, but a scheme is present, this method MAY return + * the standard port for that scheme, but SHOULD return null. + * + * @return null|int The URI port. + */ + public function getPort(): ?int; + + /** + * Retrieve the path component of the URI. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * Normally, the empty path "" and absolute path "/" are considered equal as + * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically + * do this normalization because in contexts with a trimmed base path, e.g. + * the front controller, this difference becomes significant. It's the task + * of the user to handle both "" and "/". + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.3. + * + * As an example, if the value should include a slash ("/") not intended as + * delimiter between path segments, that value MUST be passed in encoded + * form (e.g., "%2F") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.3 + * @return string The URI path. + */ + public function getPath(): string; + + /** + * Retrieve the query string of the URI. + * + * If no query string is present, this method MUST return an empty string. + * + * The leading "?" character is not part of the query and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.4. + * + * As an example, if a value in a key/value pair of the query string should + * include an ampersand ("&") not intended as a delimiter between values, + * that value MUST be passed in encoded form (e.g., "%26") to the instance. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.4 + * @return string The URI query string. + */ + public function getQuery(): string; + + /** + * Retrieve the fragment component of the URI. + * + * If no fragment is present, this method MUST return an empty string. + * + * The leading "#" character is not part of the fragment and MUST NOT be + * added. + * + * The value returned MUST be percent-encoded, but MUST NOT double-encode + * any characters. To determine what characters to encode, please refer to + * RFC 3986, Sections 2 and 3.5. + * + * @see https://tools.ietf.org/html/rfc3986#section-2 + * @see https://tools.ietf.org/html/rfc3986#section-3.5 + * @return string The URI fragment. + */ + public function getFragment(): string; + + /** + * Return an instance with the specified scheme. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified scheme. + * + * Implementations MUST support the schemes "http" and "https" case + * insensitively, and MAY accommodate other schemes if required. + * + * An empty scheme is equivalent to removing the scheme. + * + * @param string $scheme The scheme to use with the new instance. + * @return static A new instance with the specified scheme. + * @throws \InvalidArgumentException for invalid or unsupported schemes. + */ + public function withScheme(string $scheme): UriInterface; + + /** + * Return an instance with the specified user information. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified user information. + * + * Password is optional, but the user information MUST include the + * user; an empty string for the user is equivalent to removing user + * information. + * + * @param string $user The user name to use for authority. + * @param null|string $password The password associated with $user. + * @return static A new instance with the specified user information. + */ + public function withUserInfo(string $user, ?string $password = null): UriInterface; + + /** + * Return an instance with the specified host. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified host. + * + * An empty host value is equivalent to removing the host. + * + * @param string $host The hostname to use with the new instance. + * @return static A new instance with the specified host. + * @throws \InvalidArgumentException for invalid hostnames. + */ + public function withHost(string $host): UriInterface; + + /** + * Return an instance with the specified port. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified port. + * + * Implementations MUST raise an exception for ports outside the + * established TCP and UDP port ranges. + * + * A null value provided for the port is equivalent to removing the port + * information. + * + * @param null|int $port The port to use with the new instance; a null value + * removes the port information. + * @return static A new instance with the specified port. + * @throws \InvalidArgumentException for invalid ports. + */ + public function withPort(?int $port): UriInterface; + + /** + * Return an instance with the specified path. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified path. + * + * The path can either be empty or absolute (starting with a slash) or + * rootless (not starting with a slash). Implementations MUST support all + * three syntaxes. + * + * If the path is intended to be domain-relative rather than path relative then + * it must begin with a slash ("/"). Paths not starting with a slash ("/") + * are assumed to be relative to some base path known to the application or + * consumer. + * + * Users can provide both encoded and decoded path characters. + * Implementations ensure the correct encoding as outlined in getPath(). + * + * @param string $path The path to use with the new instance. + * @return static A new instance with the specified path. + * @throws \InvalidArgumentException for invalid paths. + */ + public function withPath(string $path): UriInterface; + + /** + * Return an instance with the specified query string. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified query string. + * + * Users can provide both encoded and decoded query characters. + * Implementations ensure the correct encoding as outlined in getQuery(). + * + * An empty query string value is equivalent to removing the query string. + * + * @param string $query The query string to use with the new instance. + * @return static A new instance with the specified query string. + * @throws \InvalidArgumentException for invalid query strings. + */ + public function withQuery(string $query): UriInterface; + + /** + * Return an instance with the specified URI fragment. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified URI fragment. + * + * Users can provide both encoded and decoded fragment characters. + * Implementations ensure the correct encoding as outlined in getFragment(). + * + * An empty fragment value is equivalent to removing the fragment. + * + * @param string $fragment The fragment to use with the new instance. + * @return static A new instance with the specified fragment. + */ + public function withFragment(string $fragment): UriInterface; + + /** + * Return the string representation as a URI reference. + * + * Depending on which components of the URI are present, the resulting + * string is either a full URI or relative reference according to RFC 3986, + * Section 4.1. The method concatenates the various components of the URI, + * using the appropriate delimiters: + * + * - If a scheme is present, it MUST be suffixed by ":". + * - If an authority is present, it MUST be prefixed by "//". + * - The path can be concatenated without delimiters. But there are two + * cases where the path has to be adjusted to make the URI reference + * valid as PHP does not allow to throw an exception in __toString(): + * - If the path is rootless and an authority is present, the path MUST + * be prefixed by "/". + * - If the path is starting with more than one "/" and no authority is + * present, the starting slashes MUST be reduced to one. + * - If a query is present, it MUST be prefixed by "?". + * - If a fragment is present, it MUST be prefixed by "#". + * + * @see http://tools.ietf.org/html/rfc3986#section-4.1 + * @return string + */ + public function __toString(): string; +} diff --git a/ansible/assets/api/lib/db.php b/ansible/assets/api/lib/db.php new file mode 100644 index 0000000..e50a9b9 --- /dev/null +++ b/ansible/assets/api/lib/db.php @@ -0,0 +1,86 @@ +db = new SQLite3($path, SQLITE3_OPEN_CREATE | SQLITE3_OPEN_READWRITE); + $this->_init_migrate($migrations); + } + + function _init_migrate($migrations) { + $this->db->exec("create table if not exists version ( + key INTEGER PRIMARY KEY CHECK (key = 0), + version INTEGER + )"); + $this->db->exec("insert or ignore into version (key,version) values(0,0)"); + $current_version = $this->query_int("select version from version limit 1"); + if($current_version >= count($migrations)) { + return; + } + for ($x = $current_version; $x < count($migrations); $x++) { + $this->db->exec($migrations[$x]); + $this->exec("update version set version = :version",["version"=>$x+1]); + } + + } + + function exec(string $query, array $bind = []): bool { + $stmt = $this->db->prepare($query); + foreach($bind as $key => $value) { + $stmt->bindParam(":" . $key, $value); + } + $executed = $stmt->execute(); + if($executed == false) { + return false; + } + $executed->finalize(); + return true; + } + + function query_rows(string $query, array $bind = []): array { + $stmt = $this->db->prepare($query); + foreach($bind as $key => $value) { + $stmt->bindParam(":" . $key, $value); + } + $executed = $stmt->execute(); + if($executed == false) { + throw new ExecutionFailedException("query failed"); + } + $tmp = $executed->fetchArray(); + $executed->finalize(); + if($tmp == false) { + return []; + } + return $tmp; + } + function query_row(string $query, array $bind = []): array { + $rows = $this->query_rows($query, $bind); + if(count($rows) == 0) { + throw new NoRowsException("no rows"); + } + if(is_array($rows[0])) { + return $rows[0]; + } + return [$rows[0]]; + } + function query_int(string $query, array $bind = []): int { + $row = $this->query_row($query, $bind); + if(count($row) == 0) { + throw new InvalidTypeException("expected 1 argument, got 0"); + } + $intvar = $row[0]; + if (!is_int($intvar)) { + throw new InvalidTypeException("expected integer"); + } + return $intvar; + } + + function DB(): SQLite3 { + return $this->db; + } +} + +?> diff --git a/ansible/assets/api/lib/exception.php b/ansible/assets/api/lib/exception.php new file mode 100644 index 0000000..380e0e9 --- /dev/null +++ b/ansible/assets/api/lib/exception.php @@ -0,0 +1,11 @@ + diff --git a/ansible/assets/api/lib/message.php b/ansible/assets/api/lib/message.php new file mode 100644 index 0000000..84f3faa --- /dev/null +++ b/ansible/assets/api/lib/message.php @@ -0,0 +1,162 @@ +protocolVersion = trim_prefix($_SERVER["SERVER_PROTOCOL"],"HTTP/"); +// if ($this->protocolVersion != "1.1" && $this->protocolVersion != "1.0") { +// throw new Exception("Invalid Protocol Version: " . $this->protocolVersion); +// } + $headers = apache_request_headers(); + if ($headers == false) { + $this->headers = []; + } else { + foreach ($headers as $header => $value) { + $this->headers[to_title($header)] = [[$header, $value]]; + } + } + $contents = file_get_contents('php://input'); + if($contents == false) { + $this->body = new StringBuffer(""); + }else { + $this->body = new StringBuffer($contents); + } + $this->uri = new StringURI($_SERVER["REQUEST_URI"]); + $this->method = new StringURI($_SERVER["REQUEST_METHOD"]); + } + + function getMethod(): string + { + return $this->method; + } + function withMethod(string $method): RequestInterface + { + $next = clone $this; + $next->method = $method; + return $next; + } + function getRequestTarget(): string + { + return $this->target; + } + function withRequestTarget(string $requestTarget): RequestInterface + { + $next = clone $this; + $next->target = $requestTarget; + return $next; + } + function getUri(): UriInterface + { + return $this->uri; + } + function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface + { + $next = clone $this; + if ($preserveHost) { + $next->uri = $uri; + }else { + $next->uri = $uri->withHost($this->getUri()->getHost()); + } + return $next; + } + function getProtocolVersion(): string { + return $this->protocolVersion; + } + function withProtocolVersion(string $version): MessageInterface + { + $next = clone $this; + $next->protocolVersion = $version; + return $next; + } + + function getHeaders(): array { + return $this->headers; + } + function hasHeader(string $name): bool { + $header = to_title($name); + return isset($this->headers[$header]); + } + + function getHeader(string $name): array { + $header = to_title($name); + if (isset($this->headers[$header])){ + return array_map(fn($x)=>$x[1],$this->headers[$header]); + } + return []; + } + function getHeaderLine(string $name): string { + $headers = $this->getHeader($name); + if (count($headers) == 0) { + return ""; + } + return join(",",$headers); + } + + function withHeader(string $name, $value): MessageInterface { + $next = clone $this; + if (is_array($value)) { + $next->headers[to_title($name)] = array_map(fn($x)=>[$name,$x],$value); + }else { + $next->headers[to_title($name)] = [[$name, $value]]; + } + return $next; + } + function withAddedHeader(string $name, $value): MessageInterface { + $next = clone $this; + if (!isset($next->headers[to_title($name)])) { + $next->headers[to_title($name)] = []; + } + if (is_array($value)) { + foreach ($value as $v) { + $next->headers[to_title($name)][] = [$name, $v]; + } + }else { + $next->headers[to_title($name)][] = [$name, $value]; + } + return $next; + } + function withoutHeader(string $name): MessageInterface { + $next = clone $this; + if (isset($next->headers[to_title($name)])) { + unset($next->headers[to_title($name)]); + } + return $next; + } + + function getBody(): StreamInterface + { + return $this->body; + } + function withBody(StreamInterface $body): MessageInterface + { + $next = clone $this; + $next->body = $body; + return $next; + } + + function __clone() { + $this->headers = array_merge(array(), $this->headers); + $this->uri = clone $this->uri; + $this->body = clone $this->body; + } +} + +?> diff --git a/ansible/assets/api/lib/stream.php b/ansible/assets/api/lib/stream.php new file mode 100644 index 0000000..3849a28 --- /dev/null +++ b/ansible/assets/api/lib/stream.php @@ -0,0 +1,178 @@ +buf = $buf; + } + /** + * Reads all data from the stream into a string, from the beginning to end. + * + * This method MUST attempt to seek to the beginning of the stream before + * reading data and read the stream until the end is reached. + * + * Warning: This could attempt to load a large amount of data into memory. + * + * This method MUST NOT raise an exception in order to conform with PHP's + * string casting operations. + * + * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring + * @return string + */ + public function __toString(): string { + return $this->buf; + } + + public function close(): void {} + + public function detach(){ + $this->buf = ""; + } + + /** + * Get the size of the stream if known. + * + * @return int|null Returns the size in bytes if known, or null if unknown. + */ + public function getSize(): int{ + return strlen($this->buf); + } + + public function tell(): int { + return $this->cur; + } + + public function eof(): bool{ + return $this->cur > strlen($this->buf); + } + + public function isSeekable(): bool { + return true; + } + + /** + * Seek to a position in the stream. + * + * @link http://www.php.net/manual/en/function.fseek.php + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. + * @throws \RuntimeException on failure. + */ + public function seek(int $offset, int $whence = SEEK_SET): void { + switch ($whence){ + case SEEK_SET: + $this->cur = $offset; + break; + case SEEK_CUR: + $this->cur += $offset; + break; + case SEEK_END: + $this->cur = $this->getSize()+ $offset; + break; + } + } + + /** + * Seek to the beginning of the stream. + * + * If the stream is not seekable, this method will raise an exception; + * otherwise, it will perform a seek(0). + * + * @see seek() + * @link http://www.php.net/manual/en/function.fseek.php + * @throws \RuntimeException on failure. + */ + public function rewind(): void { + $this->cur = 0; + } + + /** + * Returns whether or not the stream is writable. + * + * @return bool + */ + public function isWritable(): bool { + return true; + } + + /** + * Write data to the stream. + * + * @param string $string The string that is to be written. + * @return int Returns the number of bytes written to the stream. + * @throws \RuntimeException on failure. + */ + public function write(string $str): int { + $strlen = strlen($str); + str_pad($this->buf, $this->cur + $strlen); + substr_replace($this->buf, $str, $this->cur); + return $strlen; + } + + /** + * Returns whether or not the stream is readable. + * + * @return bool + */ + public function isReadable(): bool { + return true; + } + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return + * them. Fewer than $length bytes may be returned if underlying stream + * call returns fewer bytes. + * @return string Returns the data read from the stream, or an empty string + * if no bytes are available. + * @throws \RuntimeException if an error occurs. + */ + public function read(int $length): string { + $remaining = $this->getSize() - $this->cur; + if( $remaining <= 0 ) { + return ""; + } + return substr($this->buf, $this->cur,min($remaining, $length)); + } + + /** + * Returns the remaining contents in a string + * + * @return string + * @throws \RuntimeException if unable to read or an error occurs while + * reading. + */ + public function getContents(): string { + return $this->read($this->getSize() - $this->cur); + } + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * + * The keys returned are identical to the keys returned from PHP's + * stream_get_meta_data() function. + * + * @link http://php.net/manual/en/function.stream-get-meta-data.php + * @param string|null $key Specific metadata to retrieve. + * @return array|mixed|null Returns an associative array if no key is + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. + */ + public function getMetadata(?string $key = null){ + return null; + } + +} + +?> diff --git a/ansible/assets/api/lib/uri.php b/ansible/assets/api/lib/uri.php new file mode 100644 index 0000000..c0df6f7 --- /dev/null +++ b/ansible/assets/api/lib/uri.php @@ -0,0 +1,936 @@ + 80, + 'https' => 443, + 'ftp' => 21, + 'gopher' => 70, + 'nntp' => 119, + 'news' => 119, + 'telnet' => 23, + 'tn3270' => 23, + 'imap' => 143, + 'pop' => 110, + 'ldap' => 389, + ]; + + /** + * Unreserved characters for use in a regex. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + */ + private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; + + /** + * Sub-delims for use in a regex. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 + */ + private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; + private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26']; + + /** @var string Uri scheme. */ + private $scheme = ''; + + /** @var string Uri user info. */ + private $userInfo = ''; + + /** @var string Uri host. */ + private $host = ''; + + /** @var int|null Uri port. */ + private $port; + + /** @var string Uri path. */ + private $path = ''; + + /** @var string Uri query string. */ + private $query = ''; + + /** @var string Uri fragment. */ + private $fragment = ''; + + /** @var string|null String representation */ + private $composedComponents; + + public function __construct(string $uri = '') + { + if ($uri !== '') { + $parts = self::parse($uri); + if ($parts === false) { + throw new MalformedUriException("Unable to parse URI: $uri"); + } + $this->applyParts($parts); + } + } + + /** + * UTF-8 aware \parse_url() replacement. + * + * The internal function produces broken output for non ASCII domain names + * (IDN) when used with locales other than "C". + * + * On the other hand, cURL understands IDN correctly only when UTF-8 locale + * is configured ("C.UTF-8", "en_US.UTF-8", etc.). + * + * @see https://bugs.php.net/bug.php?id=52923 + * @see https://www.php.net/manual/en/function.parse-url.php#114817 + * @see https://curl.haxx.se/libcurl/c/CURLOPT_URL.html#ENCODING + * + * @return array|false + */ + private static function parse(string $url) + { + // If IPv6 + $prefix = ''; + if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) { + /** @var array{0:string, 1:string, 2:string} $matches */ + $prefix = $matches[1]; + $url = $matches[2]; + } + + /** @var string */ + $encodedUrl = preg_replace_callback( + '%[^:/@?&=#]+%usD', + static function ($matches) { + return urlencode($matches[0]); + }, + $url + ); + + $result = parse_url($prefix.$encodedUrl); + + if ($result === false) { + return false; + } + + return array_map('urldecode', $result); + } + + public function __toString(): string + { + if ($this->composedComponents === null) { + $this->composedComponents = self::composeComponents( + $this->scheme, + $this->getAuthority(), + $this->path, + $this->query, + $this->fragment + ); + } + + return $this->composedComponents; + } + + /** + * Composes a URI reference string from its various components. + * + * Usually this method does not need to be called manually but instead is used indirectly via + * `Psr\Http\Message\UriInterface::__toString`. + * + * PSR-7 UriInterface treats an empty component the same as a missing component as + * getQuery(), getFragment() etc. always return a string. This explains the slight + * difference to RFC 3986 Section 5.3. + * + * Another adjustment is that the authority separator is added even when the authority is missing/empty + * for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with + * `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But + * `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to + * that format). + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.3 + */ + public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string + { + $uri = ''; + + // weak type checks to also accept null until we can add scalar type hints + if ($scheme != '') { + $uri .= $scheme.':'; + } + + if ($authority != '' || $scheme === 'file') { + $uri .= '//'.$authority; + } + + if ($authority != '' && $path != '' && $path[0] != '/') { + $path = '/'.$path; + } + + $uri .= $path; + + if ($query != '') { + $uri .= '?'.$query; + } + + if ($fragment != '') { + $uri .= '#'.$fragment; + } + + return $uri; + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used + * independently of the implementation. + */ + public static function isDefaultPort(UriInterface $uri): bool + { + return $uri->getPort() === null + || (isset(self::DEFAULT_PORTS[$uri->getScheme()]) && $uri->getPort() === self::DEFAULT_PORTS[$uri->getScheme()]); + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @see Uri::isNetworkPathReference + * @see Uri::isAbsolutePathReference + * @see Uri::isRelativePathReference + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4 + */ + public static function isAbsolute(UriInterface $uri): bool + { + return $uri->getScheme() !== ''; + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + */ + public static function isNetworkPathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' && $uri->getAuthority() !== ''; + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + */ + public static function isAbsolutePathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' + && $uri->getAuthority() === '' + && isset($uri->getPath()[0]) + && $uri->getPath()[0] === '/'; + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + */ + public static function isRelativePathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' + && $uri->getAuthority() === '' + && (!isset($uri->getPath()[0]) || $uri->getPath()[0] !== '/'); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface $uri The URI to check + * @param UriInterface|null $base An optional base URI to compare against + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.4 + */ + public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool + { + if ($base !== null) { + $uri = UriResolver::resolve($base, $uri); + + return ($uri->getScheme() === $base->getScheme()) + && ($uri->getAuthority() === $base->getAuthority()) + && ($uri->getPath() === $base->getPath()) + && ($uri->getQuery() === $base->getQuery()); + } + + return $uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '' && $uri->getQuery() === ''; + } + + /** + * Creates a new URI with a specific query string value removed. + * + * Any existing query string values that exactly match the provided key are + * removed. + * + * @param UriInterface $uri URI to use as a base. + * @param string $key Query string key to remove. + */ + public static function withoutQueryValue(UriInterface $uri, string $key): UriInterface + { + $result = self::getFilteredQueryString($uri, [$key]); + + return $uri->withQuery(implode('&', $result)); + } + + /** + * Creates a new URI with a specific query string value. + * + * Any existing query string values that exactly match the provided key are + * removed and replaced with the given key value pair. + * + * A value of null will set the query string key without a value, e.g. "key" + * instead of "key=value". + * + * @param UriInterface $uri URI to use as a base. + * @param string $key Key to set. + * @param string|null $value Value to set + */ + public static function withQueryValue(UriInterface $uri, string $key, ?string $value): UriInterface + { + $result = self::getFilteredQueryString($uri, [$key]); + + $result[] = self::generateQueryString($key, $value); + + return $uri->withQuery(implode('&', $result)); + } + + /** + * Creates a new URI with multiple specific query string values. + * + * It has the same behavior as withQueryValue() but for an associative array of key => value. + * + * @param UriInterface $uri URI to use as a base. + * @param (string|null)[] $keyValueArray Associative array of key and values + */ + public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface + { + $result = self::getFilteredQueryString($uri, array_keys($keyValueArray)); + + foreach ($keyValueArray as $key => $value) { + $result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null); + } + + return $uri->withQuery(implode('&', $result)); + } + + /** + * Creates a URI from a hash of `parse_url` components. + * + * @see https://www.php.net/manual/en/function.parse-url.php + * + * @throws MalformedUriException If the components do not form a valid URI. + */ + public static function fromParts(array $parts): UriInterface + { + $uri = new self(); + $uri->applyParts($parts); + $uri->validateState(); + + return $uri; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getAuthority(): string + { + $authority = $this->host; + if ($this->userInfo !== '') { + $authority = $this->userInfo.'@'.$authority; + } + + if ($this->port !== null) { + $authority .= ':'.$this->port; + } + + return $authority; + } + + public function getUserInfo(): string + { + return $this->userInfo; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function getPath(): string + { + return $this->path; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getFragment(): string + { + return $this->fragment; + } + + public function withScheme($scheme): UriInterface + { + $scheme = $this->filterScheme($scheme); + + if ($this->scheme === $scheme) { + return $this; + } + + $new = clone $this; + $new->scheme = $scheme; + $new->composedComponents = null; + $new->removeDefaultPort(); + $new->validateState(); + + return $new; + } + + public function withUserInfo($user, $password = null): UriInterface + { + $info = $this->filterUserInfoComponent($user); + if ($password !== null) { + $info .= ':'.$this->filterUserInfoComponent($password); + } + + if ($this->userInfo === $info) { + return $this; + } + + $new = clone $this; + $new->userInfo = $info; + $new->composedComponents = null; + $new->validateState(); + + return $new; + } + + public function withHost($host): UriInterface + { + $host = $this->filterHost($host); + + if ($this->host === $host) { + return $this; + } + + $new = clone $this; + $new->host = $host; + $new->composedComponents = null; + $new->validateState(); + + return $new; + } + + public function withPort($port): UriInterface + { + $port = $this->filterPort($port); + + if ($this->port === $port) { + return $this; + } + + $new = clone $this; + $new->port = $port; + $new->composedComponents = null; + $new->removeDefaultPort(); + $new->validateState(); + + return $new; + } + + public function withPath($path): UriInterface + { + $path = $this->filterPath($path); + + if ($this->path === $path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + $new->composedComponents = null; + $new->validateState(); + + return $new; + } + + public function withQuery($query): UriInterface + { + $query = $this->filterQueryAndFragment($query); + + if ($this->query === $query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + $new->composedComponents = null; + + return $new; + } + + public function withFragment($fragment): UriInterface + { + $fragment = $this->filterQueryAndFragment($fragment); + + if ($this->fragment === $fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + $new->composedComponents = null; + + return $new; + } + + public function jsonSerialize(): string + { + return $this->__toString(); + } + + /** + * Apply parse_url parts to a URI. + * + * @param array $parts Array of parse_url parts to apply. + */ + private function applyParts(array $parts): void + { + $this->scheme = isset($parts['scheme']) + ? $this->filterScheme($parts['scheme']) + : ''; + $this->userInfo = isset($parts['user']) + ? $this->filterUserInfoComponent($parts['user']) + : ''; + $this->host = isset($parts['host']) + ? $this->filterHost($parts['host']) + : ''; + $this->port = isset($parts['port']) + ? $this->filterPort($parts['port']) + : null; + $this->path = isset($parts['path']) + ? $this->filterPath($parts['path']) + : ''; + $this->query = isset($parts['query']) + ? $this->filterQueryAndFragment($parts['query']) + : ''; + $this->fragment = isset($parts['fragment']) + ? $this->filterQueryAndFragment($parts['fragment']) + : ''; + if (isset($parts['pass'])) { + $this->userInfo .= ':'.$this->filterUserInfoComponent($parts['pass']); + } + + $this->removeDefaultPort(); + } + + /** + * @param mixed $scheme + * + * @throws \InvalidArgumentException If the scheme is invalid. + */ + private function filterScheme($scheme): string + { + if (!is_string($scheme)) { + throw new \InvalidArgumentException('Scheme must be a string'); + } + + return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + } + + /** + * @param mixed $component + * + * @throws \InvalidArgumentException If the user info is invalid. + */ + private function filterUserInfoComponent($component): string + { + if (!is_string($component)) { + throw new \InvalidArgumentException('User info must be a string'); + } + + return preg_replace_callback( + '/(?:[^%'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/', + [$this, 'rawurlencodeMatchZero'], + $component + ); + } + + /** + * @param mixed $host + * + * @throws \InvalidArgumentException If the host is invalid. + */ + private function filterHost($host): string + { + if (!is_string($host)) { + throw new \InvalidArgumentException('Host must be a string'); + } + + return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + } + + /** + * @param mixed $port + * + * @throws \InvalidArgumentException If the port is invalid. + */ + private function filterPort($port): ?int + { + if ($port === null) { + return null; + } + + $port = (int) $port; + if (0 > $port || 0xFFFF < $port) { + throw new \InvalidArgumentException( + sprintf('Invalid port: %d. Must be between 0 and 65535', $port) + ); + } + + return $port; + } + + /** + * @param (string|int)[] $keys + * + * @return string[] + */ + private static function getFilteredQueryString(UriInterface $uri, array $keys): array + { + $current = $uri->getQuery(); + + if ($current === '') { + return []; + } + + $decodedKeys = array_map(function ($k): string { + return rawurldecode((string) $k); + }, $keys); + + return array_filter(explode('&', $current), function ($part) use ($decodedKeys) { + return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true); + }); + } + + private static function generateQueryString(string $key, ?string $value): string + { + // Query string separators ("=", "&") within the key or value need to be encoded + // (while preventing double-encoding) before setting the query string. All other + // chars that need percent-encoding will be encoded by withQuery(). + $queryString = strtr($key, self::QUERY_SEPARATORS_REPLACEMENT); + + if ($value !== null) { + $queryString .= '='.strtr($value, self::QUERY_SEPARATORS_REPLACEMENT); + } + + return $queryString; + } + + private function removeDefaultPort(): void + { + if ($this->port !== null && self::isDefaultPort($this)) { + $this->port = null; + } + } + + /** + * Filters the path of a URI + * + * @param mixed $path + * + * @throws \InvalidArgumentException If the path is invalid. + */ + private function filterPath($path): string + { + if (!is_string($path)) { + throw new \InvalidArgumentException('Path must be a string'); + } + + return preg_replace_callback( + '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/', + [$this, 'rawurlencodeMatchZero'], + $path + ); + } + + /** + * Filters the query string or fragment of a URI. + * + * @param mixed $str + * + * @throws \InvalidArgumentException If the query or fragment is invalid. + */ + private function filterQueryAndFragment($str): string + { + if (!is_string($str)) { + throw new \InvalidArgumentException('Query and fragment must be a string'); + } + + return preg_replace_callback( + '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', + [$this, 'rawurlencodeMatchZero'], + $str + ); + } + + private function rawurlencodeMatchZero(array $match): string + { + return rawurlencode($match[0]); + } + + private function validateState(): void + { + if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { + $this->host = self::HTTP_DEFAULT_HOST; + } + + if ($this->getAuthority() === '') { + if (0 === strpos($this->path, '//')) { + throw new MalformedUriException('The path of a URI without an authority must not start with two slashes "//"'); + } + if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) { + throw new MalformedUriException('A relative URI must not have a path beginning with a segment containing a colon'); + } + } + } +} + +/** + * Resolves a URI reference in the context of a base URI and the opposite way. + * + * @author Tobias Schultze + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5 + */ +final class UriResolver +{ + /** + * Removes dot segments from a path and returns the new path. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4 + */ + public static function removeDotSegments(string $path): string + { + if ($path === '' || $path === '/') { + return $path; + } + + $results = []; + $segments = explode('/', $path); + foreach ($segments as $segment) { + if ($segment === '..') { + array_pop($results); + } elseif ($segment !== '.') { + $results[] = $segment; + } + } + + $newPath = implode('/', $results); + + if ($path[0] === '/' && (!isset($newPath[0]) || $newPath[0] !== '/')) { + // Re-add the leading slash if necessary for cases like "/.." + $newPath = '/'.$newPath; + } elseif ($newPath !== '' && ($segment === '.' || $segment === '..')) { + // Add the trailing slash if necessary + // If newPath is not empty, then $segment must be set and is the last segment from the foreach + $newPath .= '/'; + } + + return $newPath; + } + + /** + * Converts the relative URI into a new URI that is resolved against the base URI. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2 + */ + public static function resolve(UriInterface $base, UriInterface $rel): UriInterface + { + if ((string) $rel === '') { + // we can simply return the same base URI instance for this same-document reference + return $base; + } + + if ($rel->getScheme() != '') { + return $rel->withPath(self::removeDotSegments($rel->getPath())); + } + + if ($rel->getAuthority() != '') { + $targetAuthority = $rel->getAuthority(); + $targetPath = self::removeDotSegments($rel->getPath()); + $targetQuery = $rel->getQuery(); + } else { + $targetAuthority = $base->getAuthority(); + if ($rel->getPath() === '') { + $targetPath = $base->getPath(); + $targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery(); + } else { + if ($rel->getPath()[0] === '/') { + $targetPath = $rel->getPath(); + } else { + if ($targetAuthority != '' && $base->getPath() === '') { + $targetPath = '/'.$rel->getPath(); + } else { + $lastSlashPos = strrpos($base->getPath(), '/'); + if ($lastSlashPos === false) { + $targetPath = $rel->getPath(); + } else { + $targetPath = substr($base->getPath(), 0, $lastSlashPos + 1).$rel->getPath(); + } + } + } + $targetPath = self::removeDotSegments($targetPath); + $targetQuery = $rel->getQuery(); + } + } + + return new StringURI(StringURI::composeComponents( + $base->getScheme(), + $targetAuthority, + $targetPath, + $targetQuery, + $rel->getFragment() + )); + } + + /** + * Returns the target URI as a relative reference from the base URI. + * + * This method is the counterpart to resolve(): + * + * (string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target)) + * + * One use-case is to use the current request URI as base URI and then generate relative links in your documents + * to reduce the document size or offer self-contained downloadable document archives. + * + * $base = new Uri('http://example.com/a/b/'); + * echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'. + * echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'. + * echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'. + * echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'. + * + * This method also accepts a target that is already relative and will try to relativize it further. Only a + * relative-path reference will be returned as-is. + * + * echo UriResolver::relativize($base, new Uri('/a/b/c')); // prints 'c' as well + */ + public static function relativize(UriInterface $base, UriInterface $target): UriInterface + { + if ($target->getScheme() !== '' + && ($base->getScheme() !== $target->getScheme() || $target->getAuthority() === '' && $base->getAuthority() !== '') + ) { + return $target; + } + + if (StringURI::isRelativePathReference($target)) { + // As the target is already highly relative we return it as-is. It would be possible to resolve + // the target with `$target = self::resolve($base, $target);` and then try make it more relative + // by removing a duplicate query. But let's not do that automatically. + return $target; + } + + if ($target->getAuthority() !== '' && $base->getAuthority() !== $target->getAuthority()) { + return $target->withScheme(''); + } + + // We must remove the path before removing the authority because if the path starts with two slashes, the URI + // would turn invalid. And we also cannot set a relative path before removing the authority, as that is also + // invalid. + $emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost(''); + + if ($base->getPath() !== $target->getPath()) { + return $emptyPathUri->withPath(self::getRelativePath($base, $target)); + } + + if ($base->getQuery() === $target->getQuery()) { + // Only the target fragment is left. And it must be returned even if base and target fragment are the same. + return $emptyPathUri->withQuery(''); + } + + // If the base URI has a query but the target has none, we cannot return an empty path reference as it would + // inherit the base query component when resolving. + if ($target->getQuery() === '') { + $segments = explode('/', $target->getPath()); + /** @var string $lastSegment */ + $lastSegment = end($segments); + + return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment); + } + + return $emptyPathUri; + } + + private static function getRelativePath(UriInterface $base, UriInterface $target): string + { + $sourceSegments = explode('/', $base->getPath()); + $targetSegments = explode('/', $target->getPath()); + array_pop($sourceSegments); + $targetLastSegment = array_pop($targetSegments); + foreach ($sourceSegments as $i => $segment) { + if (isset($targetSegments[$i]) && $segment === $targetSegments[$i]) { + unset($sourceSegments[$i], $targetSegments[$i]); + } else { + break; + } + } + $targetSegments[] = $targetLastSegment; + $relativePath = str_repeat('../', count($sourceSegments)).implode('/', $targetSegments); + + // A reference to am empty last segment or an empty first sub-segment must be prefixed with "./". + // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used + // as the first segment of a relative-path reference, as it would be mistaken for a scheme name. + if ('' === $relativePath || false !== strpos(explode('/', $relativePath, 2)[0], ':')) { + $relativePath = "./$relativePath"; + } elseif ('/' === $relativePath[0]) { + if ($base->getAuthority() != '' && $base->getPath() === '') { + // In this case an extra slash is added by resolve() automatically. So we must not add one here. + $relativePath = ".$relativePath"; + } else { + $relativePath = "./$relativePath"; + } + } + + return $relativePath; + } + + private function __construct() + { + // cannot be instantiated + } +} diff --git a/ansible/assets/api/lib/util.php b/ansible/assets/api/lib/util.php new file mode 100644 index 0000000..f2e7926 --- /dev/null +++ b/ansible/assets/api/lib/util.php @@ -0,0 +1,13 @@ + diff --git a/ansible/files/Caddyfile b/ansible/files/Caddyfile index 37dfc03..2a06810 100644 --- a/ansible/files/Caddyfile +++ b/ansible/files/Caddyfile @@ -14,7 +14,13 @@ lb_try_interval 500ms } } - + handle_path /api/* { + route { + root * /var/www/site/api/ + php_fastcgi unix//run/php/php-fpm.sock { + } + } + } handle { root * /var/www/site file_server diff --git a/ansible/playbook.yml b/ansible/playbook.yml index d1baec7..545ca05 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -15,14 +15,8 @@ - import_tasks: ./tasks/prometheus.yml - import_tasks: ./tasks/debian.yml - import_tasks: ./tasks/blocky.yml - - import_tasks: ./tasks/caddy.yml - import_tasks: ./tasks/dnsdist.yml - - import_tasks: ./tasks/lbsite.yml - - name: copy caddy config - template: - src: "{{ playbook_dir }}/files/Caddyfile" - dest: "/etc/caddy/Caddyfile" - notify: "reload caddy" + - import_tasks: ./tasks/caddy.yml - name: copy dnsdist service override template: src: "{{ playbook_dir }}/files/dnsdist.service" @@ -40,4 +34,14 @@ notify: "restart blocky" handlers: - import_tasks: ./handlers/global.yml - +- hosts: lb_ord + tags: ["lb","website"] + tasks: + - import_tasks: ./tasks/lbsite.yml + - name: copy caddy config + template: + src: "{{ playbook_dir }}/files/Caddyfile" + dest: "/etc/caddy/Caddyfile" + notify: "reload caddy" + handlers: + - import_tasks: ./handlers/global.yml diff --git a/ansible/tasks/caddy.yml b/ansible/tasks/caddy.yml index d92b6b4..6b83ea6 100644 --- a/ansible/tasks/caddy.yml +++ b/ansible/tasks/caddy.yml @@ -2,6 +2,20 @@ - name: install caddy apt: deb: https://github.com/caddyserver/caddy/releases/download/v2.8.4/caddy_2.8.4_linux_amd64.deb +- name: install php + apt: + name: php +- name: install php-sqlite3 + apt: + name: php-sqlite3 +- name: install php-mbstring + apt: + name: php-mbstring +- name: install php-fpm + apt: + name: php-fpm +- name: ensure php-fpm is running + service: name=php8.2-fpm state=started enabled=yes - name: ensure /run/caddy exists file: path: /run/caddy diff --git a/ansible/tasks/dnsdist.yml b/ansible/tasks/dnsdist.yml index 6c70da8..ba3f607 100644 --- a/ansible/tasks/dnsdist.yml +++ b/ansible/tasks/dnsdist.yml @@ -1,6 +1,6 @@ # vi: ft=yaml.ansible - name: install dnsdist apt: - name: dnsdist + deb: https://repo.powerdns.com/debian/pool/main/d/dnsdist/dnsdist_1.9.6-1pdns.bookworm_amd64.deb - name: ensure dnsdist default svc is not running service: name=dnsdist state=stopped enabled=no diff --git a/ansible/tasks/lbsite.yml b/ansible/tasks/lbsite.yml index 1e890fa..41f627c 100644 --- a/ansible/tasks/lbsite.yml +++ b/ansible/tasks/lbsite.yml @@ -1,14 +1,25 @@ -- name: ensure /var/www exists +- name: ensure /var/www/site exists file: path: /var/www/site state: directory mode: '0755' owner: caddy group: caddy +- name: ensure /var/www/data exists + file: + path: /var/www/data + state: directory + mode: '0755' + owner: caddy + group: caddy - name: copy static assets copy: src: "{{ playbook_dir }}/assets/static/" dest: "/var/www/site/static/" +- name: copy api assets + copy: + src: "{{ playbook_dir }}/assets/api/" + dest: "/var/www/site/api/" - name: copy index.html template: src: "{{ playbook_dir }}/assets/index.html"