├── foundation ├── exceptions │ ├── Exception.php │ ├── UnknownVersionNameException.php │ └── ImageNotFoundException.php ├── contracts │ └── images │ │ ├── Generator.php │ │ ├── Processor.php │ │ └── Storage.php ├── bus │ ├── commands │ │ ├── DeleteImageCommand.php │ │ ├── MakeImageVersionCommand.php │ │ └── StoreImageCommand.php │ └── handlers │ │ ├── DeleteImageCommandHandler.php │ │ ├── MakeImageVersionCommandHandler.php │ │ └── StoreImageCommandHandler.php ├── images │ ├── processor │ │ ├── manipulators │ │ │ ├── Manipulator.php │ │ │ ├── Optimize.php │ │ │ ├── Fit.php │ │ │ └── Resize.php │ │ └── Processor.php │ ├── Generator.php │ └── Storage.php ├── entities │ └── Image.php └── Util.php ├── app ├── config │ ├── settings.php │ ├── definitions │ │ ├── signer.php │ │ ├── http-cache.php │ │ ├── auth.php │ │ ├── logger.php │ │ ├── command-bus.php │ │ └── images.php │ ├── definitions.php │ └── decorators.php ├── routes.php ├── exceptions │ ├── BadRequestException.php │ ├── UnauthorizedException.php │ └── HttpException.php ├── signer │ └── Signer.php ├── Sources.php ├── actions │ ├── DeleteAction.php │ ├── StoreAction.php │ └── GetAction.php ├── App.php ├── handlers │ ├── ErrorLoggingDecorator.php │ └── HttpErrorDecorator.php ├── factories │ └── CommandBus.php └── middleware │ └── Authenticate.php ├── .env.example ├── bootstrap └── functions.php ├── LICENSE ├── composer.json └── README.md /foundation/exceptions/Exception.php: -------------------------------------------------------------------------------- 1 | dirname(dirname(__DIR__)), 8 | 'storage-dir' => string('{root-dir}/storage'), 9 | 10 | 'app.name' => env('APP_NAME', 'hermitage'), 11 | ]; 12 | -------------------------------------------------------------------------------- /app/config/definitions/signer.php: -------------------------------------------------------------------------------- 1 | object(Signer::class)->constructor(env('SIGNER_ALGORITHM', 'sha256')), 10 | Signer::class => get('signer'), 11 | ]; 12 | -------------------------------------------------------------------------------- /foundation/exceptions/UnknownVersionNameException.php: -------------------------------------------------------------------------------- 1 | object(Cache::class) 10 | ->constructorParameter('type', env('HTTP_CACHE_TYPE', 'public')) 11 | ->constructorParameter('maxAge', env('HTTP_CACHE_MAX_AGE', 315360000)), 12 | ]; 13 | -------------------------------------------------------------------------------- /app/config/definitions.php: -------------------------------------------------------------------------------- 1 | object(Authenticate::class) 10 | ->constructorParameter('secret', env('AUTH_SECRET')) 11 | ->constructorParameter('timestampExpires', env('AUTH_TIMESTAMP_EXPIRES', 120)), 12 | ]; 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ### 2 | # Auth 3 | ## 4 | AUTH_SECRET=changeme 5 | AUTH_TIMESTAMP_EXPIRES=120 # seconds 6 | 7 | ### 8 | # Adapter 9 | ## 10 | # Local 11 | STORAGE_ADAPTER=local 12 | 13 | # AWS S3 14 | #STORAGE_ADAPTER=s3 15 | #STORAGE_S3_REGION= 16 | #STORAGE_S3_BUCKET= 17 | #STORAGE_S3_KEY= 18 | #STORAGE_S3_SECRET= 19 | 20 | ### 21 | # Advanced 22 | ## 23 | #APP_NAME=hermitage 24 | #LOGGER_PATH= 25 | #SIGNER_ALGORITHM=sha256 26 | #HTTP_CACHE_TYPE=public 27 | #HTTP_CACHE_MAX_AGE=315360000 28 | -------------------------------------------------------------------------------- /app/routes.php: -------------------------------------------------------------------------------- 1 | get('/{filename:.+}', actions\GetAction::class)->add(Cache::class); 9 | 10 | $app->group('/', function () { 11 | /** @var \livetyping\hermitage\app\App $this */ 12 | $this->post('', actions\StoreAction::class); 13 | $this->delete('{filename:.+}', actions\DeleteAction::class); 14 | })->add(Authenticate::class); 15 | -------------------------------------------------------------------------------- /app/config/decorators.php: -------------------------------------------------------------------------------- 1 | decorate(function ($previous, ContainerInterface $c) { 10 | return new ErrorLoggingDecorator( 11 | $c->get('logger'), 12 | new HttpErrorDecorator($previous) 13 | ); 14 | }), 15 | 'phpErrorHandler' => decorate(function ($previous, ContainerInterface $c) { 16 | return new ErrorLoggingDecorator($c->get('logger'), $previous); 17 | }), 18 | ]; 19 | -------------------------------------------------------------------------------- /bootstrap/functions.php: -------------------------------------------------------------------------------- 1 | load(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /foundation/bus/commands/DeleteImageCommand.php: -------------------------------------------------------------------------------- 1 | path = $path; 23 | } 24 | 25 | /** 26 | * @return string 27 | */ 28 | public function getPath(): string 29 | { 30 | return $this->path; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/exceptions/BadRequestException.php: -------------------------------------------------------------------------------- 1 | configure($config); 22 | } 23 | 24 | /** 25 | * @param \Intervention\Image\Image $image 26 | * 27 | * @return void 28 | */ 29 | abstract public function run(Image $image); 30 | 31 | /** 32 | * @param array $config 33 | * 34 | * @return void 35 | */ 36 | abstract protected function configure(array $config); 37 | } 38 | -------------------------------------------------------------------------------- /app/config/definitions/logger.php: -------------------------------------------------------------------------------- 1 | get('app.name'), 15 | 'logger.path' => env('LOGGER_PATH', string('{storage-dir}/logs/app.log')), 16 | 'logger.handlers' => [object(StreamHandler::class)->constructor(get('logger.path'))], 17 | 'logger.processors' => [ 18 | object(PsrLogMessageProcessor::class), 19 | object(WebProcessor::class), 20 | ], 21 | 22 | 'logger' => object(Logger::class)->constructor( 23 | get('logger.name'), 24 | get('logger.handlers'), 25 | get('logger.processors') 26 | ), 27 | LoggerInterface::class => get('logger'), 28 | ]; 29 | -------------------------------------------------------------------------------- /foundation/contracts/images/Processor.php: -------------------------------------------------------------------------------- 1 | path = $path; 27 | 28 | parent::__construct('Image not found at path: ' . $this->getPath(), $code, $previous); 29 | } 30 | 31 | /** 32 | * @return string 33 | */ 34 | public function getPath() 35 | { 36 | return $this->path; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/exceptions/HttpException.php: -------------------------------------------------------------------------------- 1 | statusCode = $status; 28 | 29 | parent::__construct($message, $code, $previous); 30 | } 31 | 32 | /** 33 | * @return int 34 | */ 35 | public function getStatusCode(): int 36 | { 37 | return $this->statusCode; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public function getName(): string 44 | { 45 | return 'Error'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /foundation/bus/commands/MakeImageVersionCommand.php: -------------------------------------------------------------------------------- 1 | pathToOriginal = $pathToOriginal; 27 | $this->version = $version; 28 | } 29 | 30 | /** 31 | * @return string 32 | */ 33 | public function getPathToOriginal(): string 34 | { 35 | return $this->pathToOriginal; 36 | } 37 | 38 | /** 39 | * @return string 40 | */ 41 | public function getVersion(): string 42 | { 43 | return $this->version; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 LiveTyping 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /foundation/bus/handlers/DeleteImageCommandHandler.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 26 | } 27 | 28 | /** 29 | * @param \livetyping\hermitage\foundation\bus\commands\DeleteImageCommand $command 30 | */ 31 | public function handle(DeleteImageCommand $command) 32 | { 33 | $image = $this->storage->get($command->getPath()); 34 | $this->storage->delete($image); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /foundation/contracts/images/Storage.php: -------------------------------------------------------------------------------- 1 | [], 13 | 14 | 'command-bus' => factory([CommandBus::class, 'create']), 15 | MessageBus::class => get('command-bus'), 16 | 17 | 'command-bus.command-handler-map' => [ 18 | commands\StoreImageCommand::class => handlers\StoreImageCommandHandler::class, 19 | commands\MakeImageVersionCommand::class => handlers\MakeImageVersionCommandHandler::class, 20 | commands\DeleteImageCommand::class => handlers\DeleteImageCommandHandler::class, 21 | ], 22 | 23 | // command handlers 24 | handlers\StoreImageCommandHandler::class => object(handlers\StoreImageCommandHandler::class), 25 | handlers\MakeImageVersionCommandHandler::class => object(handlers\MakeImageVersionCommandHandler::class), 26 | handlers\DeleteImageCommandHandler::class => object(handlers\DeleteImageCommandHandler::class), 27 | ]; 28 | -------------------------------------------------------------------------------- /app/signer/Signer.php: -------------------------------------------------------------------------------- 1 | algorithm = $algorithm; 23 | } 24 | 25 | /** 26 | * @param string $data 27 | * @param string $secret 28 | * 29 | * @return string 30 | */ 31 | public function sign(string $data, string $secret): string 32 | { 33 | $signature = hash_hmac($this->algorithm, $data, $secret); 34 | 35 | return $signature; 36 | } 37 | 38 | /** 39 | * @param string $signature 40 | * @param string $data 41 | * @param string $secret 42 | * 43 | * @return bool 44 | */ 45 | public function verify(string $signature, string $data, string $secret): bool 46 | { 47 | $compareSignature = $this->sign($data, $secret); 48 | 49 | return $compareSignature === $signature; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /foundation/images/processor/manipulators/Optimize.php: -------------------------------------------------------------------------------- 1 | aspectRatio(); 31 | $constraint->upsize(); 32 | }; 33 | 34 | $image->resize($this->maxWidth, $this->maxHeight, $callback)->interlace($this->interlace); 35 | } 36 | 37 | /** 38 | * @param array $config 39 | */ 40 | protected function configure(array $config) 41 | { 42 | $this->maxWidth = $config['maxWidth'] ?? null; 43 | $this->maxHeight = $config['maxHeight'] ?? null; 44 | $this->interlace = $config['interlace'] ?? true; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /foundation/images/Generator.php: -------------------------------------------------------------------------------- 1 | copy()->firstOfQuarter()->format('Ym'); 31 | $secondOfHour = $date->copy()->minute(0)->second(0)->diffInSeconds($date); 32 | 33 | $parts = []; 34 | $parts[] = ShortId::encode($quarter); 35 | $parts[] = $date->day; 36 | $parts[] = ShortId::encode($secondOfHour); 37 | $parts[] = uniqid(); 38 | 39 | return implode('/', $parts); 40 | } 41 | 42 | /** 43 | * @return string 44 | */ 45 | public function path(): string 46 | { 47 | $path = $this->dirname() . '/' . $this->name(); 48 | 49 | return trim($path, '/'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livetyping/hermitage", 3 | "description": "RESTful image server", 4 | "license": "MIT", 5 | "keywords": ["image", "storage"], 6 | "authors": [ 7 | { 8 | "name": "Ivan Kudinov", 9 | "email": "i.kudinov@frostealth.ru" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.0", 14 | "ext-apcu": "*", 15 | "slim/slim": "~3.4", 16 | "php-di/slim-bridge": "~1.0", 17 | "doctrine/cache": "~1.4", 18 | "monolog/monolog": "~1.19", 19 | "simple-bus/message-bus": "~2.2", 20 | "vlucas/phpdotenv": "~2.0", 21 | "league/flysystem": "~1.0", 22 | "league/flysystem-aws-s3-v3": "~1.0", 23 | "league/flysystem-cached-adapter": "~1.0", 24 | "nesbot/carbon": "~1.21", 25 | "frostealth/php-shortid-helper": "~1.0", 26 | "slim/http-cache": "~0.3", 27 | "intervention/image": "~2.3", 28 | "beberlei/assert": "~2.0" 29 | }, 30 | "require-dev": { 31 | "squizlabs/php_codesniffer": "~2.3", 32 | "phpro/grumphp": "~0.9" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "livetyping\\hermitage\\app\\": "app/", 37 | "livetyping\\hermitage\\foundation\\": "foundation/" 38 | }, 39 | "files": [ 40 | "bootstrap/functions.php" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Sources.php: -------------------------------------------------------------------------------- 1 | files = $this->core(); 23 | foreach ($files as $file) { 24 | $this->add((string)$file); 25 | } 26 | } 27 | 28 | /** 29 | * Adds file containing definitions 30 | * 31 | * @param string $file the name of a file containing definitions 32 | * 33 | * @return $this 34 | */ 35 | public function add(string $file) 36 | { 37 | $this->files[] = $file; 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * Returns the list of files containing definitions 44 | * 45 | * @return array 46 | */ 47 | public function all(): array 48 | { 49 | return $this->files; 50 | } 51 | 52 | /** 53 | * @return string[] 54 | */ 55 | protected function core(): array 56 | { 57 | return [ 58 | __DIR__ . '/config/settings.php', 59 | __DIR__ . '/config/definitions.php', 60 | __DIR__ . '/config/decorators.php' 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /foundation/bus/commands/StoreImageCommand.php: -------------------------------------------------------------------------------- 1 | mimeType = $mimeType; 30 | $this->binary = $binary; 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function getMimeType(): string 37 | { 38 | return $this->mimeType; 39 | } 40 | 41 | /** 42 | * @return string 43 | */ 44 | public function getBinary(): string 45 | { 46 | return $this->binary; 47 | } 48 | 49 | /** 50 | * @return string 51 | */ 52 | public function getPath(): string 53 | { 54 | return (string)$this->path; 55 | } 56 | 57 | /** 58 | * @param string $path 59 | */ 60 | public function setPath(string $path) 61 | { 62 | $this->path = $path; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/actions/DeleteAction.php: -------------------------------------------------------------------------------- 1 | bus = $bus; 28 | } 29 | 30 | /** 31 | * @param string $filename 32 | * @param \Psr\Http\Message\ServerRequestInterface $request 33 | * @param \Psr\Http\Message\ResponseInterface $response 34 | * 35 | * @return \Psr\Http\Message\ResponseInterface 36 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 37 | */ 38 | public function __invoke(string $filename, Request $request, Response $response): Response 39 | { 40 | $command = new DeleteImageCommand($filename); 41 | $this->bus->handle($command); 42 | 43 | return $response->withStatus(204); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /foundation/images/processor/manipulators/Fit.php: -------------------------------------------------------------------------------- 1 | upsize(); 36 | }; 37 | 38 | $image->fit($this->width, $this->height, $callback, $this->position)->interlace($this->interlace); 39 | } 40 | 41 | /** 42 | * @param array $config 43 | * 44 | * @return void 45 | */ 46 | protected function configure(array $config) 47 | { 48 | $this->width = $config['width'] ?? null; 49 | $this->height = $config['height'] ?? null; 50 | $this->position = $config['position'] ?? 'center'; 51 | $this->interlace = $config['interlace'] ?? true; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /foundation/images/processor/manipulators/Resize.php: -------------------------------------------------------------------------------- 1 | aspectRatio) { 34 | $constraint->aspectRatio(); 35 | } 36 | $constraint->upsize(); 37 | }; 38 | 39 | $image->resize($this->width, $this->height, $callback)->interlace($this->interlace); 40 | } 41 | 42 | /** 43 | * @param array $config 44 | */ 45 | protected function configure(array $config) 46 | { 47 | $this->width = $config['width'] ?? null; 48 | $this->height = $config['height'] ?? null; 49 | $this->aspectRatio = $config['aspectRatio'] ?? true; 50 | $this->interlace = $config['interlace'] ?? true; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/App.php: -------------------------------------------------------------------------------- 1 | sources = $sources; 32 | 33 | parent::__construct(); 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | protected function configureContainer(ContainerBuilder $builder) 40 | { 41 | $builder->setDefinitionCache(new ApcuCache()); 42 | 43 | foreach ($this->sources->all() as $source) { 44 | $builder->addDefinitions($source); 45 | } 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | protected function handleException(Exception $e, Request $request, Response $response) 52 | { 53 | if (($e instanceof ImageNotFoundException) || ($e instanceof UnknownVersionNameException)) { 54 | $e = new NotFoundException($request, $response); 55 | } 56 | 57 | return parent::handleException($e, $request, $response); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/actions/StoreAction.php: -------------------------------------------------------------------------------- 1 | bus = $bus; 30 | } 31 | 32 | /** 33 | * @param \Psr\Http\Message\ServerRequestInterface $request 34 | * @param \Slim\Http\Response $response 35 | * 36 | * @return \Slim\Http\Response 37 | * @throws \livetyping\hermitage\app\exceptions\BadRequestException 38 | */ 39 | public function __invoke(Request $request, Response $response): Response 40 | { 41 | $mime = (string)current($request->getHeader('Content-Type')); 42 | $binary = (string)$request->getBody(); 43 | 44 | if (empty($mime) || empty($binary) || !in_array($mime, Util::supportedMimeTypes())) { 45 | throw new BadRequestException('Invalid mime-type or body.'); 46 | } 47 | 48 | $command = new StoreImageCommand($mime, $binary); 49 | $this->bus->handle($command); 50 | 51 | return $response->withStatus(201)->withJson(['filename' => $command->getPath()]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /foundation/bus/handlers/MakeImageVersionCommandHandler.php: -------------------------------------------------------------------------------- 1 | processor = $processor; 31 | $this->storage = $storage; 32 | } 33 | 34 | /** 35 | * @param \livetyping\hermitage\foundation\bus\commands\MakeImageVersionCommand $command 36 | * 37 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 38 | * @throws \livetyping\hermitage\foundation\exceptions\UnknownVersionNameException 39 | */ 40 | public function handle(MakeImageVersionCommand $command) 41 | { 42 | $image = $this->storage->get($command->getPathToOriginal()); 43 | $image = $this->processor->make($image, $command->getVersion()); 44 | 45 | $this->storage->put($image); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /foundation/bus/handlers/StoreImageCommandHandler.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 37 | $this->processor = $processor; 38 | $this->generator = $generator; 39 | } 40 | 41 | /** 42 | * @param \livetyping\hermitage\foundation\bus\commands\StoreImageCommand $command 43 | */ 44 | public function handle(StoreImageCommand $command) 45 | { 46 | $image = new Image($command->getBinary(), $command->getMimeType(), $this->generator->path()); 47 | $image = $this->processor->optimize($image); 48 | 49 | $this->storage->put($image); 50 | $command->setPath($image->getPath()); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/handlers/ErrorLoggingDecorator.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 38 | $this->handler = $handler; 39 | } 40 | 41 | /** 42 | * @param \Psr\Http\Message\ServerRequestInterface $request 43 | * @param \Psr\Http\Message\ResponseInterface $response 44 | * @param \Throwable $error 45 | * 46 | * @return \Psr\Http\Message\ResponseInterface 47 | */ 48 | public function __invoke(Request $request, Response $response, Throwable $error): Response 49 | { 50 | if (!$this->shouldntReport($error)) { 51 | $this->logger->critical($error->getMessage()); 52 | } 53 | 54 | return call_user_func($this->handler, $request, $response, $error); 55 | } 56 | 57 | /** 58 | * @param \Throwable $error 59 | * 60 | * @return bool 61 | */ 62 | protected function shouldntReport(Throwable $error): bool 63 | { 64 | if (!($error instanceof \Exception)) { 65 | return false; 66 | } 67 | 68 | foreach ($this->dontReport as $type) { 69 | if ($error instanceof $type) { 70 | return true; 71 | } 72 | } 73 | 74 | return false; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/factories/CommandBus.php: -------------------------------------------------------------------------------- 1 | container = $container; 32 | } 33 | 34 | /** 35 | * @return \SimpleBus\Message\Bus\MessageBus 36 | */ 37 | public function create(): MessageBus 38 | { 39 | $bus = new MessageBusSupportingMiddleware($this->middleware()); 40 | $bus->appendMiddleware(new DelegatesToMessageHandlerMiddleware($this->createHandlerResolver())); 41 | 42 | return $bus; 43 | } 44 | 45 | /** 46 | * @return \SimpleBus\Message\Handler\Resolver\MessageHandlerResolver 47 | */ 48 | protected function createHandlerResolver() 49 | { 50 | $serviceLocator = function ($id) { 51 | return $this->container->get($id); 52 | }; 53 | 54 | $callableMap = new CallableMap( 55 | $this->container->get('command-bus.command-handler-map'), 56 | new ServiceLocatorAwareCallableResolver($serviceLocator) 57 | ); 58 | 59 | return new NameBasedMessageHandlerResolver(new ClassBasedNameResolver(), $callableMap); 60 | } 61 | 62 | /** 63 | * @return array 64 | */ 65 | protected function middleware() 66 | { 67 | $middleware = []; 68 | if ($this->container->has('command-bus.middleware')) { 69 | $middleware = $this->container->get('command-bus.middleware'); 70 | } 71 | 72 | return $middleware; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /foundation/images/Storage.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 28 | } 29 | 30 | /** 31 | * @param string $path 32 | * 33 | * @return \livetyping\hermitage\foundation\entities\Image 34 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 35 | */ 36 | public function get(string $path): Image 37 | { 38 | $this->assertPresent($path); 39 | 40 | $image = new Image( 41 | $this->filesystem->read($path), 42 | $this->filesystem->getMimetype($path), 43 | $path 44 | ); 45 | 46 | return $image; 47 | } 48 | 49 | /** 50 | * @param string $path 51 | * 52 | * @return bool 53 | */ 54 | public function has(string $path): bool 55 | { 56 | return $this->filesystem->has($path); 57 | } 58 | 59 | /** 60 | * @param \livetyping\hermitage\foundation\entities\Image $image 61 | */ 62 | public function put(Image $image) 63 | { 64 | $this->filesystem->put( 65 | $image->getPath(), 66 | $image->getBinary(), 67 | ['mimetype' => $image->getMimeType()] 68 | ); 69 | } 70 | 71 | /** 72 | * @param \livetyping\hermitage\foundation\entities\Image $image 73 | * 74 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 75 | */ 76 | public function delete(Image $image) 77 | { 78 | $this->assertPresent($image->getPath()); 79 | 80 | $this->filesystem->deleteDir($image->getDirname()); 81 | } 82 | 83 | /** 84 | * @param string $path 85 | * 86 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 87 | */ 88 | protected function assertPresent(string $path) 89 | { 90 | if (!$this->has($path)) { 91 | throw new ImageNotFoundException($path); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /foundation/entities/Image.php: -------------------------------------------------------------------------------- 1 | binary = $binary; 42 | $this->mimeType = $mimeType; 43 | $this->name = Util::name($path); 44 | $this->dirname = Util::dirname($path); 45 | $this->version = Util::version($path); 46 | $this->extension = Util::determineExtensionByMimeType($mimeType); 47 | } 48 | 49 | /** 50 | * @return string 51 | */ 52 | public function getMimeType(): string 53 | { 54 | return (string)$this->mimeType; 55 | } 56 | 57 | /** 58 | * @return string 59 | */ 60 | public function getBinary(): string 61 | { 62 | return (string)$this->binary; 63 | } 64 | 65 | /** 66 | * @return string 67 | */ 68 | public function getVersion(): string 69 | { 70 | return (string)$this->version; 71 | } 72 | 73 | /** 74 | * @return string 75 | */ 76 | public function getDirname(): string 77 | { 78 | return (string)$this->dirname; 79 | } 80 | 81 | /** 82 | * @return string 83 | */ 84 | public function getPath(): string 85 | { 86 | return Util::path( 87 | (string)$this->dirname, 88 | (string)$this->name, 89 | (string)$this->extension, 90 | (string)$this->version 91 | ); 92 | } 93 | 94 | /** 95 | * @param string $binary 96 | * @param string $version 97 | * 98 | * @return \livetyping\hermitage\foundation\entities\Image 99 | */ 100 | public function modify(string $binary, string $version = null): Image 101 | { 102 | $clone = clone $this; 103 | $clone->binary = $binary; 104 | $clone->version = $version ?? $clone->version; 105 | 106 | return $clone; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/actions/GetAction.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 35 | $this->bus = $bus; 36 | } 37 | 38 | /** 39 | * @param string $filename 40 | * @param \Psr\Http\Message\ServerRequestInterface $request 41 | * @param \Psr\Http\Message\ResponseInterface $response 42 | * 43 | * @return \Psr\Http\Message\ResponseInterface 44 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 45 | */ 46 | public function __invoke(string $filename, Request $request, Response $response): Response 47 | { 48 | $this->prepare($filename); 49 | $image = $this->storage->get($filename); 50 | 51 | $body = new Body(fopen('php://temp', 'r+')); 52 | $body->write($image->getBinary()); 53 | 54 | return $response->withHeader('Content-Type', $image->getMimeType())->withBody($body); 55 | } 56 | 57 | /** 58 | * @param string $filename 59 | * 60 | * @return \livetyping\hermitage\foundation\entities\Image 61 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 62 | */ 63 | protected function prepare(string $filename) 64 | { 65 | if (!Util::isOriginal($filename) && !$this->storage->has($filename)) { 66 | $this->makeVersion($filename); 67 | } 68 | } 69 | 70 | /** 71 | * @param string $filename 72 | * 73 | * @throws \livetyping\hermitage\foundation\exceptions\ImageNotFoundException 74 | */ 75 | protected function makeVersion(string $filename) 76 | { 77 | $original = Util::original($filename); 78 | $command = new MakeImageVersionCommand($original, Util::version($filename)); 79 | 80 | $this->bus->handle($command); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/config/definitions/images.php: -------------------------------------------------------------------------------- 1 | [ 24 | 'mini' => [ 25 | 'type' => 'resize', 26 | 'height' => 200, 27 | 'width' => 200, 28 | ], 29 | 'small' => [ 30 | 'type' => 'resize', 31 | 'height' => 400, 32 | 'width' => 400, 33 | ], 34 | 'thumb' => [ 35 | 'type' => 'fit', 36 | 'height' => 100, 37 | 'width' => 100, 38 | ], 39 | ], 40 | 'images.optimization-params' => ['maxHeight' => 800, 'maxWidth' => 800], 41 | 'images.manipulator-map' => [], 42 | 'images.manager-config' => ['driver' => 'gd'], 43 | 44 | // processor 45 | 'images.processor.manager' => object(ImageManager::class)->constructor(get('images.manager-config')), 46 | 'images.processor' => object(Processor::class) 47 | ->constructor(get('images.processor.manager')) 48 | ->method('addManipulatorMap', get('images.manipulator-map')) 49 | ->method('setVersions', get('images.versions')) 50 | ->method('setOptimizationParams', get('images.optimization-params')), 51 | ProcessorContract::class => get('images.processor'), 52 | 53 | // generator 54 | 'images.generator' => object(Generator::class), 55 | GeneratorContract::class => get('images.generator'), 56 | 57 | // storage 58 | 'images.storage.adapter' => env('STORAGE_ADAPTER', 'local'), 59 | 'images.storage' => function (ContainerInterface $c) { 60 | $adapter = $c->get('images.storage.adapter'); 61 | $adapter = $c->get("images.storage.adapters.{$adapter}"); 62 | $adapter = new CachedAdapter($adapter, new Memory()); 63 | 64 | return new Storage(new Filesystem($adapter)); 65 | }, 66 | StorageContract::class => get('images.storage'), 67 | 68 | // local adapter 69 | 'images.storage.adapters.local' => object(Local::class)->constructor(string('{storage-dir}/images')), 70 | 71 | // aws s3 adapter 72 | 'images.storage.adapters.s3' => object(AwsS3Adapter::class) 73 | ->constructor(get('images.storage.adapters.s3.client'), env('STORAGE_S3_BUCKET')), 74 | 'images.storage.adapters.s3.client' => object(S3Client::class) 75 | ->constructor(get('images.storage.adapters.s3.client-config')), 76 | 'images.storage.adapters.s3.client-config' => [ 77 | 'version' => '2006-03-01', 78 | 'region' => env('STORAGE_S3_REGION'), 79 | 'credentials' => get('images.storage.adapters.s3.client-credentials'), 80 | ], 81 | 'images.storage.adapters.s3.client-credentials' => [ 82 | 'key' => env('STORAGE_S3_KEY'), 83 | 'secret' => env('STORAGE_S3_SECRET'), 84 | ], 85 | ]; 86 | -------------------------------------------------------------------------------- /foundation/Util.php: -------------------------------------------------------------------------------- 1 | 'jpg', 17 | 'image/png' => 'png', 18 | 'image/gif' => 'gif', 19 | ]; 20 | 21 | /** 22 | * @param string $path 23 | * 24 | * @return string 25 | */ 26 | public static function name(string $path): string 27 | { 28 | return pathinfo($path, PATHINFO_FILENAME); 29 | } 30 | 31 | /** 32 | * @param string $path 33 | * 34 | * @return string 35 | */ 36 | public static function version(string $path): string 37 | { 38 | return self::separateExtensionAndVersion($path)['ver']; 39 | } 40 | 41 | /** 42 | * @param string $path 43 | * 44 | * @return string 45 | */ 46 | public static function extension(string $path): string 47 | { 48 | return self::separateExtensionAndVersion($path)['ext']; 49 | } 50 | 51 | /** 52 | * @param string $path 53 | * 54 | * @return bool 55 | */ 56 | public static function isOriginal(string $path): bool 57 | { 58 | return empty(self::version($path)); 59 | } 60 | 61 | /** 62 | * @param string $path 63 | * 64 | * @return string 65 | */ 66 | public static function original(string $path): string 67 | { 68 | $result = $path; 69 | $version = self::version($path); 70 | if ($version) { 71 | $version = self::VERSION_SEPARATOR . $version; 72 | $result = str_replace($version, '', $result); 73 | } 74 | 75 | return $result; 76 | } 77 | 78 | /** 79 | * @param string $path 80 | * 81 | * @return string 82 | */ 83 | public static function dirname(string $path): string 84 | { 85 | $dirname = dirname($path); 86 | 87 | return self::normalizeDirname($dirname); 88 | } 89 | 90 | /** 91 | * @param string $dirname 92 | * @param string $filename 93 | * @param string $extension 94 | * @param string $version 95 | * 96 | * @return string 97 | */ 98 | public static function path(string $dirname, string $filename, string $extension, string $version = ''): string 99 | { 100 | $path = self::normalizeDirname($dirname); 101 | $path .= '/' . $filename . '.' . $extension; 102 | $path .= !empty($version) ? self::VERSION_SEPARATOR . $version : ''; 103 | 104 | return ltrim($path, '/'); 105 | } 106 | 107 | /** 108 | * @param string $mime 109 | * 110 | * @return string 111 | */ 112 | public static function determineExtensionByMimeType(string $mime): string 113 | { 114 | return self::$mimeTypes[$mime] ?? ''; 115 | } 116 | 117 | /** 118 | * @param string $dirname 119 | * 120 | * @return string 121 | */ 122 | public static function normalizeDirname(string $dirname): string 123 | { 124 | $result = trim($dirname, '/'); 125 | 126 | return $result === '.' ? '' : $result; 127 | } 128 | 129 | /** 130 | * @return array 131 | */ 132 | public static function supportedMimeTypes(): array 133 | { 134 | return array_keys(self::$mimeTypes); 135 | } 136 | 137 | /** 138 | * @param string $path 139 | * 140 | * @return array 141 | */ 142 | protected static function separateExtensionAndVersion(string $path): array 143 | { 144 | $extension = pathinfo($path, PATHINFO_EXTENSION); 145 | $parts = explode(self::VERSION_SEPARATOR, $extension, 2); 146 | 147 | return [ 148 | 'ext' => $parts[0] ?? '', 149 | 'ver' => $parts[1] ?? '', 150 | ]; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /app/middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | signer = $signer; 40 | $this->secret = $secret; 41 | $this->timestampExpires = $timestampExpires; 42 | } 43 | 44 | /** 45 | * @param \Psr\Http\Message\ServerRequestInterface $request 46 | * @param \Psr\Http\Message\ResponseInterface $response 47 | * @param callable $next 48 | * 49 | * @return \Psr\Http\Message\ResponseInterface 50 | * @throws \livetyping\hermitage\app\exceptions\UnauthorizedException 51 | */ 52 | public function __invoke(Request $request, Response $response, callable $next): Response 53 | { 54 | $timestamp = $this->getTimestampFromRequest($request); 55 | if ($this->timestampHasExpired($timestamp)) { 56 | throw new UnauthorizedException('Timestamp has expired.'); 57 | } 58 | 59 | $signature = $this->getSignatureFromRequest($request); 60 | $data = implode('|', [$request->getMethod(), rtrim($request->getUri(), '/'), $timestamp]); 61 | 62 | if (!$this->signer->verify($signature, $data, $this->secret)) { 63 | throw new UnauthorizedException('Signature is invalid.'); 64 | } 65 | 66 | return $next($request, $response); 67 | } 68 | 69 | /** 70 | * @param \Psr\Http\Message\ServerRequestInterface $request 71 | * 72 | * @return int 73 | * @throws \livetyping\hermitage\app\exceptions\UnauthorizedException 74 | */ 75 | protected function getTimestampFromRequest(Request $request): int 76 | { 77 | if (!$request->hasHeader(self::HEADER_AUTHENTICATE_TIMESTAMP)) { 78 | throw new UnauthorizedException('Timestamp is required.'); 79 | } 80 | 81 | $timestamp = current($request->getHeader(self::HEADER_AUTHENTICATE_TIMESTAMP)); 82 | 83 | if (!is_numeric($timestamp)) { 84 | throw new UnauthorizedException('Timestamp must be an integer.'); 85 | } 86 | 87 | return (int)$timestamp; 88 | } 89 | 90 | /** 91 | * @param \Psr\Http\Message\ServerRequestInterface $request 92 | * 93 | * @return string 94 | * @throws \livetyping\hermitage\app\exceptions\UnauthorizedException 95 | */ 96 | protected function getSignatureFromRequest(Request $request): string 97 | { 98 | if (!$request->hasHeader(self::HEADER_AUTHENTICATE_SIGNATURE)) { 99 | throw new UnauthorizedException('Signature is required.'); 100 | } 101 | 102 | $signature = current($request->getHeader(self::HEADER_AUTHENTICATE_SIGNATURE)); 103 | 104 | return $signature; 105 | } 106 | 107 | /** 108 | * @param int $timestamp 109 | * 110 | * @return bool 111 | */ 112 | protected function timestampHasExpired(int $timestamp): bool 113 | { 114 | $date = Carbon::createFromTimestamp($timestamp, 'UTC'); 115 | $start = Carbon::now('UTC')->subSeconds($this->timestampExpires); 116 | $end = Carbon::now('UTC'); 117 | 118 | return !$date->between($start, $end); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/handlers/HttpErrorDecorator.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 40 | } 41 | 42 | /** 43 | * @param \Psr\Http\Message\ServerRequestInterface $request 44 | * @param \Psr\Http\Message\ResponseInterface $response 45 | * @param \Exception $exception 46 | * 47 | * @return \Psr\Http\Message\ResponseInterface 48 | */ 49 | public function __invoke(Request $request, Response $response, Exception $exception): Response 50 | { 51 | if (!($exception instanceof HttpException)) { 52 | return call_user_func($this->handler, $request, $response, $exception); 53 | } 54 | 55 | $contentType = $this->determineContentType($request); 56 | switch ($contentType) { 57 | case 'application/json': 58 | $output = $this->renderJsonErrorMessage($exception); 59 | break; 60 | 61 | case 'text/xml': 62 | case 'application/xml': 63 | $output = $this->renderXmlErrorMessage($exception); 64 | break; 65 | 66 | case 'text/html': 67 | $output = $this->renderHtmlErrorMessage($exception); 68 | break; 69 | default: 70 | $output = ''; 71 | } 72 | 73 | $body = new Body(fopen('php://temp', 'r+')); 74 | $body->write($output); 75 | 76 | return $response->withStatus($exception->getStatusCode()) 77 | ->withHeader('Content-Type', $contentType) 78 | ->withBody($body); 79 | } 80 | 81 | /** 82 | * @param \livetyping\hermitage\app\exceptions\HttpException $exception 83 | * 84 | * @return string 85 | */ 86 | protected function renderTextException(HttpException $exception) 87 | { 88 | $text = sprintf('Type: %s' . PHP_EOL, $exception->getMessage()); 89 | 90 | return $text; 91 | } 92 | 93 | /** 94 | * @param \livetyping\hermitage\app\exceptions\HttpException $exception 95 | * 96 | * @return string 97 | */ 98 | protected function renderHtmlErrorMessage(HttpException $exception) 99 | { 100 | $title = $exception->getName(); 101 | $html = '
' . $exception->getMessage() . '
'; 102 | 103 | $output = sprintf( 104 | "" . 105 | "