~thirdplace/components

e8fb90e816ece140fcd42b9af96ddff5ad9e251d — Dag 1 year, 5 months ago 939dfea
refactor
M README.md => README.md +5 -0
@@ 1,6 1,7 @@
# Thirdplace Components

This is a collection of components. Mostly for my own personal use.
Mostly used in web applications.

## Tutorial



@@ 8,6 9,10 @@ Install:

    composer require thirdplace/components

Run unit tests:

    ./vendor/bin/tunit

## Explanation

## How-to

M composer.json => composer.json +2 -6
@@ 29,13 29,9 @@
        "files": [
            "./src/common.php",
            "./src/Clock.php",
            "./src/Container.php",
            "./src/CsrfMiddleware.php",
            "./src/http/Header.php",
            "./src/http/Response.php",
            "./src/Url.php",
            "./src/Renderer.php",
            "./src/Shell.php"
            "./src/http/Response.php",
            "./src/Url.php"
        ]
    },
    "bin": [

M src/Container.php => src/Container.php +3 -5
@@ 3,8 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

final class ContainerException extends Exception {}

final class Container implements \ArrayAccess
{
    private array $values = [];


@@ 13,7 11,7 @@ final class Container implements \ArrayAccess
    public function offsetSet($offset, $value)
    {
        if (isset($this->values[$offset])) {
            throw new ContainerException(sprintf('Key already exists "%s"', $offset));
            throw new \Exception(sprintf('Key already exists "%s"', $offset));
        }

        if (! $value instanceof \Closure) {


@@ 26,7 24,7 @@ final class Container implements \ArrayAccess
    public function offsetGet($offset)
    {
        if (!isset($this->values[$offset])) {
            throw new ContainerException(sprintf('Unknown key: "%s"', $offset));
            throw new \Exception(sprintf('Unknown key: "%s"', $offset));
        }

        if (isset($this->resolved[$offset])) {


@@ 43,6 41,6 @@ final class Container implements \ArrayAccess

    public function offsetUnset($offset)
    {
        throw new ContainerException('unset() not implemented');
        throw new \Exception('unset() not implemented');
    }
}

M src/CsrfMiddleware.php => src/CsrfMiddleware.php +4 -6
@@ 3,8 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

final class CsrfException extends Exception {}

final class CsrfMiddleware
{
	public function __invoke(Request $request): Request


@@ 13,20 11,20 @@ final class CsrfMiddleware
            $tokenFromRequest = $request->post('csrf', [], true);

            if (!$tokenFromRequest) {
                throw new CsrfException('Missing token from request');
                throw new \Exception('Missing token from request');
            }
            if (!isset($tokenFromRequest['key'])) {
                throw new CsrfException('Missing token key from request');
                throw new \Exception('Missing token key from request');
            }

            $tokenFromSession = $_SESSION['thirdplace'][$tokenFromRequest['key']] ?? null;

            if (!$tokenFromSession) {
                throw new CsrfException('Unknown token key');
                throw new \Exception('Unknown token key');
            }

            if (! hash_equals($tokenFromRequest['value'], $tokenFromSession['value'])) {
                throw new CsrfException('Token mismatch');
                throw new \Exception('Token mismatch');
            }
        }


M src/Renderer.php => src/Renderer.php +55 -16
@@ 3,28 3,19 @@ declare(strict_types=1);

namespace Thirdplace;

final class RendererException extends Exception {}

final class Renderer
{
    private const CONFIG = [
        /**
         * Templates folder
         */
        'templates' => './',

        /**
         * Default context that is always available in templates
         */
        'context' => [],
    ];

    private array $config;
    private array $context;

    public function __construct(array $config = [])
    {
        $this->config = array_merge(self::CONFIG, $config);
        $defaultConfig = [
            'templates' => './',
            // Default context that is always available in templates
            'context' => [],
        ];
        $this->config = array_merge($defaultConfig, $config);
        $this->context = $this->config['context'];
    }



@@ 58,6 49,54 @@ final class Renderer
            return $candidate;
        }

        throw new RendererException(sprintf('Unable to resolve file path: "%s"', $filePath));
        throw new \Exception(sprintf('Unable to resolve file path: "%s"', $filePath));
    }
}

/**
 * Escape for html context
 */
function e(string $s): string
{
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false);
}

/**
 * Explicitly don't escape
 */
function raw(string $s): string
{
    return $s;
}

/**
 * Sanitize for html tag context. Don't use for html tag attribute context!
 */
function sanitize(string $s): string
{
    return filter_var($s, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES);
}

/**
 * Truncate string
 */
function truncate(string $s, int $length = 100, $marker = ' [...]'): string
{
    $s = trim($s);

    if (mb_strlen($s) < $length) {
        return $s;
    }

    $naiveTruncate = mb_substr($s, 0, $length);

    $lastSpace = mb_strrpos($naiveTruncate, ' ');

    if ($lastSpace === false) {
        $lastSpace = $length;
    }

    $properTruncate = mb_substr($s, 0, $lastSpace);

    return $properTruncate . $marker;
}
\ No newline at end of file

M src/Shell.php => src/Shell.php +2 -4
@@ 3,8 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

final class ShellException extends Exception {}

final class Shell
{
    public static function execute(string $command, array $arguments = []): array


@@ 20,9 18,9 @@ final class Shell
            case 0:
                return $result;
            case 127:
                throw new ShellException(sprintf('Command not found: "%s"', $command));
                throw new \Exception(sprintf('Command not found: "%s"', $command));
            default:
                throw new ShellException(sprintf('Unsuccessful: "%s"', $command));
                throw new \Exception(sprintf('Unsuccessful: "%s"', $command));
        }
    }
}

M src/Url.php => src/Url.php +2 -3
@@ 3,8 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

final class UrlException extends Exception {}

function url(string $url): Url
{
    return Url::fromString($url);


@@ 26,7 24,7 @@ final class Url implements \JsonSerializable
        $url = trim($url);

        if (!self::validate($url)) {
            throw new UrlException(sprintf('Not valid: "%s"', $url));
            throw new \Exception(sprintf('Not valid: "%s"', $url));
        }

        return (new self)


@@ 175,6 173,7 @@ final class Url implements \JsonSerializable
            $path = rtrim($path, '/') . '/';
        }

        // todo: remove trailing / if the path, query and fragment is empty
        return sprintf(
            '%s://%s%s%s%s%s',
            $this->scheme,

M src/common.php => src/common.php +1 -50
@@ 3,54 3,5 @@ declare(strict_types=1);

namespace Thirdplace;

class Exception extends \Exception {}
final class HttpException extends \Exception {}

final class HttpException extends Exception {}

/**
 * Escape for html context
 */
function e(string $s): string
{
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false);
}

/**
 * Explicitly don't escape
 */
function raw(string $s): string
{
    return $s;
}

/**
 * Sanitize for html tag context. Don't use for html tag attribute context!
 */
function sanitize(string $s): string
{
    return filter_var($s, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES);
}

/**
 * Truncate string
 */
function truncate(string $s, int $length = 100, $marker = ' [...]'): string
{
    $s = trim($s);

    if (mb_strlen($s) < $length) {
        return $s;
    }

    $naiveTruncate = mb_substr($s, 0, $length);

    $lastSpace = mb_strrpos($naiveTruncate, ' ');

    if ($lastSpace === false) {
        $lastSpace = $length;
    }

    $properTruncate = mb_substr($s, 0, $lastSpace);

    return $properTruncate . $marker;
}
\ No newline at end of file

M src/http/Cookie.php => src/http/Cookie.php +4 -1
@@ 24,6 24,9 @@ final class Cookie

    public function send(): void
    {
        setcookie($this->name, $this->value);
        $options = [

        ];
        setcookie($this->name, $this->value, $options);
    }
}
\ No newline at end of file

M src/http/CurlHttpClient.php => src/http/CurlHttpClient.php +10 -3
@@ 13,6 13,7 @@ final class CurlHttpClient implements HttpClient
        'max_redirs'        => 5,
        'headers'           => [],
        'body'              => null,
        'max_file_size'     => 5 * 1024**2 // 5MiB
    ];

    private $ch;


@@ 39,6 40,7 @@ final class CurlHttpClient implements HttpClient
        curl_setopt($this->ch, CURLOPT_TIMEOUT,                 $config['timeout']);
        curl_setopt($this->ch, CURLOPT_FOLLOWLOCATION,          $config['follow_location']);
        curl_setopt($this->ch, CURLOPT_MAXREDIRS,               $config['max_redirs']);
        curl_setopt($this->ch, CURLOPT_MAXFILESIZE,             $config['max_file_size']);
        curl_setopt($this->ch, CURLOPT_ENCODING,                '');

        if ($method === 'GET') {


@@ 60,12 62,12 @@ final class CurlHttpClient implements HttpClient
        curl_setopt($this->ch, CURLOPT_HTTPHEADER, $requestHeaders);

        $responseHeaders = [];

        curl_setopt($this->ch, CURLOPT_HEADERFUNCTION, function ($ch, $rawHeader) use (&$responseHeaders) {
            $len = strlen($rawHeader);
            if (preg_match('#^HTTP/(2|1.1|1.0)#', $rawHeader) || $rawHeader==="\r\n") {
                return $len;
            }
            // todo: maybe try..catch here
            $header = Header::fromString($rawHeader);

            if (!isset($responseHeaders[$header->name])) {


@@ 79,12 81,17 @@ final class CurlHttpClient implements HttpClient
        $body = curl_exec($this->ch);

        if ($body === false) {
            throw new HttpException($url . ': ' . curl_error($this->ch), curl_errno($this->ch));
            throw new HttpException(sprintf('%s: %s', $url, curl_error($this->ch)), curl_errno($this->ch));
        }

        $cookies = array_map(fn($cookie) => Cookie::fromString($cookie), $responseHeaders['set-cookie'] ?? []);

        return new Response($body, curl_getinfo($this->ch, CURLINFO_RESPONSE_CODE), $responseHeaders, $cookies);
        return new Response(
            $body,
            curl_getinfo($this->ch, CURLINFO_RESPONSE_CODE),
            $responseHeaders,
            $cookies
        );
    }

    public function __destruct()

M src/http/Header.php => src/http/Header.php +1 -3
@@ 3,8 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

final class HeaderException extends Exception {}

final class Header
{
    public const APPLICATION_ATOM_XML   = 'application/atom+xml';


@@ 27,7 25,7 @@ final class Header
        $header = explode(':', $rawHeader);

        if (count($header) === 1) {
            throw new HeaderException(sprintf('Invalid header string: "%s"', $rawHeader));
            throw new \Exception(sprintf('Invalid header string: "%s"', $rawHeader));
        }

        $name = mb_strtolower(trim($header[0]));

M src/http/Response.php => src/http/Response.php +1 -0
@@ 15,6 15,7 @@ function text(string $body, int $code = 200): Response

function json(array $body, int $code = 200): Response
{
    // todo: remove dep
    $json = Json::encode($body, JSON_PRETTY_PRINT);
    return response($json, $code)->withHeader('content-type', 'application/json');
}

M src/logger/StreamHandler.php => src/logger/StreamHandler.php +3 -1
@@ 31,7 31,9 @@ final class StreamHandler implements Handler
        if ($record['context'] === []) {
            $record['context'] = '';
        } else {
            $record['context'] = Json::encode($record['context'], JSON_PRETTY_PRINT) ?: '["Unable to json encode context"]';
            // todo: remove dep
            $json = Json::encode($record['context'], JSON_PRETTY_PRINT) ?: '["Unable to json encode context"]';
            $record['context'] = $json;
        }

        $result = sprintf(

M test/container.php => test/container.php +3 -1
@@ 15,4 15,6 @@ assertSame(2, $sut['two']);
$sut['test'] = fn($c) => $c['two'];
assertSame(2, $sut['test']);

expectException(fn() => $sut['foo'] = 'bar');
\ No newline at end of file
expectException(function() use ($sut) {
    $sut['foo'] = 'bar';
});
\ No newline at end of file