diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df286f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +vendor +.php_cs.cache +composer.lock diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..98c92ee --- /dev/null +++ b/.php_cs @@ -0,0 +1,36 @@ +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); diff --git a/README.md b/README.md index 8f72b58..fde85e1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/Response.php b/app/Response.php new file mode 100644 index 0000000..562fdc3 --- /dev/null +++ b/app/Response.php @@ -0,0 +1,111 @@ +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; + } +} diff --git a/app/Responses/ErrorResponse.php b/app/Responses/ErrorResponse.php new file mode 100644 index 0000000..748a114 --- /dev/null +++ b/app/Responses/ErrorResponse.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/app/Responses/NotAllowedResponse.php b/app/Responses/NotAllowedResponse.php new file mode 100644 index 0000000..cb0cad5 --- /dev/null +++ b/app/Responses/NotAllowedResponse.php @@ -0,0 +1,45 @@ +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; + } +} diff --git a/app/Responses/ResponseInterface.php b/app/Responses/ResponseInterface.php new file mode 100644 index 0000000..f636385 --- /dev/null +++ b/app/Responses/ResponseInterface.php @@ -0,0 +1,16 @@ +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()); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..54fbce1 --- /dev/null +++ b/composer.json @@ -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/" + } + } +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..a18336e --- /dev/null +++ b/config.php @@ -0,0 +1,26 @@ + [ + // 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, + ], +]; diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..0dde4d2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,16 @@ +send($urlPath); diff --git a/storage/.gitignore b/storage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore