Skip to content

Commit

Permalink
Merge pull request #1 from istiak-tridip/develop
Browse files Browse the repository at this point in the history
Merge develop branch
  • Loading branch information
istiak-tridip authored Feb 25, 2021
2 parents 99064a9 + b17a4e3 commit c8fb140
Show file tree
Hide file tree
Showing 12 changed files with 373 additions and 4 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.idea
vendor
.php_cs.cache
composer.lock
36 changes: 36 additions & 0 deletions .php_cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

$finder = Symfony\Component\Finder\Finder::create()
->in([
__DIR__ . '/app',
])
->name('*.php')
->ignoreDotFiles(true)
->ignoreVCS(true);

return PhpCsFixer\Config::create()
->setRules([
'@PSR2' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sortAlgorithm' => 'alpha'],
'no_unused_imports' => true,
'trailing_comma_in_multiline_array' => true,
'phpdoc_scalar' => true,
'unary_operator_spaces' => true,
'blank_line_before_statement' => [
'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
],
'phpdoc_single_line_var_spacing' => true,
'phpdoc_var_without_name' => true,
'class_attributes_separation' => [
'elements' => [
'method',
],
],
'method_argument_space' => [
'on_multiline' => 'ensure_fully_multiline',
'keep_multiple_spaces_after_comma' => true,
],
'single_trait_insert_per_statement' => true,
])
->setFinder($finder);
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
### About
## About

This project is a simple implementation of "Origin Pull" CDN with cache support written in PHP 7.4.

#### Origin Pull CDN

---
Origin pull CDN is a type of CDN where you don't have to upload files to the CDN server instead CDN does it for you. You only rewrite URLs to point to the CDN. When asked for a specific file, the CDN will first go to the
original server, pull the file, cache and serve it.
original server, pull the file, cache, and serve it.
111 changes: 111 additions & 0 deletions app/Response.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace App;

use App\Responses\ErrorResponse;
use App\Responses\NotAllowedResponse;
use App\Responses\SuccessResponse;
use Doctrine\Common\Cache\FilesystemCache;
use Exception;
use GuzzleHttp\Client;
use Throwable;

class Response
{
protected array $config;

protected FilesystemCache $cache;

public function __construct(array $config)
{
$this->config = $config;
}

public function send(string $path): void
{
$this->checkIfPathIsAllowed($path);

try {
$key = $this->getCacheKey($path);
if (!$this->cache()->contains($key)) {
$this->fetchFileContent($path);
}

$content = $this->cache()->fetch($path);
$response = new SuccessResponse($content);
$response->send();
} catch (Throwable $exception) {
$response = new ErrorResponse($exception);
$response->send();
}
}

protected function fetchFileContent(string $path): void
{
$client = $this->client();
$response = $client->get($path);

$key = $this->getCacheKey($path);
if (!$this->cacheFileContent($key, $response->getBody())) {
throw new Exception("Failed to cache file content.");
}
}

protected function cacheFileContent(string $path, string $contents): bool
{
return $this->cache()->save(
$path,
$contents,
$this->config["cache"]["lifetime"]
);
}

protected function client(): Client
{
return new Client([
"base_uri" => $this->config["origin"]["base_uri"],
"timeout" => $this->config["origin"]["timeout"],
"headers" => [
"User-Agent" => "OPCDN-BOT/0.1.0",
],
]);
}

protected function cache(): FilesystemCache
{
return $this->cache ??=
new FilesystemCache($this->config["cache"]["directory"], ".it");
}

protected function checkIfPathIsAllowed(string $path): void
{
if (empty($paths = $this->config["origin"]["paths"])) {
return;
}

foreach ($paths as $pattern) {
if ($this->pathMatches($pattern, $path) === false) {
$response = new NotAllowedResponse($path);
$response->send();
}
}
}

protected function pathMatches(string $pattern, string $path): bool
{
if ($pattern === $path) {
return true;
}

$path = trim($path, "/");

return preg_match('#^' . $pattern . '\z#u', $path) === 1;
}

protected function getCacheKey(string $path): string
{
$host = parse_url($this->config["origin"]["base_uri"], PHP_URL_HOST);

return $host . "::" . $path;
}
}
50 changes: 50 additions & 0 deletions app/Responses/ErrorResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Responses;

use GuzzleHttp\Exception\RequestException;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class ErrorResponse implements ResponseInterface
{
private Throwable $exception;

public function __construct(Throwable $exception)
{
$this->exception = $exception;
}

public function send(): void
{
$response = new Response();
$response->setContent($this->getContent());
$response->setStatusCode($this->getStatusCode());

$response->send();
}

public function getContent(): string
{
return Response::$statusTexts[$this->getStatusCode()] ?? "Unknown Error";
}

public function getStatusCode(): int
{
if ($this->exception instanceof RequestException && $this->exception->hasResponse()) {
$statusCode = $this->exception->getResponse()->getStatusCode();
}

return $statusCode ?? Response::HTTP_FAILED_DEPENDENCY;
}

public function getContentType(): string
{
return "";
}

public function getContentLength(): int
{
return 0;
}
}
45 changes: 45 additions & 0 deletions app/Responses/NotAllowedResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Responses;

use Symfony\Component\HttpFoundation\Response;

class NotAllowedResponse implements ResponseInterface
{
private string $path;

public function __construct(string $path)
{
$this->path = $path;
}

public function send(): void
{
$response = new Response();
$response->setContent($this->getContent());
$response->setStatusCode($this->getStatusCode());

$response->send();
exit;
}

public function getContent(): string
{
return "Path ({$this->path}) is not allowed.";
}

public function getStatusCode(): int
{
return Response::HTTP_BAD_REQUEST;
}

public function getContentType(): string
{
return "";
}

public function getContentLength(): int
{
return 0;
}
}
16 changes: 16 additions & 0 deletions app/Responses/ResponseInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace App\Responses;

interface ResponseInterface
{
public function send(): void;

public function getContent(): string;

public function getStatusCode(): int;

public function getContentType(): string;

public function getContentLength(): int;
}
47 changes: 47 additions & 0 deletions app/Responses/SuccessResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Responses;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class SuccessResponse implements ResponseInterface
{
private string $content;

public function __construct(string $content)
{
$this->content = $content;
}

public function send(): void
{
$response = new SymfonyResponse();
$response->setContent($this->getContent());
$response->setStatusCode($this->getStatusCode());
$response->headers->set("Content-Type", $this->getContentType());
$response->headers->set("Content-Length", $this->getContentLength());

$response->send();
}

public function getContent(): string
{
return $this->content;
}

public function getStatusCode(): int
{
return Response::HTTP_OK;
}

public function getContentType(): string
{
return finfo_buffer(finfo_open(FILEINFO_MIME_TYPE), $this->getContent());
}

public function getContentLength(): int
{
return strlen($this->getContent());
}
}
17 changes: 17 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "istiak/origin-pull-cdn",
"description": "A simple implementation of \"Origin Pull\" CDN with cache support.",
"type": "project",
"require": {
"php": "^7.4|^8.0",
"ext-fileinfo": "*",
"doctrine/cache": "^1.10",
"guzzlehttp/guzzle": "^7.2",
"symfony/http-foundation": "^5.2"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}
26 changes: 26 additions & 0 deletions config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

return [
"origin" => [
// Base URI is used with relative requests
"base_uri" => "https://istiaktridip.net",

// Float describing the total timeout of the request in seconds.
// Use 0 to wait indefinitely.
"timeout" => 5.0,

// The origin URIs that should be served from this CDN.
// Leave the array empty if you want to server all origin URIs.
"paths" => [
"images/.*",
],
],
"cache" => [
// Absolute Path of the directory where cache entries will be stored.
"directory" => __DIR__ . "/storage",

// The lifetime in number of seconds for a cache entry.
// Use 0 to store the entry indefinitely.
"lifetime" => 0,
],
];
16 changes: 16 additions & 0 deletions public/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
require_once __DIR__ . "/../vendor/autoload.php";

error_reporting(E_ALL);
ini_set("display_errors", "On");

/**
* Get the config & requested path
*/
$config = require __DIR__ . "/../config.php";
$urlPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

/**
* Run The Application
*/
(new \App\Response($config))->send($urlPath);
2 changes: 2 additions & 0 deletions storage/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore

0 comments on commit c8fb140

Please sign in to comment.