From 4d7159e93ffcc4a5d76517f317373f9298e19d98 Mon Sep 17 00:00:00 2001 From: Sebastian Marcet Date: Wed, 29 Apr 2015 18:58:16 -0300 Subject: [PATCH] OpenstackId resource server * migration of resource server from openstackid to its own project * migration of marketplace api * added api tests * added CORS middleware * added SecurityHTTPHeadersWriterMiddleware Change-Id: Ib3d02feeb1e756de73d380238a043a7ac1ec7ecc --- .env.example | 46 ++ .env.testing | 50 ++ .gitattributes | 3 + .gitignore | 28 + app/Commands/Command.php | 7 + app/Console/Commands/Inspire.php | 32 + app/Console/Kernel.php | 29 + app/Events/Event.php | 7 + app/Exceptions/Handler.php | 42 ++ app/Handlers/Commands/.gitkeep | 0 app/Handlers/Events/.gitkeep | 0 app/Http/Controllers/Controller.php | 11 + app/Http/Controllers/JsonController.php | 99 ++++ .../Controllers/OAuth2ProtectedController.php | 40 ++ .../marketplace/OAuth2CloudApiController.php | 86 +++ .../OAuth2CompanyServiceApiController.php | 143 +++++ .../OAuth2ConsultantsApiController.php | 89 +++ .../OAuth2PrivateCloudApiController.php | 36 ++ .../OAuth2PublicCloudApiController.php | 30 + app/Http/Kernel.php | 37 ++ app/Http/Middleware/Authenticate.php | 50 ++ app/Http/Middleware/CORSMiddleware.php | 505 ++++++++++++++++ .../Middleware/CORSRequestPreflightData.php | 82 +++ .../Middleware/CORSRequestPreflightType.php | 36 ++ app/Http/Middleware/ETagsMiddleware.php | 44 ++ ...Auth2BearerAccessTokenRequestValidator.php | 286 +++++++++ app/Http/Middleware/RateLimitMiddleware.php | 106 ++++ .../Middleware/RedirectIfAuthenticated.php | 44 ++ .../SecurityHTTPHeadersWriterMiddleware.php | 50 ++ app/Http/Middleware/VerifyCsrfToken.php | 20 + app/Http/Middleware/cors_server_flowchart.png | Bin 0 -> 99960 bytes app/Http/Requests/Request.php | 9 + app/Http/routes.php | 40 ++ ...erAccessTokenAuthorizationHeaderParser.php | 77 +++ app/Libs/oauth2/HttpMessage.php | 54 ++ app/Libs/oauth2/HttpResponse.php | 52 ++ app/Libs/oauth2/InvalidGrantTypeException.php | 28 + app/Libs/oauth2/OAuth2DirectResponse.php | 39 ++ .../OAuth2InvalidIntrospectionResponse.php | 24 + ...Auth2MissingBearerAccessTokenException.php | 28 + app/Libs/oauth2/OAuth2Protocol.php | 92 +++ .../oauth2/OAuth2ResourceServerException.php | 58 ++ app/Libs/oauth2/OAuth2Response.php | 18 + .../OAuth2WWWAuthenticateErrorResponse.php | 71 +++ app/Libs/utils/ConfigurationException.php | 29 + app/Libs/utils/ICacheService.php | 114 ++++ app/Libs/utils/RequestUtils.php | 44 ++ app/Models/Marketplace/CompanyService.php | 38 ++ app/Models/Marketplace/Consultant.php | 28 + app/Models/Marketplace/DataCenterLocation.php | 37 ++ app/Models/Marketplace/DataCenterRegion.php | 38 ++ app/Models/Marketplace/ICloudService.php | 26 + .../Marketplace/ICloudServiceRepository.php | 22 + .../Marketplace/ICompanyServiceRepository.php | 45 ++ app/Models/Marketplace/IConsultant.php | 25 + .../Marketplace/IConsultantRepository.php | 21 + .../IPrivateCloudServiceRepository.php | 21 + .../IPublicCloudServiceRepository.php | 22 + app/Models/Marketplace/Office.php | 38 ++ .../Marketplace/PrivateCloudService.php | 31 + app/Models/Marketplace/PublicCloudService.php | 30 + .../ResourceServer/AccessTokenService.php | 154 +++++ app/Models/ResourceServer/Api.php | 100 ++++ app/Models/ResourceServer/ApiEndpoint.php | 134 +++++ app/Models/ResourceServer/ApiScope.php | 57 ++ .../ResourceServer/IAccessTokenService.php | 30 + app/Models/ResourceServer/IApi.php | 70 +++ app/Models/ResourceServer/IApiEndpoint.php | 91 +++ .../ResourceServer/IApiEndpointRepository.php | 30 + app/Models/ResourceServer/IApiScope.php | 46 ++ app/Models/Utils/BaseModelEloquent.php | 81 +++ app/Models/Utils/IBaseRepository.php | 24 + app/Models/Utils/IEntity.php | 23 + app/Models/oauth2/AccessToken.php | 120 ++++ app/Models/oauth2/IResourceServerContext.php | 58 ++ app/Models/oauth2/ResourceServerContext.php | 76 +++ app/Models/oauth2/Token.php | 90 +++ app/Providers/AppServiceProvider.php | 47 ++ app/Providers/BusServiceProvider.php | 34 ++ app/Providers/ConfigServiceProvider.php | 23 + app/Providers/EventServiceProvider.php | 32 + app/Providers/RouteServiceProvider.php | 62 ++ app/Repositories/RepositoriesProvider.php | 49 ++ .../EloquentCompanyServiceRepository.php | 94 +++ .../EloquentConsultantRepository.php | 32 + .../EloquentPrivateCloudServiceRepository.php | 35 ++ .../EloquentPublicCloudServiceRepository.php | 34 ++ .../EloquentApiEndpointRepository.php | 67 +++ app/Services/ServicesProvider.php | 34 ++ app/Services/utils/RedisCacheService.php | 190 ++++++ artisan | 51 ++ bootstrap/app.php | 57 ++ bootstrap/autoload.php | 35 ++ composer.json | 55 ++ config/app.php | 202 +++++++ config/auth.php | 67 +++ config/cache.php | 50 ++ config/compile.php | 41 ++ config/cors.php | 30 + config/curl.php | 19 + config/database.php | 109 ++++ config/filesystems.php | 71 +++ config/log.php | 10 + config/mail.php | 124 ++++ config/queue.php | 92 +++ config/services.php | 37 ++ config/session.php | 153 +++++ config/view.php | 33 ++ database/.gitignore | 1 + database/migrations/.gitkeep | 0 .../2015_04_27_141330_create_apis_table.php | 36 ++ ...5_04_27_141832_create_api_scopes_table.php | 50 ++ ...4_27_141848_create_api_endpoints_table.php | 81 +++ database/seeds/.gitkeep | 0 database/seeds/ApiEndpointsSeeder.php | 185 ++++++ database/seeds/ApiScopesSeeder.php | 88 +++ database/seeds/ApiSeeder.php | 54 ++ database/seeds/DatabaseSeeder.php | 22 + database/seeds/TestSeeder.php | 32 + doc/source/conf.py | 94 +++ doc/source/index.rst | 17 + doc/source/restapi/v1.rst | 560 ++++++++++++++++++ gulpfile.js | 16 + package.json | 7 + phpspec.yml | 5 + phpunit.xml | 20 + public/.htaccess | 15 + public/favicon.ico | 0 public/index.php | 57 ++ public/robots.txt | 2 + readme.md | 23 + requirements.txt | 4 + resources/assets/less/app.less | 1 + resources/lang/en/pagination.php | 19 + resources/lang/en/passwords.php | 22 + resources/lang/en/validation.php | 107 ++++ resources/views/errors/404.blade.php | 12 + resources/views/errors/503.blade.php | 41 ++ resources/views/layouts/master.blade.php | 39 ++ resources/views/vendor/.gitkeep | 0 server.php | 21 + setup.cfg | 23 + setup.py | 21 + storage/.gitignore | 1 + storage/app/.gitignore | 2 + storage/framework/.gitignore | 6 + storage/framework/cache/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/OAuth2ConsultantApiTest.php | 154 +++++ tests/OAuth2PrivateCloudApiTest.php | 123 ++++ tests/OAuth2PublicCloudApiTest.php | 121 ++++ tests/ProtectedApiTest.php | 74 +++ tests/TestCase.php | 47 ++ tox.ini | 22 + 156 files changed, 8623 insertions(+) create mode 100644 .env.example create mode 100644 .env.testing create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 app/Commands/Command.php create mode 100644 app/Console/Commands/Inspire.php create mode 100644 app/Console/Kernel.php create mode 100644 app/Events/Event.php create mode 100644 app/Exceptions/Handler.php create mode 100644 app/Handlers/Commands/.gitkeep create mode 100644 app/Handlers/Events/.gitkeep create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/JsonController.php create mode 100644 app/Http/Controllers/OAuth2ProtectedController.php create mode 100644 app/Http/Controllers/apis/protected/marketplace/OAuth2CloudApiController.php create mode 100644 app/Http/Controllers/apis/protected/marketplace/OAuth2CompanyServiceApiController.php create mode 100644 app/Http/Controllers/apis/protected/marketplace/OAuth2ConsultantsApiController.php create mode 100644 app/Http/Controllers/apis/protected/marketplace/OAuth2PrivateCloudApiController.php create mode 100644 app/Http/Controllers/apis/protected/marketplace/OAuth2PublicCloudApiController.php create mode 100644 app/Http/Kernel.php create mode 100644 app/Http/Middleware/Authenticate.php create mode 100644 app/Http/Middleware/CORSMiddleware.php create mode 100644 app/Http/Middleware/CORSRequestPreflightData.php create mode 100644 app/Http/Middleware/CORSRequestPreflightType.php create mode 100644 app/Http/Middleware/ETagsMiddleware.php create mode 100644 app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php create mode 100644 app/Http/Middleware/RateLimitMiddleware.php create mode 100644 app/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 app/Http/Middleware/SecurityHTTPHeadersWriterMiddleware.php create mode 100644 app/Http/Middleware/VerifyCsrfToken.php create mode 100644 app/Http/Middleware/cors_server_flowchart.png create mode 100644 app/Http/Requests/Request.php create mode 100644 app/Http/routes.php create mode 100644 app/Libs/oauth2/BearerAccessTokenAuthorizationHeaderParser.php create mode 100644 app/Libs/oauth2/HttpMessage.php create mode 100644 app/Libs/oauth2/HttpResponse.php create mode 100644 app/Libs/oauth2/InvalidGrantTypeException.php create mode 100644 app/Libs/oauth2/OAuth2DirectResponse.php create mode 100644 app/Libs/oauth2/OAuth2InvalidIntrospectionResponse.php create mode 100644 app/Libs/oauth2/OAuth2MissingBearerAccessTokenException.php create mode 100644 app/Libs/oauth2/OAuth2Protocol.php create mode 100644 app/Libs/oauth2/OAuth2ResourceServerException.php create mode 100644 app/Libs/oauth2/OAuth2Response.php create mode 100644 app/Libs/oauth2/OAuth2WWWAuthenticateErrorResponse.php create mode 100644 app/Libs/utils/ConfigurationException.php create mode 100644 app/Libs/utils/ICacheService.php create mode 100644 app/Libs/utils/RequestUtils.php create mode 100644 app/Models/Marketplace/CompanyService.php create mode 100644 app/Models/Marketplace/Consultant.php create mode 100644 app/Models/Marketplace/DataCenterLocation.php create mode 100644 app/Models/Marketplace/DataCenterRegion.php create mode 100644 app/Models/Marketplace/ICloudService.php create mode 100644 app/Models/Marketplace/ICloudServiceRepository.php create mode 100644 app/Models/Marketplace/ICompanyServiceRepository.php create mode 100644 app/Models/Marketplace/IConsultant.php create mode 100644 app/Models/Marketplace/IConsultantRepository.php create mode 100644 app/Models/Marketplace/IPrivateCloudServiceRepository.php create mode 100644 app/Models/Marketplace/IPublicCloudServiceRepository.php create mode 100644 app/Models/Marketplace/Office.php create mode 100644 app/Models/Marketplace/PrivateCloudService.php create mode 100644 app/Models/Marketplace/PublicCloudService.php create mode 100644 app/Models/ResourceServer/AccessTokenService.php create mode 100644 app/Models/ResourceServer/Api.php create mode 100644 app/Models/ResourceServer/ApiEndpoint.php create mode 100644 app/Models/ResourceServer/ApiScope.php create mode 100644 app/Models/ResourceServer/IAccessTokenService.php create mode 100644 app/Models/ResourceServer/IApi.php create mode 100644 app/Models/ResourceServer/IApiEndpoint.php create mode 100644 app/Models/ResourceServer/IApiEndpointRepository.php create mode 100644 app/Models/ResourceServer/IApiScope.php create mode 100644 app/Models/Utils/BaseModelEloquent.php create mode 100644 app/Models/Utils/IBaseRepository.php create mode 100644 app/Models/Utils/IEntity.php create mode 100644 app/Models/oauth2/AccessToken.php create mode 100644 app/Models/oauth2/IResourceServerContext.php create mode 100644 app/Models/oauth2/ResourceServerContext.php create mode 100644 app/Models/oauth2/Token.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Providers/BusServiceProvider.php create mode 100644 app/Providers/ConfigServiceProvider.php create mode 100644 app/Providers/EventServiceProvider.php create mode 100644 app/Providers/RouteServiceProvider.php create mode 100644 app/Repositories/RepositoriesProvider.php create mode 100644 app/Repositories/marketplace/EloquentCompanyServiceRepository.php create mode 100644 app/Repositories/marketplace/EloquentConsultantRepository.php create mode 100644 app/Repositories/marketplace/EloquentPrivateCloudServiceRepository.php create mode 100644 app/Repositories/marketplace/EloquentPublicCloudServiceRepository.php create mode 100644 app/Repositories/resource_server/EloquentApiEndpointRepository.php create mode 100644 app/Services/ServicesProvider.php create mode 100644 app/Services/utils/RedisCacheService.php create mode 100755 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/autoload.php create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/compile.php create mode 100644 config/cors.php create mode 100644 config/curl.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/log.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 config/view.php create mode 100644 database/.gitignore create mode 100644 database/migrations/.gitkeep create mode 100644 database/migrations/2015_04_27_141330_create_apis_table.php create mode 100644 database/migrations/2015_04_27_141832_create_api_scopes_table.php create mode 100644 database/migrations/2015_04_27_141848_create_api_endpoints_table.php create mode 100644 database/seeds/.gitkeep create mode 100644 database/seeds/ApiEndpointsSeeder.php create mode 100644 database/seeds/ApiScopesSeeder.php create mode 100644 database/seeds/ApiSeeder.php create mode 100644 database/seeds/DatabaseSeeder.php create mode 100644 database/seeds/TestSeeder.php create mode 100644 doc/source/conf.py create mode 100644 doc/source/index.rst create mode 100644 doc/source/restapi/v1.rst create mode 100644 gulpfile.js create mode 100644 package.json create mode 100644 phpspec.yml create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 resources/assets/less/app.less create mode 100644 resources/lang/en/pagination.php create mode 100644 resources/lang/en/passwords.php create mode 100644 resources/lang/en/validation.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/503.blade.php create mode 100644 resources/views/layouts/master.blade.php create mode 100644 resources/views/vendor/.gitkeep create mode 100644 server.php create mode 100644 setup.cfg create mode 100644 setup.py create mode 100755 storage/.gitignore create mode 100755 storage/app/.gitignore create mode 100755 storage/framework/.gitignore create mode 100755 storage/framework/cache/.gitignore create mode 100755 storage/framework/sessions/.gitignore create mode 100755 storage/framework/views/.gitignore create mode 100755 storage/logs/.gitignore create mode 100644 tests/OAuth2ConsultantApiTest.php create mode 100644 tests/OAuth2PrivateCloudApiTest.php create mode 100644 tests/OAuth2PublicCloudApiTest.php create mode 100644 tests/ProtectedApiTest.php create mode 100644 tests/TestCase.php create mode 100644 tox.ini diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..dc5cd0e1 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +APP_ENV=local +APP_DEBUG=true +APP_KEY=SomeRandomString +APP_URL=http://localhost +APP_OAUTH_2_0_CLIENT_ID=clientid +APP_OAUTH_2_0_CLIENT_SECRET=clientsecret +APP_OAUTH_2_0_AUTH_SERVER_BASE_URL=http://localhost + +DB_HOST=localhost +DB_DATABASE=homestead +DB_USERNAME=homestead +DB_PASSWORD=secret + +SS_DB_HOST=localhost +SS_DB_DATABASE=homestead +SS_DB_USERNAME=homestead +SS_DB_PASSWORD=secret + +REDIS_HOST=127.0.0.1 +REDIS_PORT=port +REDIS_DB=0 +REDIS_PASSWORD= + +CACHE_DRIVER=file + +SESSION_DRIVER=redis +SESSION_COOKIE_DOMAIN= +SESSION_COOKIE_SECURE=false + +QUEUE_DRIVER=sync + +MAIL_DRIVER=smtp +MAIL_HOST=mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null + +CORS_ALLOWED_HEADERS=origin, content-type, accept, authorization, x-requested-with +CORS_ALLOWED_METHODS=GET, POST, OPTIONS, PUT, DELETE +CORS_USE_PRE_FLIGHT_CACHING=true +CORS_MAX_AGE=3200 +CORS_EXPOSED_HEADERS= + +CURL_TIMEOUT=60 +CURL_ALLOWS_REDIRECT=false +CURL_VERIFY_SSL_CERT=true \ No newline at end of file diff --git a/.env.testing b/.env.testing new file mode 100644 index 00000000..0119b429 --- /dev/null +++ b/.env.testing @@ -0,0 +1,50 @@ +APP_ENV=testing +APP_DEBUG=true +APP_KEY=KKzP6APRNHmADURQ8OanDTU5kDpGwo6l +APP_URL=https://local.resource-server.openstack.org +APP_OAUTH_2_0_CLIENT_ID=tM9iYEq2iCP6P5WQL.~Zo2XXLbugpNhu.openstack.client +APP_OAUTH_2_0_CLIENT_SECRET=f70Ydbhq9NernTem4Yow8SEB +APP_OAUTH_2_0_AUTH_SERVER_BASE_URL=https://local.openstackid.openstack.org + +DB_HOST=localhost +DB_DATABASE=resource_server_test +DB_USERNAME=root +DB_PASSWORD=Koguryo@1981 + +SS_DB_HOST=localhost +SS_DATABASE=os_local +SS_DB_USERNAME=root +SS_DB_PASSWORD=Koguryo@1981 + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD= + +CACHE_DRIVER=redis + +SESSION_DRIVER=redis +SESSION_COOKIE_DOMAIN= +SESSION_COOKIE_SECURE=false + +QUEUE_DRIVER=sync + +MAIL_DRIVER=smtp +MAIL_HOST=mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null + + +LOG_EMAIL_TO= +LOG_EMAIL_FROM= + +CORS_ALLOWED_HEADERS=origin, content-type, accept, authorization, x-requested-with +CORS_ALLOWED_METHODS=GET, POST, OPTIONS, PUT, DELETE +CORS_USE_PRE_FLIGHT_CACHING=false +CORS_MAX_AGE=3200 +CORS_EXPOSED_HEADERS= + +CURL_TIMEOUT=3600 +CURL_ALLOWS_REDIRECT=false +CURL_VERIFY_SSL_CERT=false \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..95883dea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto +*.css linguist-vendored +*.less linguist-vendored diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f352365b --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +/vendor +/node_modules +.env +composer.phar +composer.lock +.DS_Storeapp/storage +/app/storage/* +.idea/* +app/config/dev/* +app/config/testing/* +app/config/local/* +app/config/production/* +app/config/staging/* +app/config/packages/greggilbert/recaptcha/dev/* +app/config/packages/greggilbert/recaptcha/local/* +app/config/packages/greggilbert/recaptcha/production/* +app/config/packages/greggilbert/recaptcha/staging/* +/bootstrap/compiled.php +/bootstrap/environment.php +.tox +AUTHORS +ChangeLog +doc/build +*.egg +*.egg-info + + +.env.testing \ No newline at end of file diff --git a/app/Commands/Command.php b/app/Commands/Command.php new file mode 100644 index 00000000..018bc219 --- /dev/null +++ b/app/Commands/Command.php @@ -0,0 +1,7 @@ +comment(PHP_EOL.Inspiring::quote().PHP_EOL); + } + +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100644 index 00000000..0c088c89 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,29 @@ +command('inspire') + ->hourly(); + } + +} diff --git a/app/Events/Event.php b/app/Events/Event.php new file mode 100644 index 00000000..d59f7690 --- /dev/null +++ b/app/Events/Event.php @@ -0,0 +1,7 @@ + 'server error'), 500); + } + + protected function created($data = 'ok') + { + $res = Response::json($data, 201); + //jsonp + if (Input::has('callback')) + { + $res->setCallback(Input::get('callback')); + } + return $res; + } + + protected function deleted($data = 'ok') + { + $res = Response::json($data, 204); + //jsonp + if (Input::has('callback')) + { + $res->setCallback(Input::get('callback')); + } + return $res; + } + + protected function ok($data = 'ok') + { + $res = Response::json($data, 200); + //jsonp + if (Input::has('callback')) + { + $res->setCallback(Input::get('callback')); + } + return $res; + } + + protected function error400($data) + { + return Response::json($data, 400); + } + + protected function error404($data = array('message' => 'Entity Not Found')) + { + return Response::json($data, 404); + } + + /** + * { + "message": "Validation Failed", + "errors": [ + { + "resource": "Issue", + "field": "title", + "code": "missing_field" + } + ] + } + * @param $messages + * @return mixed + */ + protected function error412($messages) + { + return Response::json(array('message' => 'Validation Failed', 'errors' => $messages), 412); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/OAuth2ProtectedController.php b/app/Http/Controllers/OAuth2ProtectedController.php new file mode 100644 index 00000000..190a6fb4 --- /dev/null +++ b/app/Http/Controllers/OAuth2ProtectedController.php @@ -0,0 +1,40 @@ +resource_server_context = $resource_server_context; + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/apis/protected/marketplace/OAuth2CloudApiController.php b/app/Http/Controllers/apis/protected/marketplace/OAuth2CloudApiController.php new file mode 100644 index 00000000..f99ae4f3 --- /dev/null +++ b/app/Http/Controllers/apis/protected/marketplace/OAuth2CloudApiController.php @@ -0,0 +1,86 @@ +getCompanyServices(); + } + + /** + * @param $id + * @return mixed + */ + public function getCloud($id) + { + return $this->getCompanyService($id); + } + + /** + * @param $id + * @return mixed + */ + public function getCloudDataCenters($id) + { + try { + $cloud = $this->repository->getById($id); + + if (!$cloud) + { + return $this->error404(); + } + + $data_center_regions = $cloud->datacenters_regions(); + + $res = array(); + + foreach ($data_center_regions as $region) + { + $data = $region->toArray(); + $locations = $region->locations(); + $data_locations = array(); + foreach ($locations as $loc) + { + array_push($data_locations, $loc->toArray()); + } + $data['locations'] = $data_locations; + array_push($res, $data); + } + + return $this->ok(array('datacenters' => $res )); + } + catch (Exception $ex) + { + Log::error($ex); + return $this->error500($ex); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/apis/protected/marketplace/OAuth2CompanyServiceApiController.php b/app/Http/Controllers/apis/protected/marketplace/OAuth2CompanyServiceApiController.php new file mode 100644 index 00000000..8f264beb --- /dev/null +++ b/app/Http/Controllers/apis/protected/marketplace/OAuth2CompanyServiceApiController.php @@ -0,0 +1,143 @@ + 'The :attribute field is does not has a valid value (all, active, non_active).', + 'order' => 'The :attribute field is does not has a valid value (date, name).', + 'order_dir' => 'The :attribute field is does not has a valid value (desc, asc).', + ); + + $rules = array( + 'page' => 'integer|min:1', + 'per_page' => 'required_with:page|integer|min:10|max:100', + 'status' => 'status', + 'order_by' => 'order', + 'order_dir' => 'required_with:order_by|order_dir', + ); + // Creates a Validator instance and validates the data. + $validation = Validator::make($values, $rules, $messages); + + if ($validation->fails()) + { + $messages = $validation->messages()->toArray(); + return $this->error412($messages); + } + + if (Input::has('page')) + { + $page = intval(Input::get('page')); + $per_page = intval(Input::get('per_page')); + } + + if (Input::has('status')) + { + $status = Input::get('status'); + } + + if (Input::has('order_by')) + { + $order_by = Input::get('order_by'); + $order_dir = Input::get('order_dir'); + } + + $data = $this->repository->getAll($page, $per_page, $status, $order_by, $order_dir); + return $this->ok($data); + } + catch (Exception $ex) + { + Log::error($ex); + return $this->error500($ex); + } + } + + /** + * @param $id + * @return mixed + */ + public function getCompanyService($id) + { + try + { + $data = $this->repository->getById($id); + return ($data)? $this->ok($data) : $this->error404(); + } + catch (Exception $ex) + { + Log::error($ex); + return $this->error500($ex); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/apis/protected/marketplace/OAuth2ConsultantsApiController.php b/app/Http/Controllers/apis/protected/marketplace/OAuth2ConsultantsApiController.php new file mode 100644 index 00000000..286f34f4 --- /dev/null +++ b/app/Http/Controllers/apis/protected/marketplace/OAuth2ConsultantsApiController.php @@ -0,0 +1,89 @@ +repository = $repository; + } + + /** + * query string params: + * page: You can specify further pages + * per_page: custom page size up to 100 ( min 10) + * status: cloud status ( active , not active, all) + * order_by: order by field + * order_dir: order direction + * @return mixed + */ + public function getConsultants() + { + return $this->getCompanyServices(); + } + + /** + * @param $id + * @return mixed + */ + public function getConsultant($id) + { + return $this->getCompanyService($id); + } + + /** + * @param $id + * @return mixed + */ + public function getOffices($id) + { + try + { + $consultant = $this->repository->getById($id); + + if (!$consultant) + { + return $this->error404(); + } + + $offices = $consultant->offices(); + $res = array(); + + foreach ($offices as $office) + { + array_push($res, $office->toArray()); + } + return $this->ok(array('offices' => $res)); + } + catch (Exception $ex) + { + Log::error($ex); + return $this->error500($ex); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/apis/protected/marketplace/OAuth2PrivateCloudApiController.php b/app/Http/Controllers/apis/protected/marketplace/OAuth2PrivateCloudApiController.php new file mode 100644 index 00000000..f1a40b48 --- /dev/null +++ b/app/Http/Controllers/apis/protected/marketplace/OAuth2PrivateCloudApiController.php @@ -0,0 +1,36 @@ +repository = $repository; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/apis/protected/marketplace/OAuth2PublicCloudApiController.php b/app/Http/Controllers/apis/protected/marketplace/OAuth2PublicCloudApiController.php new file mode 100644 index 00000000..29049015 --- /dev/null +++ b/app/Http/Controllers/apis/protected/marketplace/OAuth2PublicCloudApiController.php @@ -0,0 +1,30 @@ +repository = $repository; + } +} \ No newline at end of file diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php new file mode 100644 index 00000000..7d5d09f7 --- /dev/null +++ b/app/Http/Kernel.php @@ -0,0 +1,37 @@ + 'App\Http\Middleware\Authenticate', + 'auth.basic' => 'Illuminate\Auth\Middleware\AuthenticateWithBasicAuth', + 'guest' => 'App\Http\Middleware\RedirectIfAuthenticated', + 'oauth2.protected' => 'App\Http\Middleware\OAuth2BearerAccessTokenRequestValidator', + 'rate.limit' => 'App\Http\Middleware\RateLimitMiddleware', + 'etags' => 'App\Http\Middleware\ETagsMiddleware', + ]; + +} \ No newline at end of file diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 00000000..72a7613a --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,50 @@ +auth = $auth; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + if ($this->auth->guest()) + { + if ($request->ajax()) + { + return response('Unauthorized.', 401); + } + else + { + return redirect()->guest('auth/login'); + } + } + + return $next($request); + } + +} diff --git a/app/Http/Middleware/CORSMiddleware.php b/app/Http/Middleware/CORSMiddleware.php new file mode 100644 index 00000000..d4dda0b5 --- /dev/null +++ b/app/Http/Middleware/CORSMiddleware.php @@ -0,0 +1,505 @@ +endpoint_repository = $endpoint_repository; + $this->cache_service = $cache_service; + $this->allowed_headers = Config::get('cors.allowed_headers', self::DefaultAllowedHeaders); + $this->allowed_methods = Config::get('cors.allowed_methods', self::DefaultAllowedMethods); + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + if ($response = $this->preProcess($request)) + { + return $response; + } + //normal processing + $response = $next($request); + $this->postProcess($request, $response); + return $response; + } + + private function generatePreflightCacheKey($request) + { + $cache_id = 'pre-flight-'. $request->getClientIp(). '-' . $request->getRequestUri(). '-' . $request->getMethod(); + return $cache_id; + } + + /** + * @param Request $request + * @return Response + */ + public function preProcess(Request $request) + { + $actual_request = false; + if ($this->isValidCORSRequest($request)) + { + if (!$this->testOriginHeaderScrutiny($request)) + { + $response = new Response(); + $response->setStatusCode(403); + return $response; + } + /* Step 01 : Determine the type of the incoming request */ + $type = $this->getRequestType($request); + /* Step 02 : Process request according to is type */ + switch($type) + { + case CORSRequestPreflightType::REQUEST_FOR_PREFLIGHT: + { + // HTTP request send by client to preflight a further 'Complex' request + // sets the original method on request in order to be able to find the + // correct route + $real_method = $request->headers->get('Access-Control-Request-Method'); + $request->setMethod($real_method); + + $route_path = RequestUtils::getCurrentRoutePath($request); + if (!$route_path || !$this->checkEndPoint($route_path, $real_method)) + { + $response = new Response(); + $response->setStatusCode(403); + return $response; + } + // ----Step 2b: Store pre-flight request data in the Cache to keep (mark) the request as correctly followed the request pre-flight process + $data = new CORSRequestPreflightData($request, $this->current_endpoint->supportCredentials()); + $cache_id = $this->generatePreflightCacheKey($request); + $this->cache_service->storeHash($cache_id, $data->toArray(), CORSRequestPreflightData::$cache_lifetime); + // ----Step 2c: Return corresponding response - This part should be customized with application specific constraints..... + return $this->makePreflightResponse($request); + } + break; + case CORSRequestPreflightType::COMPLEX_REQUEST: + { + $cache_id = $this->generatePreflightCacheKey($request); +; // ----Step 2a: Check if the current request has an entry into the preflighted requests Cache + $data = $this->cache_service->getHash($cache_id, CORSRequestPreflightData::$cache_attributes); + if (!count($data)) + { + $response = new Response(); + $response->setStatusCode(403); + return $response; + } + // ----Step 2b: Check that pre-flight information declared during the pre-flight request match the current request on key information + $match = false; + // ------Start with comparison of "Origin" HTTP header (according to utility method impl. used to retrieve header reference cannot be null)... + if ($request->headers->get('Origin') === $data['origin']) + { + // ------Continue with HTTP method... + if ($request->getMethod() === $data['expected_method']) + { + // ------Finish with custom HTTP headers (use an method to avoid manual iteration on collection to increase the speed)... + $x_headers = self::getCustomHeaders($request); + $x_headers_pre = explode(',', $data['expected_custom_headers']); + sort($x_headers); + sort($x_headers_pre); + if (count(array_diff($x_headers, $x_headers_pre)) === 0) + { + $match = true; + } + } + } + if (!$match) + { + $response = new Response(); + $response->setStatusCode(403); + return $response; + } + $actual_request = true; + } + break; + case CORSRequestPreflightType::SIMPLE_REQUEST: + { + // origins, do not set any additional headers and terminate this set of steps. + if (!$this->isAllowedOrigin($request)) { + $response = new Response(); + $response->setStatusCode(403); + + return $response; + } + $actual_request = true; + // If the resource supports credentials add a single Access-Control-Allow-Origin header, with the value + // of the Origin header as value, and add a single Access-Control-Allow-Credentials header with the + // case-sensitive string "true" as value. + // Otherwise, add a single Access-Control-Allow-Origin header, with either the value of the Origin header + // or the string "*" as value. + } + break; + } + } + if ($actual_request) + { + // Save response headers + $cache_id = $this->generatePreflightCacheKey($request); + // ----Step 2a: Check if the current request has an entry into the preflighted requests Cache + $data = $this->cache_service->getHash($cache_id, CORSRequestPreflightData::$cache_attributes); + $this->headers['Access-Control-Allow-Origin'] = $request->headers->get('Origin'); + if ((bool)$data['allows_credentials']) + { + $this->headers['Access-Control-Allow-Credentials'] = 'true'; + } + /** + * During a CORS request, the getResponseHeader() method can only access simple response headers. + * Simple response headers are defined as follows: + ** Cache-Control + ** Content-Language + ** Content-Type + ** Expires + ** Last-Modified + ** Pragma + * If you want clients to be able to access other headers, + * you have to use the Access-Control-Expose-Headers header. + * The value of this header is a comma-delimited list of response headers you want to expose + * to the client. + */ + $exposed_headers = Config::get('cors.exposed_headers', 'Content-Type, Expires'); + if (!empty($exposed_headers)) + { + $this->headers['Access-Control-Expose-Headers'] = $exposed_headers ; + } + } + } + + public function postProcess(Request $request, Response $response) + { + // add CORS response headers + if (count($this->headers) > 0) + { + $response->headers->add($this->headers); + } + return $response; + } + + /** + * @param Request $request + * @return Response + */ + private function makePreflightResponse(Request $request) + { + $response = new Response(); + if (!$this->isAllowedOrigin($request)) + { + $response->headers->set('Access-Control-Allow-Origin', 'null'); + $response->setStatusCode(403); + return $response; + } + $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); + // The Access-Control-Request-Method header indicates which method will be used in the actual + // request as part of the preflight request + // check request method + if ($request->headers->get('Access-Control-Request-Method') != $this->current_endpoint->getHttpMethod()) + { + $response->setStatusCode(405); + return $response; + } + // The Access-Control-Allow-Credentials header indicates whether the response to request + // can be exposed when the omit credentials flag is unset. When part of the response to a preflight request + // it indicates that the actual request can include user credentials. + if ( $this->current_endpoint->supportCredentials()) + { + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + if (Config::get('cors.use_pre_flight_caching', false)) + { + // The Access-Control-Max-Age header indicates how long the response can be cached, so that for + // subsequent requests, within the specified time, no preflight request has to be made. + $response->headers->set('Access-Control-Max-Age', Config::get('cors.max_age', 32000)); + } + // The Access-Control-Allow-Headers header indicates, as part of the response to a preflight request, + // which header field names can be used during the actual request + $response->headers->set('Access-Control-Allow-Headers', $this->allowed_headers); + + //The Access-Control-Allow-Methods header indicates, as part of the response to a preflight request, + // which methods can be used during the actual request. + $response->headers->set('Access-Control-Allow-Methods', $this->allowed_methods); + // The Access-Control-Request-Headers header indicates which headers will be used in the actual request + // as part of the preflight request. + $headers = $request->headers->get('Access-Control-Request-Headers'); + if ($headers) + { + $headers = trim(strtolower($headers)); + $allow_headers = explode(', ', $this->allowed_headers); + foreach (preg_split('{, *}', $headers) as $header) + { + //if they are simple headers then skip them + if (in_array($header, self::$simple_headers, true)) + { + continue; + } + //check is the requested header is on the list of allowed headers + if (!in_array($header, $allow_headers, true)) + { + $response->setStatusCode(400); + $response->setContent('Unauthorized header '.$header); + break; + } + } + } + //OK - No Content + $response->setStatusCode(204); + return $response; + } + + /** + * @param Request $request + * @returns bool + */ + private function isValidCORSRequest(Request $request) + { + /** + * The presence of the Origin header does not necessarily mean that the request is a cross-origin request. + * While all cross-origin requests will contain an Origin header, + + * Origin header on same-origin requests. But Chrome and Safari include an Origin header on + * same-origin POST/PUT/DELETE requests (same-origin GET requests will not have an Origin header). + */ + return $request->headers->has('Origin'); + } + + /** + * https://www.owasp.org/index.php/CORS_OriginHeaderScrutiny + * Filter that will ensure the following points for each incoming HTTP CORS requests: + * - Have only one and non empty instance of the origin header, + * - Have only one and non empty instance of the host header, + * - The value of the origin header is present in a internal allowed domains list (white list). As we act before the + * step 2 of the CORS HTTP requests/responses exchange process, allowed domains list is yet provided to client, + * - Cache IP of the sender for 1 hour. If the sender send one time a origin domain that is not in the white list + * then all is requests will return an HTTP 403 response (protract allowed domain guessing). + * We use the method above because it's not possible to identify up to 100% that the request come from one expected + * client application, since: + * - All information of a HTTP request can be faked, + * - It's the browser (or others tools) that send the HTTP request then the IP address that we have access to is the + * client IP address. + * @param Request $request + * @return bool + */ + private function testOriginHeaderScrutiny(Request $request) + { + /* Step 0 : Check presence of client IP in black list */ + $client_ip = $request->getClientIp(); + if (Cache::has(self::CORS_IP_BLACKLIST_PREFIX . $client_ip)) + { + return false; + } + /* Step 1 : Check that we have only one and non empty instance of the "Origin" header */ + $origin = $request->headers->get('Origin', null, false); + if (is_array($origin) && count($origin) > 1) + { + // If we reach this point it means that we have multiple instance of the "Origin" header + // Add client IP address to black listed client + $expiresAt = Carbon::now()->addMinutes(60); + Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt); + return false; + } + /* Step 2 : Check that we have only one and non empty instance of the "Host" header */ + $host = $request->headers->get('Host', null, false); + //Have only one and non empty instance of the host header, + if (is_array($host) && count($host) > 1) + { + // If we reach this point it means that we have multiple instance of the "Host" header + $expiresAt = Carbon::now()->addMinutes(60); + Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt); + return false; + } + /* Step 3 : Perform analysis - Origin header is required */ + + $origin = $request->headers->get('Origin'); + $host = $request->headers->get('Host'); + $server_name = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : null; + $origin_host = @parse_url($origin, PHP_URL_HOST); + + + // check origin not empty and allowed + + if (!$this->isAllowedOrigin($origin)) + { + $expiresAt = Carbon::now()->addMinutes(60); + Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt); + return false; + } + + if (is_null($host) || $server_name != $host || is_null($origin_host) || $origin_host == $server_name) + { + $expiresAt = Carbon::now()->addMinutes(60); + Cache::put(self::CORS_IP_BLACKLIST_PREFIX . $client_ip, self::CORS_IP_BLACKLIST_PREFIX . $client_ip, $expiresAt); + return false; + } + + /* Step 4 : Finalize request next step */ + return true; + } + + private function checkEndPoint($endpoint_path, $http_method) + { + $this->current_endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($endpoint_path, $http_method); + if (is_null($this->current_endpoint)) + { + return false; + } + if (!$this->current_endpoint->supportCORS() || !$this->current_endpoint->isActive()) + { + return false; + } + return true; + } + + /** + * @param string $origin + * @return bool + */ + private function isAllowedOrigin($origin) + { + return true; + } + + private static function getRequestType(Request $request) + { + + $type = CORSRequestPreflightType::UNKNOWN; + $http_method = $request->getMethod(); + $content_type = strtolower($request->getContentType()); + $http_method = strtoupper($http_method); + + if ($http_method === 'OPTIONS' && $request->headers->has('Access-Control-Request-Method')) + { + $type = CORSRequestPreflightType::REQUEST_FOR_PREFLIGHT; + } + else + { + if (self::hasCustomHeaders($request)) + { + $type = CORSRequestPreflightType::COMPLEX_REQUEST; + } + elseif ($http_method === 'POST' && !in_array($content_type, self::$simple_content_header_values, true)) + { + $type = CORSRequestPreflightType::COMPLEX_REQUEST; + } + elseif (!in_array($http_method, self::$simple_http_methods, true)) + { + $type = CORSRequestPreflightType::COMPLEX_REQUEST; + } + else + { + $type = CORSRequestPreflightType::SIMPLE_REQUEST; + } + } + return $type; + } + + + private static function getCustomHeaders(Request $request) + { + $custom_headers = array(); + foreach ($request->headers->all() as $k => $h) + { + if (starts_with('X-', strtoupper(trim($k)))) + { + array_push($custom_headers, strtoupper(trim($k))); + } + } + return $custom_headers; + } + + private static function hasCustomHeaders(Request $request) + { + return count(self::getCustomHeaders($request)) > 0; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/CORSRequestPreflightData.php b/app/Http/Middleware/CORSRequestPreflightData.php new file mode 100644 index 00000000..899dad76 --- /dev/null +++ b/app/Http/Middleware/CORSRequestPreflightData.php @@ -0,0 +1,82 @@ +sender = $request->getClientIp(); + $this->uri = $request->getRequestUri(); + $this->origin = $request->headers->get('Origin'); + $this->expected_method = $request->headers->get('Access-Control-Request-Method'); + $this->allows_credentials = $allows_credentials; + + $tmp = $request->headers->get("Access-Control-Request-Headers"); + if (!empty($tmp)) + { + $hs = explode(',', $tmp); + foreach ($hs as $h) + { + array_push($this->expected_custom_headers, strtoupper(trim($h))); + } + } + } + + /** + * @return array + */ + public function toArray() + { + $res = array(); + $res['sender'] = $this->sender; + $res['uri'] = $this->uri; + $res['origin'] = $this->origin; + $res['allows_credentials'] = $this->allows_credentials; + $res['expected_method'] = $this->expected_method; + $res['expected_custom_headers'] = implode(',', $this->expected_custom_headers); + return $res; + } + +} \ No newline at end of file diff --git a/app/Http/Middleware/CORSRequestPreflightType.php b/app/Http/Middleware/CORSRequestPreflightType.php new file mode 100644 index 00000000..4dcf2a82 --- /dev/null +++ b/app/Http/Middleware/CORSRequestPreflightType.php @@ -0,0 +1,36 @@ +getStatusCode() === 200) + { + $etag = md5($response->getContent()); + $requestETag = str_replace('"', '', $request->getETags()); + if ($requestETag && $requestETag[0] == $etag) + { + $response->setNotModified(); + } + $response->setEtag($etag); + } + return $response; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php new file mode 100644 index 00000000..d456b534 --- /dev/null +++ b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php @@ -0,0 +1,286 @@ +context = $context; + $this->headers = $this->getHeaders(); + $this->endpoint_repository = $endpoint_repository; + $this->token_service = $token_service; + } + + /** + * @param \Illuminate\Http\Request $request + * @param callable $next + * @return OAuth2WWWAuthenticateErrorResponse + */ + public function handle($request, Closure $next) + { + $url = $request->getRequestUri(); + $method = $request->getMethod(); + $realm = $request->getHost(); + + try + { + $route = RequestUtils::getCurrentRoutePath($request); + if (!$route) + { + throw new OAuth2ResourceServerException( + 400, + OAuth2Protocol::OAuth2Protocol_Error_InvalidRequest, + sprintf('API endpoint does not exits! (%s:%s)', $url, $method) + ); + } + // http://tools.ietf.org/id/draft-abarth-origin-03.html + $origin = $request->headers->has('Origin') ? $request->headers->get('Origin') : null; + if(!empty($origin)) + { + $nm = new Normalizer($origin); + $origin = $nm->normalize(); + } + + //check first http basic auth header + $auth_header = isset($this->headers['authorization']) ? $this->headers['authorization'] : null; + if (!is_null($auth_header) && !empty($auth_header)) + { + $access_token_value = BearerAccessTokenAuthorizationHeaderParser::getInstance()->parse($auth_header); + } + else + { + // http://tools.ietf.org/html/rfc6750#section-2- 2 + // if access token is not on authorization header check on POST/GET params + $access_token_value = Input::get(OAuth2Protocol::OAuth2Protocol_AccessToken, ''); + } + + if (is_null($access_token_value) || empty($access_token_value)) + { + //if access token value is not set, then error + throw new OAuth2ResourceServerException( + 400, + OAuth2Protocol::OAuth2Protocol_Error_InvalidRequest, + 'missing access token' + ); + } + + $endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($route, $method); + + //api endpoint must be registered on db and active + if (is_null($endpoint) || !$endpoint->isActive()) + { + throw new OAuth2ResourceServerException( + 400, + OAuth2Protocol::OAuth2Protocol_Error_InvalidRequest, + sprintf('API endpoint does not exits! (%s:%s)', $route, $method) + ); + } + + $token_info = $this->token_service->get($access_token_value); + + //check lifetime + if (is_null($token_info) || $token_info->getLifetime() <= 0) + { + throw new OAuth2ResourceServerException( + 401, + OAuth2Protocol::OAuth2Protocol_Error_UnauthorizedClient, + 'invalid origin' + ); + } + //check token audience + $audience = explode(' ', $token_info->getAudience()); + if ((!in_array($realm, $audience))) + { + throw new OAuth2ResourceServerException( + 401, + OAuth2Protocol::OAuth2Protocol_Error_InvalidToken, + 'the access token provided is expired, revoked, malformed, or invalid for other reasons.' + ); + } + if ($token_info->getApplicationType() === 'JS_CLIENT' && str_contains($token_info->getAllowedOrigins(), $origin) === false) + { + //check origins + throw new OAuth2ResourceServerException( + 403, + OAuth2Protocol::OAuth2Protocol_Error_UnauthorizedClient, + 'invalid origin' + ); + } + //check scopes + $endpoint_scopes = explode(' ', $endpoint->getScope()); + $token_scopes = explode(' ', $token_info->getScope()); + //check token available scopes vs. endpoint scopes + if (count(array_intersect($endpoint_scopes, $token_scopes)) == 0) + { + Log::error( + sprintf( + 'access token scopes (%s) does not allow to access to api url %s , needed scopes %s', + $token_info->getScope(), + $url, + implode(' OR ', $endpoint_scopes) + ) + ); + + throw new OAuth2ResourceServerException( + 403, + OAuth2Protocol::OAuth2Protocol_Error_InsufficientScope, + 'the request requires higher privileges than provided by the access token', + implode(' ', $endpoint_scopes) + ); + } + //set context for api and continue processing + $context = array( + 'access_token' => $access_token_value, + 'expires_in' => $token_info->getLifetime(), + 'client_id' => $token_info->getClientId(), + 'scope' => $token_info->getScope() + ); + + if (!is_null($token_info->getUserId())) + { + $context['user_id'] = $token_info->getUserId(); + } + $this->context->setAuthorizationContext($context); + } + catch (OAuth2ResourceServerException $ex1) + { + Log::error($ex1); + $response = new OAuth2WWWAuthenticateErrorResponse( + $realm, + $ex1->getError(), + $ex1->getErrorDescription(), + $ex1->getScope(), + $ex1->getHttpCode() + ); + $http_response = Response::json($response->getContent(), $response->getHttpCode()); + $http_response->header('WWW-Authenticate', $response->getWWWAuthenticateHeaderValue()); + return $http_response; + } + catch (InvalidGrantTypeException $ex2) + { + Log::error($ex2); + $response = new OAuth2WWWAuthenticateErrorResponse( + $realm, + OAuth2Protocol::OAuth2Protocol_Error_InvalidToken, + 'the access token provided is expired, revoked, malformed, or invalid for other reasons.', + null, + 401 + ); + $http_response = Response::json($response->getContent(), $response->getHttpCode()); + $http_response->header('WWW-Authenticate', $response->getWWWAuthenticateHeaderValue()); + return $http_response; + } + catch (\Exception $ex) + { + Log::error($ex); + $response = new OAuth2WWWAuthenticateErrorResponse( + $realm, + OAuth2Protocol::OAuth2Protocol_Error_InvalidRequest, + 'invalid request', + null, + 400 + ); + $http_response = Response::json($response->getContent(), $response->getHttpCode()); + $http_response->header('WWW-Authenticate', $response->getWWWAuthenticateHeaderValue()); + return $http_response; + } + $response = $next($request); + return $response; + } + + /** + * @return array + */ + protected function getHeaders() + { + $headers = array(); + if (function_exists('getallheaders')) + { + foreach (getallheaders() as $name => $value) + { + $headers[strtolower($name)] = $value; + } + } + else + { + // @codeCoverageIgnoreEnd + foreach ($_SERVER as $name => $value) + { + if (substr($name, 0, 5) == 'HTTP_') + { + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5))))); + $headers[strtolower($name)] = $value; + } + } + foreach (Request::header() as $name => $value) + { + if (!array_key_exists($name, $headers)) + { + $headers[strtolower($name)] = $value[0]; + } + } + } + return $headers; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/RateLimitMiddleware.php b/app/Http/Middleware/RateLimitMiddleware.php new file mode 100644 index 00000000..81689bb6 --- /dev/null +++ b/app/Http/Middleware/RateLimitMiddleware.php @@ -0,0 +1,106 @@ +endpoint_repository = $endpoint_repository; + $this->cache_service = $cache_service; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + $response = $next($request); + // if response was not changed then short circuit ... + if ($response->getStatusCode() === 304) + { + return $response; + } + + $url = $request->getRequestUri(); + + try + { + $route = RequestUtils::getCurrentRoutePath($request); + $method = $request->getMethod(); + $endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($route, $method); + + if (!is_null($endpoint->rate_limit) && ($requestsPerHour = (int)$endpoint->rate_limit) > 0) + { + //do rate limit checking + $key = sprintf('rate.limit.%s_%s_%s', $url, $method, $request->getClientIp()); + // Add if doesn't exist + // Remember for 1 hour + $this->cache_service->addSingleValue($key, 0, 3600); + // Add to count + $count = $this->cache_service->incCounter($key); + if ( $count > $requestsPerHour ) + { + // Short-circuit response - we're ignoring + $response = Response::json(array( + 'message' => "You have triggered an abuse detection mechanism and have been temporarily blocked. + Please retry your request again later."), 403); + $ttl = (int) $this->cache_service->ttl($key); + $response->headers->set('X-RateLimit-Reset', $ttl, false); + } + $response->headers->set('X-Ratelimit-Limit', $requestsPerHour, false); + $remaining = $requestsPerHour-(int)$count; + if ($remaining < 0) + { + $remaining = 0; + } + $response->headers->set('X-Ratelimit-Remaining', $remaining, false); + } + } + catch (Exception $ex) + { + Log::error($ex); + } + return $response; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 00000000..dd5a8672 --- /dev/null +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,44 @@ +auth = $auth; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + if ($this->auth->check()) + { + return new RedirectResponse(url('/home')); + } + + return $next($request); + } + +} diff --git a/app/Http/Middleware/SecurityHTTPHeadersWriterMiddleware.php b/app/Http/Middleware/SecurityHTTPHeadersWriterMiddleware.php new file mode 100644 index 00000000..257f996f --- /dev/null +++ b/app/Http/Middleware/SecurityHTTPHeadersWriterMiddleware.php @@ -0,0 +1,50 @@ +headers->set('X-content-type-options', 'nosniff'); + $response->headers->set('X-xss-protection', '1; mode=block'); + // http://tools.ietf.org/html/rfc6797 + /** + * The HSTS header field below stipulates that the HSTS Policy is to + * remain in effect for one year (there are approximately 31536000 + * seconds in a year) + * applies to the domain of the issuing HSTS Host and all of its + * subdomains: + */ + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + return $response; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 00000000..1977cd7e --- /dev/null +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,20 @@ +aQBziU{AzN2TG)xslHucyAG!K2rF0$mqqwBw(zMR;`KW^n z2~!VvQ!k@<(Kl|n^YBdn`uXySOWjJ{TF=CDDTIVx>63d=JYsF&z7)Lw+)1;xG$kM& zB$V?*XPy7!-|aRHdb z+%m;BIy((?ukAQYf&#?%mFpRtpL-@J%Y47Pv5%E-(S1y;7eB1Sty2+(h;r8JD zbZ_-h-oQ{d<5h6M%8%#!qYE!?Q9a-cr~PG2{hhI0g{`7oU?9Ho~1Cy%+*T;a6IN%K*CWe1`1yAKd+Ncg_M?1ZuD zy|(OIwH*b+e*{rJ#fFbb@7N+so~qQPT1Qu8Hp=sNC+7{si?vvbQ^2`;ZI?a^bt@x2 zVydebnVD;dg14DKD`bIv>pvhbhVC62_&0rQ)8{LUTp`$q%2SD9=M zMj=bRz39(BBz%A0@1GqKx@2IxswMi2VeK$g~b^y+NIpxb8Y=nJlR z?)R9KVD^(r=0>Uk?)S_3CIJ^)vizd@<48dHV9@<`Q2`oTX!k;yXw2U53%QZL$v)$~ zYG)=1E2iMl+iJJKs-l^+{_~OYbJHE~ju(^NrCWtTe`b=33KXykV=sR|e%ggU|L$98 z8T$51=ZN~Vgj<9!%>JKymIw#060uZQUH^as*+N}vvsS(IGu+H;;VoIgl&Qg%-DP#9 zR8upo1KcOebXqK|-^?{dU|0Wy<6|UW$*BH98L>7iSdJ$%*w~5DCwr$jxv72hL{`eSuA9rQkL?RG~ zjLfcCE#AaGQ44I6z%s>jen;o})gn*cIgDeY98%7=S}}2T36k9jaQJG?5d@!|ouT#j zyVzov36 z*qfa5)j5lxF%nNp36iH4&nVVk!$EIYdZJ%W)n0o(ahp~AO0!O0A_bllCvkF~_O|vBw0Xm)k0B2OPlIRTXk}Oi2`9bbuc7 zm{c8X)Jn41vk+86JX8lqtbZcin)@%3Dj zc8sZAyco>>R);xx68+2P&E|yTs~E8Z zqFcFTs8(ID__=~$P^Kw#hT9)~9MHa(9DR0V^}#vv zBTN$C)@SNUJ(gqh`^HtlpM6?!8G?X0dJsi&;qMYCPPIQaU95$Tu=3q_u*22O%GuQ? zS8iE8y6;HIEN<%3x5wnWqTd{VfVOJPKcDSI$lor$sDCXo1XuNSb9Sj$dZ`hx_U&*C zwc;_AA~QV%CNl2=^**_!hBApwUhrW-arYs1DaF?8si;J>-*@L>eVP$@Zr?uS^Cw>n zXKtO74X7R|c6lk6ere16-o7c)t#O2)Rc^^~fhn3fuFc1w6gPL*gpBIF(J#TLLci~= z#6tqKD2Vw~jUke^!;r`-2v*)Lo zuwa#S+VMJ@C+!zizDfJTUm5@4 zF@$?^Et6d2E2kEcu49LyMnW&i30h6n0S&gGYo$C{W$hgDK8bXRm?w2A<`g!Lw+X0( zaM*m9#KMdgrde+MmacTQZm2P0-a3_l)l~hGP!kvIzV4W7e@BBRGTVcEly=T-uM}K+ z#mQS-T#2@GDfQ71v(1O^S-%vfx4jwI73wPQs0m!}Xo9!3OVwP{#}w?lIloD>y!l}$ zK$dJkNrzhYsySEbS7X(A=;zXfC->#Ri91p!H1taP*InPU@peagS!N(%lmqx-`cbAE zolBD&;9;hEVy#JenE%o0^DRPO&P|!%^Ssq%CGc-}4`nJW)HC;M8$1ff-ash9slE77 zT^-iqJCrV28qO^ZdsCUgvVN?!cThF5`^evbXkk~j8^Pq6$JPSNGbjGkETTy6X!0^* zg-C|X=ZxQ0vtKNUu6$elGM4+pt2o}?qXc8y`mdM|&njOX)pM;_(`agKj^~_K-1xf( zq}yZX?X}$y7@JijQZz!rRfh6y|EEYJ%Bx@D1>{$h6oqF()XyDRUx^b@=oz=XB`Wp3 z`rD0Lff}2!`t7c}niRp6b;C7DKBii?5tq}0Dkd0GKlkc9oN?3Q{xzIFTIKVwQdGhU zvowU1b^=r;wLWJ`kQE;sI zDDqQLY?&9X2s%W6u4Sv`HVAVq}=F}|qgqeV<8 zElm*4g816*Axq%XnKBf5N?M}yHTEn|9XdovlqnuVB0HD*uc~W1@bYC-{QsF{ZC)t8(w<&rh}aET2<)FsB65JbYI?% z*5pj*RR&E7iH~xA%-yRK=!$4imG607id@aoeFrl(+i-IqY4o2h9oqmiImrKTL)hS~ zjgwrUS6rY5_>k-9D87E}w_mm7Gga;-gh+>mT)tQK_GT-G;`!{br&;7ITC`@6PwuL( z3XX!6L+*$AEUWZgo*l-Ut10_^^^WZen7Rj=!x#bA9H0DSIesUCmnSz@q(3 zSoXPj_SZkQvnTy5_?i{Hr4(ZB>m8iqbPoJlbZ(Y?Kw`BePVRizeQo1bK^$py4v*0J z&M=}gHG6n?)`GsOvbXA+txC>mrAj+iVy(-A2Nw2SFZ+T{CG`mP4ktVD;?6yzl`5zN z+5kjH(^fQ@zbj5DcG#wi2b=?z_6JFOn`=`M&(FSI|L9-T9`ou+hf(gEyXs%a2pr7x zWn^FS=45`oh7YF%c|F^|c|9I$vW=omHfTm~)B zrhR%{P|q^nGq=ng{!2Cye7*d>a_{qV`@eFpx`eIBHp`I{?Rn{iLJeqcd?mk0_Rx)* zV43bJ!MEgwXG;j7yd^JmjRs8cuf2E8p6gs$J3Nkqg%8JpNOs-(RmAgz<5iM%&`PZG z)wQ@Tbw9vM_G*WEb$d7E8aGQDPj+uPjSIR^9!-=-A74lU!=+epT?15?_5_wS{E5=n zby@FlzI~;WjbM|RTYZzF!)zuLLbc^fWIvcsbPcQ@O~ed?25Ue%16c*3E5~E95cQUyK$|rTv<&2XIn# zBpmn?<)kQq;>ZOXms-ea*mLR9yyP%y*D|qPT;i)yHpAJm5xSb-q3wd>dPwcoZ?r`= zt!pYt0*Y@#k;C|*c4?+g(pDNGV<`NkS=i@awV|ChU&9Y&ZmO4F?fM)`qoJ|HhEuBU zpSgW2Ua#deEWkJveqV6_uOtE@YP`c!#F#ypn&Iu<7ciH9;WXa&qy-XKc(`7(ldYW% zHk<5_vI~ur_7WSo*9adT;J}fhyXLfrUEJ$P00OGgtE0#^((GD}Z2yg!a1!0&@(o3Z zmE3Fix&7~6#9vXepXCPD+xhN!99-pq&#ww3+26WN)owUk{Zjv_;MK1FSG}(+#4&8gl^dZMDu@<-*m1`Z>!|!L5 zG-BptALy8e!oU5#Q`)>*OtNhEz^HF7ZctLwEsqC2RTJD}+eYp*PIt5$3l3(>dRKbf zLr#n&^LMxL%?2ypg@i)&Q%8%;`SZf(Av%%@9MhY=Tg+pyo6WQ?YALfpH$G?{V{JSor4ZS@zm)rnsuA; zNq&|adwZ|?RjN+)t*~uLHpWS3+t}#k$WkK`<}uYzzidNe6tlTMQZ`>)=E!cBV%=e* zoUn9TA8vjYWjR_;E`Hg;?V!e;FKKbzUR9JlIOCD>R-?d^3;q^jwcB6r$*SLOgx?pv z`5-3J_HL(*=`ER0u(xJnI>u-+g6Ql)9^E-o-Vkh2F?WGkxFcrZwMBNN`;M{e+qly4 zJV;|ygq!N#9;$NzYN7Ti?Wqnujt2CoIJ%WJ$>$z3+oOK42MGWCfHuxy%H?<0eAW++ zhhzT6V*!)X$9P32273m!-^#{~{Z-bTi;Vq;yGzE8ekKlNYrErwzTRs?MTEPqz67US z)sz!%3NZ+l+_$}Fjn5^LRg=yRWGsgAh|F^n0xJV`hs8G>cjpXVAFp&T7^qe8Je*oNSp z(t{mr=PuO<|BUW{w!Mps=J6GK0h}l;j*-D4^EF97BWo=1*2BoqpYdAw=L~8fC&tS2 zcm6=kzk$Rt`Gx2Hgd=z-(cgW7v00(_AwyPj*awfq??OIF_xN0m@{!kToD~ZY44;Lf zw@WL7<#xXhacgCa&&al&gT{7-~J2wNjHcH{fxa%WN+p zb2uL%D2qqe*45QjRovWH))K`!OT?>%cyda_$J|vyB)*n^2sum1GjsTE`CY!UBsqqk zwLh+ka=IfS@XiHF2K-uAW*sJhS3WFdbr5jdbM2j@c%}VYC_WzTN4))1H-u=7tI+w8 zBf3p+ErfhaYpa&ka?QE^&i{PStaUZkWj@bl*tcdLdtbYkf~UAtlO_C8durxsz0A*d z@Sido$LyDIO5Ie6=+uZ?vm0@F2vNJ)MPPiP4|(G}&4L?Zy{8Qk*FM%2)4 z^J?9<=o)CUU)@IX^NrM7YXjmNDqrNLQ62Y4C6ToeZ5m~r<#Keu%`6`5u#ws{vqljm zp%bGp<{K3QA8!9jPq#qRZOe-x79AAz_kD0%dUNnrz^w6$UPF`9a*a=ar=%aSRR2+k zo~$n~2{+$eEVG2ZgcqaKRpVTs@yly-=9JWKUq)HAerB-Cd=SXY3{4+s+C&-maXLel z$I%Sa@pzEDx*?nFh2sI{jAqVdvIVz*pJSZ&s^^@*ED_xb^)C}EJ|V@tHW>&vulk^; zThOjHkNQK#C)nXPd7l4A|8aqZCFRhFMXLCEV(u%X!*e7KJcUjg!raCUdF+>zWP`b1 z{7S7zSU9RBk5j1(@ySN;=;>zB4*$HNbJ0YVwm-!1BqVkmQ&%g2Dj^V;ft%=on8hP< z;@5K+A>R{nPHRc0^A?WJXR~hNitFpsv>_Y7tz}{ZXZnj)WB9g$&-Q)h!OiD8dsFAT zFa)2Y!=?4IaK-G9jN1!8el(@i3o3f*nUSsWRy-{joNjy&H#V``_oDgO)opk2Z8lZh z<`3*_!%uCOf9;WbJz9BM<#n-KH&d6PH$e5{;RV};vJsmeRKUk@55d88^|08){QBpL zE1%z8N%iDNsb!&mH`&vFK3C#Hzx%zTvaRPghRDnhLO)!5^!=HuT~>&al-nlDO3LNM zo#xpE?$VeC?N5$~NEMYP3|gS?QqR_D7vXt>uXw${>oVCN8SHdWZD+0LUuCy?D5o_h zBfLigzR6v)4Vk~)El(piaQ_;mBm-6iBc4;U8vpC^rBaN6WxxUdE63pGW(NF_@9%t% zboud&L}tl@aMI5Q+q{*c8q(_OMyzkI3a-GUDLycbwG#*Aa1^?-BcnGcy%r^&+IEIq zGEXg=X^9}@gb^GfaB7smm0DZIX&&a*ty?8vn$%r;hJd^Ba!h-sW?%+v+We&+U5HqE z`s_1O^0A?Nx>ex+;Fvc*ZY^JSG z%=Q>mAwE+g!z$S@^OW9axW#2mZe>SSu0QG-E2Rr*j$_{o$z8fj;RewxUT0(vjD(q$ zFwl34h?fexqWCQgw|+Rq;docc664`>N|pD{H`za`UqchrvPz2(bXy&_gnyNx`AI0V zATgtW-{Np;cviV@_vWo>XlO=aLcgZwSz^#!#BKa?7FLc~dV-*~;P{O4Vu)i2m{0`f zO5s2ijuRb95XYCHKs-Ab3>$p!3JcKR5cv5sXY;qKX+B^An-^`b;%2_lp5+j-7~U_( ze|h0PA{VYB+nlUwSbX!BthAI-z&6_+vIqeq_Er&wXGd7_uerl6fu=-mY<?XYoOfBCn9Y384bSY4O#=$9H%I-CRW<=nrf6?x@Az*%1imN>syoKfN0# zKYSuD{%S9hO8)0=cxY%5Tv=H_h-uHeV&BYws5cg{=A%{7UX!SeP4kH?ojv`;V931k zvnFv1xHwI5n9lxBs`@W^&J-7@QiczwcYQ>AzNYsI(u%9as-kGOw+f zQ(lCyuxx1f*QtzIN3P|GGqvyuqqOV>7{ueY!F<5ih@6zoNgojCX29%4x~d z($MG=W*u>ExZg@hCEl4qGps*Nwr1_t)feI%>To*~PjB7ICD72Mj@XZ5OD0ikUKlhGNcG6lk~`MW z-NTHE-oe4;ZJm`}u)?3)V5h%1A%P-M9NHH|xc7CjUlp!WTvtmkv96(*R0P zAs8g^sGT+PVa4vTo56wzD(M+i?YAu!b z!O9folc(TKsu02Ib$?@cGOP=Y{;}m7z23b%bTVKU=hGqR`3Dl}eQ*Z_>$yGIZ3(Ex383IewgKH%cvlv7G6zn;cxZtlAZ(n6=+jvDS{eeG<3ET5s=;) zM7*-^UKw5FhtTycX8%c1 z!Y~;bAaz|{xOEIt2re3VCMnmLZ(qb+Hb(X;DOFf((RCzBRdCE@Txn7x;=0kR0h5nq0tM^A}pqvN~YuT2E#ouWZIGdEYy^+!x@J8NIBpfLrmB-kCIc ztg7TrdPdB4r3V){_?(J;kGxV6aH4-PdvhZ7Y?woN*@4bEfc&)hjXxsJiSaiU45T!r z2D1<+BKf*SL>*MFn0pUp1BObb4n&BOJVqK^|8&oaAKN`{PXy z@0qs&g7u8XKa+UY1%A%Dz|UFtWk)m%`PmZ;pu;u^#b6VNfx!r&*pc}7?5zZ^Z(y*7 zEE&}{Fepd-d8>n${MVSXlV@Z8o&?^`PX0f}yz`eSBw+|9Vf)JCcQXqU-{K}g^?W4f z&O22vp{G87I8nfoGn)2{Za(9u|8{{RXWg?SXJ`Mr3ye7H0u%n61-fT*fUeYZMh!3= z$ONVGy1f~qTMN@a$IX-@g-cfX6#9N8rc#1F_Vlt0OHh+qGEhcl3x&t>guNsWZh3p3 zjX#KuoFsm`CuGl%JH+5ocG8TDa#G#CD#DfmaAGO5bw=Nk%am)DTm2|)P&D2BzNKAj zu4Or%^M82b z(^Z9hq-yfiAr+z^Rp=K}|9n$%KuF_O*reS_;;o+MtL>}*hVyT9|4-rk8{Pj3=ilg_ z%{iO@H@bhjfo;zgYg%s2Gn;y8|MA#QN9#x_^yxpSJzGk`m29<))~9oxPw6lOTh87$ zN=Q_Gt<-%XQsSiziOe&suDd5$`kT)Z7SfKDS6;&SSj3id)iS>a^-whDMRR6~8I(wS zjP5QYlOoLzMgOcI4xvn~2z}lHAh!Sz3=4~;TStG}vx`nmKY-@|&UQ!p@3x{&0MCCm z2FZ|DNB<`+BK042OTY7}}iJyh>vV36KpTVfJ#P zvmr4X<6!D=jt{@rJ;AGlwYhD72KY=9s>va(yM#ddxno6t=^pdKs|311z^}k2aqQb> z2NlLF{2m$Dml1_^+9R!*-x#%-grZ;x#HVh2>qj$_^LQ{kP!4&@;?P_;;6uCy@b@Z{xH?EN% z0_or_GSvSeQ){K7Y2ht==ECwwpU{ni6baaGR3! z3`Jlh-+sAWM|y#!%9WwYO?Gtv9B}c%hW%!_nObNb`GS4=SgXA;tX#Xj^&i-|4Auyk z2O>WXcK5@INrekymEfEd>~dxmFfld_mT1F^eKh4W_@a6}Z4sUnyx~roafWP&3dCmv zeW3dlEi&Qrq>aH`3C$X{M;3I~f#Huy&!C6j<>CX{1Sm^O=>8sK9D`yE#*#%)vQGhv zU;*sV#DzJ2%Bch(W)_6kFZOyfB^}u==LK9LJpKqa+98mB!coUUe>+11v)__PhxSXH zKS#UBo_~J4nYssuLZ+XSKXj~+m~^fUtFcn+9Y_>!&D!x9TCkTF{1_GrTWM&&iy0A{ z)jA%{vYz)TEY6U$d83F{I)e`a=|DiB&bt_cYsq0SV-~8hp%y)p7kQTI%7E#>3lBW` z5_x4zw4o8{>n*l!X)K@>9=wnNEOg+N4eSk}FdA%{?-;hO#%`gxN?S~$B+ec%{l88+ zjfwgf)IJ%@DM7`kwCA&h0cHZPRWi`eSYcE(e;BN)4r#h^(|d!FGCqOxEEhnaA}}a0 zEI)xCQ*ep^?p1vnM|vZjrudc=BKg>-H}SpHK%{}!Fcs({ioq~4j)7hnb?7PgbZY4F z5o-xDJoqku2G@T?v0)xWMwH9#uK6??M)DC)f^yOK|6~5kc`K-mcOnBl$_`}(6)oSh zSrAl0Kloqgovophle-mG&_NAhc*+cZIn~-PfGv#fb4vo8P@FV|zv3|ru4shI&uFZ4 z+PBRHIghN|szL@tA~kG;zS>gj?@+^LY^Ymo#6jioKP2%3D6AvGr=iL)Af( zl*Md9tOr>Q9rjIDNw1e(qTGr^F;OJdDOZ(IKkxqfeel3pv!w9QXiU3gb@KCP-dtb_ z0J|OVMB}L-=C&**aS#KUog7-ryfSxka^&MWGhhuiAs*05DiHb$4@>eMF=vV?kd~G- zT9WQsF&#V;oMIMXXx_~A{eDm+U4l?rW8Igz;q#tQrJxI~RrlfaaDPnM_Pd%t#=~7r zlUaTA>x%uGXxo>;4UIoZS6J!fq(}S=A?*fJgK|vj#3KF|MMk5M?tQrH*mMadPdGo* zD90bq0{}s67!M(Qo|kHO_W>76MKwR}ZrKY~d&r^A+T z9TX?~=OP@#5Z}(LX)I&chqv-F6&g81-Vb^)GP+zHL_4&m6zl3g?UJdMC^wPjJG5F= zp>sFDW;BR>S}e*l@Kj0^a8NY3yU=_ufvc@q&m`G&pj=GBddt3AdgkU`M#>MYv+EpA z*5N98nA+$Ua|4E>GVjeM#>oy>iU@Jbx)V=X+c@o>m99VNMtS&^9jOzH^jV^0N)hFG zibxa zdjq?n2O;$DIkm1*<0sSni8fouW*ofjQt+0WuH*UYOzJ~O?Ysjr)ywJbcUGtrCLZyn zFDQR{_+jPoApafiTw7|Y&8w+0uE|O%0J5j2{ zWv6U=bm(YmDLhtQ%!HSN&Qd`9u^D9khrJ%Y{Q-_zd!Z!MxIARjyWS#kK`?J1xhB`r z##oxk_78Ntb)>MUHL#UCuR1_;;8u+xDg^amD(#`CUIk){Jc&|i)rXoLgzLjG7;mwZ zpyE$k-zQ7%WoS_xZ5MfGB=F+%*jXM)2Xk@x^EAgS2BF7U$93OP(fTBYn+_wBRMymN z>$u(X;bIYA^jq(|`>`@6kVS_2Eb@ajJ0!zMOtzZn{G4W-l zsZn81I^MCwH&?s#Fri|Qk7g|VqoIilo~~*4%dr-n+EL2G0zP!sW1^1cn{}4(rkD5m zLyHWXCrQUd+io#=jtvL_Uc@kzf~~*z6qMZQKuan4kXk{zOTZF416SpNM+_ru9}z1f zH9hrW^0FAgO!Y>KA3Zd2?m7@V-HIo~pH{K_Vj62aE!S1-EuT%UsvPnQ(EhX{>307u zEeI2Q@rL3uKE9X4K{kZh!(WRAGgZ2lIy9r$^8*7o% zsd|t!s`;S;IL_uJ4at}1-5_8L*m3)s8tf%!O+WrIo^YJe|0>3I$o|IJZO#;GALZ z^wJl9VCkrVgYZ0_X{Mhz@8TR|n^wVf!toa!~>e2EVH_lm}o zCZ5*L^J=6k0VL5ujAsdDL`?D6myg`Ae-)y3;%pfm6H@@67dC*bQ-11DAl00SO%SJC&z z_$BX}80UJ|pAo^u;FCG}2u^$+UnnONo;*A3!}H$0K3&%y!L@St2!vFNldQa3WVf(D z%$_sgpyJQ?$cOvmIOKJ_kKoY57iUot#;kUhL3o%K{TtWuba_g~LBrZ5@%FDgtVY9N zZmxB3^A+2^n>rruIQg*3>f;}0vdVNaRu>oy1I(zrQL?9=Qq3|R!}L>cEw^`NE6BH* z{w}E@!L*>F5S9^b&gK-JIKss2EET@}$aiZ=99dE#+Xrk;^8IRJ>+xJnd;P)!O*3s#9-;8Vdcop;x%pqyy1{+wA<*)RLwn~U$<{0EL1e2vN~UV zOEV6s5S0$z1YkLRpYuP7?N44HJt@-hAbu4_v38)@tf2BuKNze}thd%->7ltoX*QMQ zH||I2&0RlVb)lI=Iqv=WJM_ni%On>l-!bb1e$KtXFI3<=Hl%48y-3H=9i|(){>%!I z$Gx`Llc^S$|8;;k^QSzIcdI8@x^k{^d*2!)k!0$>?{_bGBPw{sr%`lL|J%+d1wn*8 z)A0AFN^Jpxw05%azQ4#y=P=H$*SmKc=B}>VN%?&f zywVyR2X1{QEMN@Aaw$UL{ zT^V@cIZD8B9uL~HG?0ijLPMThviCEpJ7l}>8#C7|bt_|b!Y0((9r3#~+R6dED*V5+ zhJ?C?`{s4!kt2N;+kvd-WB(UUfXiBcB(}a?3Ax$&<@}0|gukL&?$cu4T=?XJ^Uk`& zpZ4P#&-wJY#1Zy+MF`K1MT>xOWXRhw2Ymb#jS|3da%03<+e9%yHUF$suS-;I$hD+6 zPkE6_B~Q`-Z#L$}I_74HkGDj4W^gazIWg~|*}F!L55YS@-zMaoX@|oJpVs>)F0(jm zxjwzuoN7CXrrBz5P&Li!DilU(cA^PHfJr^G8sNN2z#pCtRBC(331brL1n}`3a2?Tg z&Vmt>bPD{->-csq4+)28LKh8WeMmcw5drO?LtisUkZwdKsC;EJ>tpihbKbixj3N>N zM&eB5q8q^Et*W3%fr8jTrJPZ8aLdQ@_a@z{I^6s0AM;EqB~N+1xQnT-4k`>IDBvpT z+S;$07rL#?)WXuJ5W~wU%dAgUS8(n2xNi)}!U6y@ct)G7syw^LglVfQ&9j_!ms>K8 zKQ$>Q?6^Q#B4IF#r2iULFUs`f(LFwfqt?Cwj7bo{$^7FT!25?PVr65>?N8|_MKOss zk)=n%s7BP%SwaG?1CT*^m^{C~?(`t)1KdnELo&eJoN-q8JVIP~RPc`cZ(uj@lQ?ob zqhoA3(9fCws_z4PZrrVym-_g5)eD^GJ@>ptl9$s@D^lP;D+ViD>RTdVx3I30@k{Aj@{B;Gr4XzE5t4K; zGJDa2wCvOJeFjwmiWJ-;0%y1XDe&w-F=wPh^wCN0C?{FL1YB6z5>$C!x=`yK|AHde@nsII*aUSPKXNR`#2pS5-~ zqNw8|WJ6@>S|fl&hzQgO9LAHyoc+^+&AuG)VACQZ`CfSct1=Lbv?Me0ir3@A5)1*1 z3YZK32;Js$NQqPjdVh@3F=DDG@KQ3z>`(2`Mk%Ey8oKs z$y6`GYO zc#6S)Yh{^~pm=M^z{+;{^3J)`eD!C+=XSS2Zh{uqQxcO46$%^cb&+ zJ)d5R`=t3Y>FJNLdS6jMRV+Lf{u83rek(vjyFKfXcgrlJp@GYH^jRGX;22P@y;w1R z#jfGA>ik(XEEaCDGgQg7lnjx;hIc-q!bX-R{Jwftm;#E5eMrlu7ekeu-D2xf-I1jz zr++mBrfasRC1mw2HJE3@3KIS!-ZmrHg=1Q|H7wNn%z=YcgH)B<4o1&1TVGf;UKh4M zzD42jP% z(mxF&$j2>XqyHaafAv(t0T5@JE8zg@|1{QsTKk_P|26`x-}3`?G2)rspg$gXsXmq3 zIm0q_heFAc!Hh#+=w0$Q?fOTG)Z5HLBvxN1fibH%>aAP4`cN1x^@je)#dcOYW#OhH?4OuYOeTO|__> zJR<7a()|y`_;BUJqE=@qAYS=kAO+H_&9S-_ z9%iOaM+I81M{erivuCNP|4OK_FOQPBza{qpNu~F$={58ND(>%reo&T7Cw8fm1$^}JV|4ViT0E)}s}N?=X+gn+h?0|l=?TA9`w3s*9P zhY{BRt}QDaR!~7*Vx(` z-6w6novi+D{C0A&dj8Cfb`Uj@;edS z_kh^et?95(WPXp8LXoCmTK^=S?THEwfjo}F$n+D+g5pn-pn+ESVyBLl-6v?m$iO3 z&IULpk^|F;V{YLT9swP>V0evJrxc7-mm#q4fi@XgXv9sDrHcwfN@;hCGj-_dDa1-+ zA1mmC)WcVhGrgMpPr{!liVOxCN*ww7aA$kRnD172Apv#(xml;RZ@ZLetld|;p%l4W zc&KL(QervI(HyhcwoC>Zh*>7%mWgb@jL2o&&3yvwHeBguLyoe{kXkS zg&H7jmsM~<@hsw~jWP7Mnyizq!!aw^Ni)+E1MujG5ZDmIssPZsx!?gREziQcOU)>l z2jrb-E|a16w7xvAS>KY~8PrgK8pUxCK6UlhIspt%@fbQdtG*>-=ctislnDDDsxqs_ z&;Y^on#piScJI5s__`^1og>2@c`g`O1;Eq()p;24F!WN@E4@a?H2uFOhQg@<6YZ{v zqc+zT9ic5S?ZdJs4SzLL6WPu*CpkiYNOqr&p(j0yG2+MklH5z_9rI4)Do01Y|1}a; z-99ypwym)iN0r;j32Hxrq%G#7ASJ<;F>C<+iwj!r?7Hd*#YXxjy$~rKK`;n5au$_b z{c8goVFK^l3jt|)4x@+BT-&usKUUY134O{4;WG)L@RKU9O=&G4DQ1wbFBTL&ET$H$ zod11}4FU?ib({aOzx9dc4iasgqO$pGiC?iQHH51Z9*W?u&9Rs%~LR%^|}+SL99*pDOqKEFK%)A&^K(Y%zG5~(kmivyNdBW<&+Zj?4~SE9bjy_>6% zb>x%lb;T^3hS1=u{}HG!=++MO0h6K1afvQu1~SUly;-EK(z*Oj-c|0s{EY(<;cK5; zn1{Tg^qyhUkl!4U#Ha0%8l30|2gz^EMMk`W(P?0jUKf z;COWfkXqd$R#5e(tmk+-o2KDz>pgBgNTKhumy-(Rg`ZRcOo~la;<;cNM1Tu&d)*^x z^Qfrz-NvxjMhVjoYpg=U-WPGxX2n;=D(wQ%o!oi$DQ?mKNg+JxSqhyXtf3BRv{;kR zc>WwXr$7IOjhcWBT5O_TRL%9oYgGkVHUv~%<-UB6E(WL9YJr`$2QF+R%cV~gyA0&8 z>Y3l$Fd%-HEUmLUEGdb3u=C}ElbUNi3ng=u7WKc2z|UrD-Esl_O$chrqj=-JhfLG! z&ms+vUYCANO3%iP)*bLpGu8}~rD9hU42ko_O^O=>}%s#_5gT zDR?X3=tgU_9Hyw!7RSsS$Jf*-)giPOh|0Rx9wycpx4beWE`L> zx~vol9rH0Y6D)e|7BBxd9et?T`OX1dBuw~&#vmGFDfb9FY2P)5j;7r7-uAPpBQVe^ zJ2H?c`1&H$9gPTaJis^Ua(Mvr-@rwl#bj|uIf)$l45A-@_IZz~Gn*ELs#2!+ZE=b7 z6IHL_=pN`^1I$r?FjlKL6~M%MWYn-s?6urw+yE@Y&19RZgLdaV>ESZa|W|eFgb883kO9;K@vT8|BF0L$w9F;&*mK zG5Vf!Zf))>v@zd)e|a&Droqwuu@i`x0;F^20+-TW=Rmyabp2iWUyD8ISymr7U1*J@Y;w9POt=h{uzb6saz ziDnrB!>_5MFg{7$ZFpGU#?giZrH@3OX9(+dq@cC#=$rrk0ma_E2!SzDI z192omyJI#N^*pxXU8PyHv23p)LFZ}6@$jR!{H|;%KO0=91(^@WEU5UD6ObnLKMDJK z(+6>!TvC-)bz0B);Q7+91@1lE#l`lR3L1awHM5u?Fy0x!EQ&mURNZa|w0x)}6B(S8 z_VC8nUVWQxMd;o;HnN)!4)u9t_RY#|h9>W+Z7`7IBv$Aq0?iIj8X1L{WDh<&i2Qm$ zZoZoPF|9**fHS_{tHNyY9tal|K5J1CdC0d>HPgkge^OhUWi2I4z%ialFxH1@=gHGE z9o=n$lNw_xCkv@{tk5~FA>8&7(zAVVzF>qcMW(j=Zbw*&+uoLJR?Txq^k9g*F9!Me zXEkblSvNju?%==KMnMRnU)oI{+JP06=E^nPS#*@TcWgRfE#wd(8~`hLRBRI8Gxq4g zw_(@W#6rt49`A5_t*9>A_F#ozcG}yrc>_Pre4A<0aqKC-EC#i3uXtn&tRD7S=fH6E zt&tp=sfza%#&*vq!VW3kwa%V-f9>8Ft*3DDDX|&aZ1ZO=Rm}+5(v$F~Us(DP#ehwU z%9V5Z8N^k3Pa17QZw)D|@@bqQwrBwc6GG;bw`p*lV#JQ$fv?WAw!YXf#Y1VCl*LY1 z`9o0w3B^ z9Fe(VdCHKZJtBu-J_s-+n^c9#=!-;?A<;zMFBl&BaE!0|R;&{>F2`BDT=mz85iX)* z@|nmz*-COTT007r9Me68k7t+!53b~&Pq~}DM-99Ve~R6=KYj%} zPTnX(e(LbEpNM=}twNWK;^u;`wE#0xQdq8S${D;6jP$GQvOw?q<_LT>Odp6B1S?#iY3w84q(@ zfPKO^Jj>cE#bhvyrp5E}IA}Nu_3PIc+V__@q854-8OHYv1Iy6Ug}1QrggCc`4373A zeIhVQk-v6;5Vl4j(Y)Ad^=o`xiSglDfdl#emn6 zQOl3VU(i1EcDaMKI;ZX;?LodC#bLBX$}waa_gQwxl<6&5g5`sqBbGyub)oUgaS3n+ z+OV%>r$M^_Q?s)-4P2@FqOv~Ya6?*MfG^~x7d_H#^f;rPcbch+UEO@o5rHLzK-)KE zR({DT5Qh4;2Z9<<-B)VT=;te!> z93R2&b1OtQ$nKi53G8_r9Q3Lh)GT;mo})X5KcA)oL)EfH)hE39zSTfOuBV(RwP#3a z9eg~taK-p#uDO1t10Np&lL?lr!S8b54eg=(99RK77c}6_Oh}0(_y-+6q zxPsOrYJR9T+_M|>&`9$sc(2w9u2ddB=7~xYH3DpJZBE@mg?^A>j!PNv?wu|6m&FRQ zVo!TlOo>=?^Hd%PT968JKXcrFzh@oRqbM>uVmjI17i;AESa)vvMhL}q6X)-0 z%f#P1P_1R6+g82qIz)%~i;mm$W3!q(N#>8TC)X)5*ZvV<`&Ky=5srKE&*4Gu{BFEa zd?Z!$8hK@RMOq7#59{xl!>{{GTOrg_I{ zzQX!Kxl^Q-Y_pMH-8&9jX+;h&e#>YOQHlC=x-(1+ zW=0x$T+wNUNvP|>_dM(QPu`T=#a@P;H1bg8xUQAY5g$kXPwHj7xVxs`HyF zoacC<=B}L96}dt!0`DF5Z;9T_L;rLh(}77IgcqRS+imag@{zB!m_VKbPctg!J=mLc zH^CE|djgp43?$KAD?I48#5Qm*^OSfVz}Gcz71>(`E8*X&7xb!SOAw99T00xfDstnw`%^w!@O zC`T90^1}+4l!Stx{|Qfc#lR!EBiL7J2WG|BtX<_-I4pbCMx4@qJnj>7f-KxwyF3WH zANnhVd;N~lq2~BkL=Z_9{7+N`tKluJ(6v@R;{7hG3`WEVO2Y?wgn93EweUlsC^l}( zdQC66nYAA)=?pxm&d8^Q3JdRqjWMemo)oNc2nz~N7q6DiIrXb*SU199yJh;EgxRPd ze##Ic?XnrA#-~wM!{L@5T%t$Z@KrHUYS8PC-iL7tnNeZ8NmIUN%AbseCv}fu)E}QF0ebABsSAl(#Q<9HLr_jC2FUoX^w1IdXIuA zQrqJ}ti-Z&SmaP(8m2tFQQl|>E@|u5;}pftC+P}^4$?Q(v|%GG(sy;N$meFr#{O>+ zo-LbAk>khElG?|y+eq4uCqVfgT_e)8abX09Vl5n2L2WL)~S#; z>%3MPUIj-)(}E9TOW~q-DG|ENoELPo^j*w+I-vZ@yT(mqb~v4;!6#}7z#FzgER+rh z8KZt}`Z5>T#ovWkJq@{bQn4B`{q^Qdq;v zNx!>kV!Qj93|L=ScI_d8ztK}QW${5G-S??E3E2x^u@Gu)4NS3_8FxQQef$J_j^tty-_%tjp5o41$}Xh= z;!q$M1!7ph;b*E9g}8S~-a$WiI~C+RK=>v@y?siR2F8qqN&BQQ_GSO4>OUbTV76dx z&=%84*DB4R_9p1GGF?l;gB}9ilx_nfMue>7b2z8?3m2CNK!8P8d3LQaT;>1Td~kFB zuFd!M=F?hz8=E_)in43=B-t5X574HIKsf0W+a<+3oayBr+5?~gzwc~On6t(wQ|6~e zL~8*u-u?lEKT5cUhb*00@8WC?8K5`smSKN8=MiA^tmkcq`6=*^foJKNTu`9`@VYme1QSu=W48>7M zYPXar@LzT0f5AJ@UvNC5K5{Z1?p@iXUtY=hDG}aJ!>*>6$)!Ndi5a06dWOE1dhC21 zywPOtL1MO*_-#3Az$kSHOQobrLC0OIS|EI3G72d#UsU+N^eQwr$VrD+9xFiAQVA-e zRpBEfH#sH^UU#0!&lp&|AJJi9>d7&k%_#|6etU~sKOMn%fp!B5>h*Zw;;3Oyk$S(r zfAc_xcxT4)R+*R}3vj|-fyXyMx-zE|zf&L77LEEHsnkAj84Ht~vZ^Y4%Wyk-Z&Wjo z>oLz$16=r1r8e)ADjz~}V;!8DX1D!=p30U27U~dA?|xpz|Gvy8>{_Z?lojh=_FPq% zPj6KdI}67XPo3mskT2y1$B#c>QJ_G#cs%xOOm;rDN(A9{xG1nhXV_9lw?vKmnK+R+ z(d}=#K)t$V&s_bM>b(lqmHI@bnbn>KxI1RxLSV1#dR(RJl@nuwQ>epe2I8d--B{7j zseo^4EYb%zaeU8erv$6a{#1*W5^M?(P)r2sKztun)C4eA#1m(;+8>dl|u%u7b|8y5lAC$07YR82_a> z^nMJ+mFORAo@iLKJ&!qsj||Nh_1H<a{^)czE)*J6KDopl@ESm;St@xzND&1`Se z^Olz2h9_rfk^mbzAXjF$p%`3*bQ1DULARNS$}6%-2oJANgvIL!3&NlWNizS_+^sTn zaBz{OL?6sUcK+Rc9Kv>&BKVzocF8(udU5nV55zBXhI#-(^B@ZbG~( zKLpdMKy`UOR(qtT>3dgyJH^>PA+Y=8A#$aQ?bd_&+~DZNZ-NjHWX0w3=n)-WDT1s* zw6q$R{mHD`*Hj@BV)U=xe~lXLsIxjg-n>B>!Oo@#-`%z1-n~#-w@dn%SWfKRp#2rA ztUJD7tHf^QpO1v^VwMp*CqFARk6=_BP+R-<&l3q}-eL*9bR1QImMdW?t=-$a%XHnP zf%HBU*mLjh?$(;I%$_+ndaC@ddxSGtmfTE!at4WktS6UHpDBpKIK=i}2Fr(z1XbD@ zMFUIF$EQt$DcvRhbbVq9(!EB*&2pD}0&-Y26!yP_PPFrz@TDRI8Q)-OsX=wUwbtC- zo8GM%ELV3WRCs+oWDK=d`gt-Cy#k6FqU@-n}g#R#WvDhb+r+ z=`rRWswSFZgGPqlC|u@@&=c@reT1vEJjJ7odF}rN7-_k!6Rf9&b{!4J4%6WeKPizs z{4vnxSzRB1gmW{VSdwyG6aM3ay(oXD1$)SU>HA0lmXP9 zCM!LJ1bT-AT*=%7-IXb)fRp|%GllK`2o(Jgy$4!Gi2eT) z>Wn1&4&+xrXeR|MF&Wvk_;=Wq|0?@iM=8aBI_(6HI)Ls}DZSHx^8Z%0@~{3osDFyI zBar@7_wUaCA#B|9=);{3D3@Av@188}Ak2nTRFtxco4jWoR3?-Qi+2jnv{ktAi?@H@ zdHwN!?v!WzxdRjtR#O4){3!wmmuLJF2JZY5{@>O9Z(#!GUEjOCLHcigO+N8^?5%p& z?v&zQW3f4d^29#{PRG(qiu1))PXFrYep9>Y9o{9SHbsYDU*L-E}+_WQ?5|! zzLOS|-@q17CfO>V^Bl&1f21L9yt%6C>Qw&9<_o^J_CWfLkO9?Bj*fnw)BS_70$F_p z7#iwTk>%$+!ZzZChET8N`KjP?016BwdPR}&&F~$)2qPn7SQs|2H#(bf<>cEp@ro&c zD9f_|q5z7-ku zWq)rjUr@lj2?`DBb9T96)qO@nG9n2x%|dd+fBC1Y z^o!v=1x~^(4n}UIUt{x!BuJW$<}MFyvM3svK-aI`(tG-*Hr2liUoI}X@|Ci(+U%Wd z<}>!?U%`vEf}r`$5YwiUlYBuzi>9%$1J&Jw^ZEwCnwt06mzS5rh>zZPa3N5VzlHX~ zzPqD@YYeclDS5L(CI_2p=X$$%+Ylz1b)WV}?3_>YzB%Dx0p3|dm`++_uz)~ii3l8`mF!7rkR52>G4)38(3zv}<-27wad_Mi-cKg%+s`7FPW#FUv`^j8R4 zo!o2_7dq9*Ub;ndFYk~^HyoPp?;Yh28DuMR zkQ&n|D$L&xpYnMLz5`78nh!#bdC#0QW!^ripJPo5LUR4idsUkIt%fsu$UjHX`RKH6 z&WD=>>!HXU)CmMy_W79^rwI%1`?W9=w#KS|p&H@~Xy~6Cw?Eaqsv8BXnTExJ9UN#h zna{7@k5i0d-yxgYhr;F1svty(znZ@|bYsRL=r;U!nPwKnZY5M@qx{DD&8Jp>g-$yzQaiZ z%{=ahcv1jca1&myXq;0a;^+|b72uXD2?WyJ=IAFWcKrD1c9(5$_rtdsPxS}6!k0); zZaH}li~){zrFvLw8XsR}lq@OPf1N7&nQbkFeeLn;@Bk#?>*+KJKDew+W~ABq>ayP~ z>Bfc=3%U5Vs_|=m{nsYqb9c#%chVW2H|S6{pt-xwO0sJtO7yBTku}l2B)M#IBtA3# z$tC~l2Nz~xs<9z)Z_bHBwqkNllpvnespfzPYl`3>xdgB7ywjmPpE?uxg^NW$YaUCU zC((Vy14ZbrgFZ2eGOS>JP8`SU6}i`E?3AhgjAdsyjFv2*k-^ti5>Pd~0@7 zHn*2MR!Juh^?l`8t3dOuJBTRilgLJt$jpy5!>i@oIP^bW&>kK}lz2X`bRE|uU!c!? zH4gYeUYdJR31haRO17f2^;uT|{heyp#s{A6DM4#|*VJ5oYRevM<=pewti$87IrJ&qmgHqo63*W&mI~duhU;amewG`)52gC>M?-H>RHB{`LkFGnLmdt(T>Lt8lB=XBVsAS$F_M z*$Hr*E(H)z)lEUF6-!v(!dtblJYI5RE;rG~ziKK`8@HsslUwD(z!0!n1*o-t!#W5@ zh!fZN#vu=RW@C4OmhNBG&4yWX?>?Pi^Ig$qTGCx00@$v%axT6;CZpiI0H7v!c#(F~ z2?*D)SVRe=T*fT#vEiVRzxMeHK%}(dI46c8SCKqVF}3%YgV$Z=4kG}JDPjWd^4-y1 z5rVhyfs=XjQzW49lZci_*}c6FN9o!|d!AokH=d}PyJNihqZlNCI8mBsl{k?dTx4gI zW&lZFcP!B#VvP4_onZ6YUNui*&&L9N`u=44u03y{z2pA=_0IbRBIDSz?dCnkM)%qeYH_#Ku8sQ+ViP7U-2b z3q|v}%Qg4#xy${U=rFPSHu!~g%{+HSjz!#6Fy62AQnpT1CVvGgY5*!?5_l?*zq}xr ztGJjK6;U)rXERU#`gI-B9drSB^P6i!DfT|848{it)y>KgW&3TTPMx@zozbG5nb(;B z@`&LVIz9f4Es}_JEd>4MCeN9{&^$WsKk?+|^be}sH;$>=>_-r z74UAW-d$ev`yk<($0CW<+uE06=T8PtpY;$HrDK<0Ng|6WL-qRK&Xov(qu?)s;8kXp zHe;9Ke;>*eo1hcfc2xf;=itVr0JC^se!s zX-5;*Q$@OGmLJas7dogb)^~R+3hGeL!t>rFt*-L>UH{w7lB=$j+P}8TX|IibN9Hbf z)B)Owc@qndL0r~!=OIx@VU&1h!;~!;-FIt%PJOjyp3`*%JZRGQ=>Ac~LHvW?C?+a7 zCfMi+KYvN%3_l=vhX@P~e%o3uWoxVF**3K+Sx_*WlnqC3^L$&2c6L56Gb6f1^P}_w zgV$_#B1j-*j(6dBumUuJ{sdg6rvE6f4|I7{`+O9^7WV@8f5K-*u(VR*|?0{G4 zI+G|Ox;>_Ja)?nrN{Txi{~Q?4i)yILd#qvT!kZv6F`;5LZlL!qhi1$?Ph?9e{fooY zl#zpD|Jf&UtEui^i6RkcWUam%Ga4jTBNg#CM0{I0bLEU)<#Yo4Z{Nj<@a)f~+&+H( z^v|&Kttzo=ESHPeA@DF6qv>+`sQ=V*4Quqi(Z*{_^ax{;{>Vb!9~6~zC~Mq7i@{M+ z72=fb+4z*xUqIY^pV9AYQw5>X$v*(Th&SqeFF&s<*I|A>)X!L6-Xv5eU7nmS-&PU% zYBSx#TE)qt`%Fk=bMezMxkjiBWL2pzXP-Ig9^Awej=V#)WTkCYm3~yl1f0WR-{zA$ zHnTU$W?^wHJ9=}mHp%E!U1Oq#w$*%a&+)#CDt&)4=kXU5VIt2Pr=2~WO2RA(4ZNG{ z!?W_mED356+#9Z}AyfF+LIbw^7&ScRqs3Iq0+5dRO;~Nz7|~~?y))>ZVx|28q3>Z# z%42`43x~d`Sqk&64ZN*j68s3A$P>=dP~7=>r-HKYF&;*~S!*js@7PQYYNkZh5Dub9 zyxs4i6Bt3i&d(K0svF>ms3DZ@Q^9Z!AHrZwk1TmNIMw}lT3F$!?^s( zjjx$6tGt+b(#Pc|89tG*K8P2dboLYvWR0m5>`u)!M`%1_5dj|Os>kOC!L!Tl0g|4h zK^3+q&r?4FEY-n!nuD=v0#bpr{oh>L+3ypD?)nDCfvWXGkWmaAC^$YXqusQ-B>A7} z@y+~*y}UeePXPFQnL6}~-Qj4#HPd#p{vso;CzseF>h%CS>u=s!3%Po`Dva=12>o8N z`((iDq*H(x!0yJUntZqGLN(z$Z1r6YcleTOUC_~0v!w=}N`evN;RDz(Ib3!mDg-;8 z?*(Gz(@Gy}mrK$;(8#t_<;D#S@`03y zWGK{8G9(2i(kwwS3xYi*|$ z(JH4DRIPbfrdwgip{Azqj`a18P~FHXnTO?-$9#94ex9(g@Y-~3Rr&}Ip=bQ&=A=2+ z+WBmm{$y_Cxj8O^YeX>*x(2?Ihna*N<8+Sd)gm_JmLEyqOO=Ar?Oi@T^7{!=s(p|B z=&~}YZWvpF>4EU^7u+n0@~uZAlFQtfMz}YkZh=nk;CR7===TjHD<3I@de43osb1^| zxQ!hH@}s{grsumic*=iNl4o?+CC@=7m(r(BYjDbqX3!ggG`_63Ry@s8#Jl;v`9hTF zm6M);*#x#2q6NrpFF%qG)&Svndj{p#+<5w1|bp$Uw*_W zWvG_#n<+B6k3i?OUiJ-b7Z`sSAqwudZ?L^_4;z+I)o$u^i1-}wp9cc7i?OgZ@SS_= zzU>ei3FfV|ShNXaRE#5#Iks(y^8HjW;ka12dtDmmuMM$xdRdfiP)H zmxg9)@z-*Q$?3BSRI}Xj8M2=TBS~p|J4MC7(O^2|7b6#9&qFw8Lswt5`M_V>4P9Te z_SxH6)TEE7s{D!srj(P8GJV7S_sG(UNJQhMOnqk3;J1R#yEw?B=MwkfV=VAb@gj!A zTqg1X2jMousqC-S>9Q$O>o8L|^N}2TyQBv9R=+z(?q?UG`gBSUmPt_b!vKEcKkoW` zroYR_`2>8p(9LaZ2DIH<_0`SWZf(b%Pd6=gs)2Tudokn`e$_)=O=L9>#I(|ckJle$ ziWlE5_ww(P_?&-*&%zSkAutQf1#&|(c?BYnm$pnF_>?)rs;zrp_=fE zJ`~KO1$92SompL7tv)4&AEtqP4J3VTh;J~_MW4eBVX@g66py^J{XAA1ut2kHFhe-- z^M^S(RylohPPb%i2JhQ@K+}}*H^)$y2RBI3c}LaPLGLKbX{et(6f;N5rw?8w3l~o( zw3N>F({W{?1;2uB8i_-SG)VIy1uwBeP+2636w-eo$>3_9afR1Vi}96WtpMB%ijO^2 zo+MXdh~%TWM6=Q)Q$8Yjt2}`h9+JO};P-A)P5v?4Oh_wqPr%bTJx@Q>`$AG(2UY6$ z`Wgp;z7nJ!wU4|%;#IxdaDkJY4M}}^JbwB(2B&;Y2Kk(x8eTIY=kX51<^z?d829j; z&94t%PBk$wFfub58~Hs|5V98LBudIie-K&FJ@#ifFG5S$ zM|Yf7IC^mlGR>tw6if;Bv(fSmyzKpyVkBa8`G#5V$#Nx*2sI=pk~t`}5~ow*2lgXe zdhD8oeQ(dtE8Oe0s6@%H;WUvFY(~7##yCG#2(pz3`nsYWqfu+KGv5cTu;Cv(52m$a z&Xum?cULJF^3PgivlKNiuX>gC9&>GKFil^e;=tiPJOFCH(HQ;%S~fSZ`9_Dn$>Q1`Sf6826>9+c!44>5a<{G^D0P>@NOra1rpZ3*2W-gA-67(38xT(sQbnLS8Uu$yP?Z+UbU*m&r}U;V zCxIp8(=fIeDFhRvg9R66nlO~T!A2P!>p5v3oW!sEtp@Rpz?RL97KEcT{L~ul=tZB; zmPV(Y^8HRHEBMLd6y$eV$6pWQDQ zyKJn#(kQa3B8CNLr%xDaWYb0NP=r>YOd#Gz&z#`9Pk;A;U28_0P*-8 zNqRzEQPlbBLF~^XUE2sIK|FEYsb6W|#vsalc;A?}y+B+SZ^%7KiJM1Xky6{HIvW0j z$URCpCc{TXhjGzh)&+Wo+9jFqkJC5@{2(wvLaus+g9^3DrjW0xdV@xd9I_&87x&{$ zvZEpQIRf{*LzyN=-n0Cb>b;lrQs8VJE{7i`S;aPNr2DKPIq%5gPjX>0g=jP~Fko+p zts$NDrpdEtmUg7^<1B@ygQd=KyLr2-M znshT5G@+BRrfULC+^fMQr72{U3cRqbK~r{FqH4T4p%y#_J5y=MeW zM*0jc4i?%A@GgX-+e$D;8C0c<&(Q-h z>Z&9+cXlOyUZi7QX<(E|VfC!Nl01t7iR_hq=wL+`2cly>NN_mnEWwcH7=pY zFGpRESHm)ki-Y7MTnP5RbJ4gK^ec-d^!8I&xh%h^K^n88^Wn!4!D zDW3lwh1~LG`b$PLYeK)0=yzhI+zddRV(K|=5OG}to|=#-4)x^1jgDt4lG_+#DU^I% z4)L7D@E0VjGzh$-cM%3?PUIZt#Tf+_I=JAn$MM=2y5KqT7aR(L6wH(iEh~Pg9{LvdIIyI zs9VB1uuX@FQ&La-0#UFn#phik45Gs5;ix%Csp8{OQUme^hV1br@5|3(;JAA+j#T)o zbIZYOjjt$7A5na%nU+z=_*u`x-mwR3bkN?ccuz31@-!+cLjP&>dhb*JkPd;9nws5J zVePi$^rz?g7#^|)(Kro4d56JkP4E0fj<4%1ZW#;yuIsU^EC}&pNFpe-%9ij9gpI<+ zmm@!;T+q2x=0;@d8!;O4O=>n`=y`aH@KUy zc7*lP78VHnIdJ`QdCbAS z8$?)*Jn1*}=0d`g9^tpfG%&EC(e3`qn^I){amhWj)!4GKqqq+{TT1;owV6@c&7N-S z{mH^W*!gD>7W9=n+&?p$+cIxqQG^JxS#ZX2>|1^Q)=qV7xN-ypcL-rHtSigZ4N7V?t$QX% zh8;NfB}aBh9Lz?>yp~WhjIEYX6=dOHhno-O4w9zP!yybQ$Og-bQn+pXe4Vjt{dX~{ z`sBKS9&LMp_)KUB42fVQRZ5MD^bobhG!}85%xUaP=?oW4KMr|t7u6&hb@`cI`68=F z--F}sWF?^PL*w~49KGW?_0k9@nBW0ho9`m9e}TkHpM9Xy(qX2Uf3%MT(*GReceSpi zg)U0bQulGEF6Gx(v%jrRQf0rT)r5+~3TQ#S9Y_)t%)#zvigxRl^Hqswon|{N6fQy= zG3{>>%Iw_*P)mTrE`NW*QRR-uFVN;zQw2KvhHSWCR}{SPXfk$wOXnsls^4c4(chmA zMAUzl+mmfpSYPNhp$Yr~t^QWp;^0>GA-&as1dDG5Vw-vu6QOh}M zor5ryFb)1PP8c{8%Xp}Y2;8jD-N5FZVj%u+C?s%0CM${Rg0ObXwDq2_y{>V4-%r~& z`SP=0S@eBa^fL+FXJ~l9hl3hlnR)5Y5vv^TSwW{bAorIj2&zdX1A6Q>>Z;+#Q4of) z2yb<0iKvjao8%i5t|zz4E7of%8NYh(++A)w=nyOg92S_RxIIiKs}xTkV_^jR`m7*8%}u3g|B(fA zH|=0DOJt8kk>%x=CzXu_B8rP#2d#Tz;al8p$G3yHxG}N-#}7eQLG?91cY)oC5*r^^ z|COG~Fz@vq>Z=Wmk8z(wj6GMMyQt$Q(0yz{sR4hPxJ-){-2E1t@6EEeM4Avx9^+Rb zi_XeW`)EMw)FII>J=cTqMAl3BZA+9OK!MZm`NE4MUL?O&8GJ{xk6ZMX%hGBOw|PA^ z!)MP~ahq?J^OjtABzQkdu}fPqgetVpRjMa&CMyfYf|s2;agy34Xm2WDU+hRPY}9&S z0xa#3xVHcVI49dMyAYrFcmmylUU6 zI$)Zk-ZdmEc*{l1%FY4o&8biZU!>)FxJGQi?PS5P*;#@Hsk^%fH5Dbhg*Gj0EabLg z>)=_v#$z@AO!eW~mBG>;a@`JozCNf|k@4W+3n5WFH5#CNx&6<6L-EhZE1 z;4fV3EkQ*Y(K512l1X73+6HdfU_lGz32ZDmdd#;@R@2npKjsa!T6MEVB~Ma(GCuAsE9l_&c2Yzkf;0TXpY_5t3WMyKdw_KO_&4`sxgg`8V@dYSsu-^5XXTM5WO z(RQ`*u{=giTlO8lz8*deFCX*KrsHJ-*sC~LPEJmKODZ5F_pB^2o#PnY0q+2ql0sgx zjIj9&No4B}Q4{mXPQ@nfvjfCWQ{>lg5g+Kh3Xcyf4|)?5{*}%*!Hx*7R+WHSm;`}+2ebN)2&{TCjVN0;tsH^e;KqxaaO6GFHL zH1W;>2lZy9Hy%GvQF=6(;q3e}*ifOK?l-3qa7u*-*Y04K>2#|Y*DgE`trhw!iw za-gB)Z#8kOmWC!WTd^PEp9-hu`}^m2OO*if$h*@mLI|;Fi0g?qt@vjR0TQJkVJUuC zT7_-Fj51*eqBAkR#DhQyXyY9+*zw+e~w)7w9S6aku8}?)vV_&Jr^(^Wy6L zW>?q#R?%p|;3!?%c9)NX@NW(%fRssE210Phw9h)@YI(`jg*yR?OxD)-I~f%B>FUv!2G=Bgn70i#R0rXg<&gKQJwio%VOSL z8+xhv`kY;>B``skjIy!@;L-8Pf9QS?ekS~swF+X-N6>^-*ix^$uwa#*+DU`B_JW(G zrFW}{#CSa7>Qi zLk0Qv!n%V)n;b8|you7y`}PUHp*ZX=US4GH@c{}H0g$5k4Dn2M3j2iD_QCrG~~BW(ioV;{e{u$ z&Cz*2qgZ}R3%jIZ+)&fwD?lM)!5&M`mEwJ^0*u1(s3ROb3c-m|{~ z7fn`k*(X0S#{L7`Q%3hC}_ECtHpJ zpo@$FfEO|uGmNY-FUL=R%v=TB4EQrSKfe_j*U0a^|kXzql0mGcn0ebwD8;{Zv_o#E#2Fz1NRkOSK<5$uD z%TXWvb;>(O{o|DXIqGkx{68J_&ME&os_lOj-dz&~#?0Mlu)BE!c==!9;F$)GiTW(i z!}fEkPzzXem7X?Ov?IW55_562s2TN?2!5aJetMJnckRFel!wHxQJl=hN6Iz61kZ&< z1P_BB?&)n@;YfFPulYHYVt^Dy=ju0P(Xxb!b_DKa`s*XafPrUHW|nfusiGnUQf89} ze~#3t>kAL{w0krSYXkYNq+O;c6ySpJF~_j&Q9*>j;fLAu)PGB}5nkOanz8=73nYBR(PDo* z`==3f*lEGJSqFeaZg2#$Wekno7}o%7Q1=ecvESxj%fDz6L8BtI0ZJ^L` zPuAy@z|ou-06I?MDs-=zXq?qEEj8?II_{mnH@r?LUjwK~qeapNbb|tv3>y{N#<(K7 z?@bS5lORsl8n2zw+e&u6TwN)?co~&7d;XrB-1=Z&B)>(cto+?sP%IbzhMTTRJSsE$ z9ebkh!R|t<_4peclsCFzPpwd~eKA~-k5PQ`c=!#SsQ~L`o7@TC(l)5yy=otLjqTa zqO6%>XlK-Y`AuLkQP@O?LrVpPIqy(cdhzU#&|A-JoAcm=V*NXaI|#Ivk!i^MBGlj< z&oT9|GavU#`djMxSUrWI;fp@hqgWNFFVQ$engjmbJqT0yMrXltu}oDlGIIaX|5VY2 znUT2$GO6vJaN6mt`(STi_(|K$(eN!6a=Cg0qp(CqYM75WzU-T~^Pz2;-0}b|5e4YY z8gY>wZNFQu4_slFD9_r*tLt(R?Vf{g^jR>NHT zk`oSp``>-zw`-ww&!BWLQU@M^F9UPM?&gL_EQ%3~hC%7SbQY}Jcm{SRwyVsOV5q?S zY&*?e&X@X0aTv8OL0)mni-hOtuOW7}0AjU88SZ>5@c3!i#<6KF@za$=3Awvn1)$}} z6A%Mt{)Gz|f$;d1zez*2Y zOKy}mL9kfgw#yqog;=fL#R)N7&2&J}^?HKswSWG|$!_lx({;wlt_~s{!2$Oq6#vtfoo=*rPJq9WOebgeLDv8>V9YL*SgF zDZ5ebO`$v+YOmUhz zGQEWEst3O5rk!$NcBA#rwA?V((|`^TBI zKAMj-U)`VqMgS%dCCUv~oea$77=0P=PLf~DF?>t>e0@0H?nhR|d*$Z+ik^T3R#8fp z>k8mC{LgUuwQA-WX|2a3yXu}pReWzA-SFS0-^tjNmDlebIG9Z{zJdb1IUa>09%6_Z zfUn`7&ph*hNNsNN^@*1y&y|sb)eqO}Sx3YVZ#!YjjQ$cI>`dlH_nvGn`CZoF^6;H) zr#h>HA!G2G_DEsi*j5j+Sx?s~CJEdfg?Yx!DxOPzTjq}i!8P|^YgN~9loz`Pqo6Ym zywg!EuVUusZ9i zm0~NtT?A6SLUPeP_92)kH;mudDRwvHYHEFT=M{gfXw~*N);2gd(tZQ>EQlH!#})yb z>-Z%9%(9|5bOKX^aSyoM=dUBw&ki>9?|QjrT~I*RImW-duAYL52z6V(Je63h^R& z<6F~hs?vOh3usJ;@{55}!)|H6Oymq!?S=4~ILjte4NoJw3#C64C7W-~QjVozw2B@J zPY|Hm)#lru{q#jUR!qM)SoGxZ$tXagZ6=&Pl71S-Fj?RBx`1K;mD#4^ zbCB#mx0pIr0)Om8*9aKG&8Q;#dNcOkX@SO{@3NK8I5gPW?>;qYRc@Dku=q`MESLX^ z`!}wQ^j2Ealn9};8qdxxqo0kE_9m;z^I6y(8gNC$_q}!Noc|AJUmaIf^Sw)hG}7Ir zG>G(}Ly+!HX{DPZg3{8V)S;ULNQ0;}f^-W=cQ+iuyMb5V?=SBC<9^)!!R(1OYu3y@ zvu4fnbUuWkgvh^?pNmVg+6+PBiASNw&qjsN$aS8F?b8_NN8v997cl4!yVUCLu|@Yg zHju}5wKP2)O~UkKbHCnrM;S59wV-Vy+N9imAuXsSEFUsD^g~im-fj^ ze4Df4GhSXYxP_^2%z+gT!&3)IqS3bZf3LSuJhgftge88Z`+GRDR|haT4rf z+!j?y0W)i~xX$eXx)Xd@u!SyFX*K?jp+Sp;s*gD4;RSNnG*7>o^fLtFQ=tYKG`xi^ zXi`w6BMzj;=o!2M@(N&ZNI{ zzxT6BD3wr1?!iM2ep4oSM5iOi?-hi$lM!_+)U8s{*uoo96EJPhy5Bj=J2zTdX6Z&AZuh*V6G?S8f>RmDSV{k`;#J zwS|^1j8VQ5N@HpZq-c$*kP%)@(k;a|q()valuaawcR48xgp9M+p?^gTivLKX{0=ja zd{dMl+q#l{;nCuPKl%xN!MX56((z8Iwb5Co?U!QULXUFBnP}G{sHa}Gr=DNx3=`u^ ztGV3FGk8OpYw>o@B<=8h7h6#W(uvbMGgL01j_Tqxp}QZp-=94BIbAf>2$&!9BE498 zar4HZHKv9Gaf$_s+F1?~w>-5Y_@s`c^FC+&+4CjyghHt$V)PWED`mDm<#qpZ`w8;b zqqNCuB}Li!yhft4{v64R<7GPKcKR;+X`XwJ0hX|;DWb8?4!OVy^9Mr7V3z3xoMo1k zcNXgsiMmH(2~=7vBAagJ2Gl&1dDL&IjIahgu7Jn|6yg67(}y595>^soGsC z99}0gZCE4QD3rA6hA-BVM0|C1a3!BmugRFH65W=NKWWRp;gbh76m!@hZ>ih3lTTzj z6M7rHOL|^r)?JhYb^soJpcB&x{Lo#FjgTHn$1yh6w<9sWX!Q)FXe#98xIWbwINWcF zy#QZ|y@>vguwfiJwaB$@hdA(NDWXb=JFeb5N!npBk^nqn_K~6p1MKs81VM}?TqLri zjH6yCk0B!y?t9y0u&N)4tQt+25onNdRDfg&mo*h*8w&J)Pv>54N|f?yc^6x+KFa?h2uW3_A!=fk;*5S zZxVk)DvvFyfM?2qf7++kxsUcb7h^A|#eQg6=6!SmjYvuZT7txvp*2%JL{eKz(m7RT zVhf%Pxyq!JnnagE5$0zgvZ9t}+2`_4X>({cgSf_4+lfFhshAg|UW~{gkth+BPEU%x zewKhGSJZwuEI%=j7fCo;5BrSb8uz^zyy)*{VFc6D(;wdbJzq7|{C#;Vyi%mH>Zs75 zZCqW-lU%3itn`FW0mLGPAVpbK44M3WBfWu&iPVnJ14IB4sA3ti_nLGq;jmUC=~PK7 zzSSYr*N0W=!773Nqylk@#Q@kK{jv0!Hbd35&uANAKWny1lG%s2lULOx@ew5yi44jH zh@06e%N4yi^owi`Bxeev-Y|=(>FXy>B%jJ(p++b2Ky#+_iq-$E_OkP;vj}O zAQo_t_T)Ik`SpAI_ca+F5arjJS&YKvPilez72RuT`obtMpW@qX^Ze|3&iW5;JZF0k zlfx@NFOOFcy~z(L8=UDJV}_DxmSu4g=rG5Z%jVSKU}xCcV%^%!z3J3BUvDwmj6AG! zW{*nhHX!NkOXaM8!WJ2qWL z8}U#9kvfap{$WiF)(l+BjwvRs?2T*)AZsnzZH{c&Q1sUTPv@N7sqOCfu7QMK9p^=oic80*&3jxe}* zD%Kwwp%JG!h73KF@LbLUS5swBolga31N+UmG#s6V*y*wpxAw8`%e}=A)xGcTQ$d1vbKxeDIMB1tNLaKBLoxVY>HxCqf{6fk zH_As2GF;6OXDGd{TX zZER|LAp6`>z)zID8aQ=q`P;#r?Of_Q=O?NJ;F4^C)ittcP}G&_{v&Tp877{%7r||H zZuCCyi`~(B4msEmJZM=_$KkJ4gGcz{g{t2kIQKA!AUDQA^|xe;M8X@8U#Js6;=?{s zn#ZX-k^B(iEl~HcIkAfc%cHy-d~0=q%5AvPg30-)EmoH_?SVr0GOl!~FRch!SQLKR z+ovJCm?-5jn0B*ju&h@RA9TzgzR27NV3;w%DURcekc{l2=Eh`3+py;Ikvls?KK^v| z9`hAKNbIZrht+;!8`k#rTl@P?L!u_d$KM|X3xFh$MH!|i?exYG=arL3wxuETNT-K$0-$GUqRqpfGnF{UbBvKEDP}4(~hRMHtCe#<` zMq!|X<;MA(sO)*Wd9bc3tYSQnJU+3dD`wLJE!LDW?M5t?!-K+ z7pKj>kWy_5yDFh&h3q7jkLPWPAHGt=+kL7RLJgrI-9!T0=5JPFpr9^hqkOFjAyGV25<96I#epZCQGYAPEtot?p2Py+rv-_d zcY8g%RwDPveZj`X_JX3$p+%gFl4M~}HLgBK%BsdIw|7I8Uwj64I#na7M!joTW2>gTM40;V8xCf{yDE(pE zQ$bQ_YOSksYQAJLBXXxzW zbm3`nN%u}wkj5DA7-f^Wd_SQM?MFmXDS>i_7R=}E%wz)jB8DyfCtvP!ihh>Sfx!y; zN8u$rVhk(x=gPrTO|mj&FTdt8C>s(fWK-B6lfWyGGWz;idLw-0zrinmQUCk}otw`yHCpE!1F&vVg=vh{f&v-Ns7i)9VNBXf35}1sE?yB0;*B8;FIg82 z!mD)g>u}Iw12L|4UJqK64)kIQQbNPg5(wYIoJINlxLg^#a}`4D4IPv7Fx(^9qPI{t zt#3Glr}rt@#4JfC%yB%ES4cZSpw!6(LiJOb7cC)8zkO8e?W!z)tWu^r=P$DHF0{wT zYL7E|dPV4Jvuxwcf5e=p&_$FS`Has@>X=O0?Usb~Y_dHI97js#7tq6cCOQ`SuH``( z{Xi7X667M3GC>TP7BS0pv&%EcR2{^x2}@?W#NQ0bO~OIZr1yLtA2r9fq0!)iu)r7X z^i3O$@)zT81ee#PktHEb6VI+k?P~NLSzmNW=6SY+Ta~MzdB_Pl3Me0C6^05#=<2n^ zI6JRXos~Er{(3~`b#rw_{1McLSm}+wh~xLrN50#g*J& zTtyWT_Oe$j8{vxq1+>B40_$Du>nE_MY2uq}F;<3deGZd>#zZGeY9;}M+#oIxzckJh z!q33$$WS*idW-+nu(|Ag60hx~*Rx zRI%P>q=_t}Z_uB(R`5Wb))9*nU`2_rqA|OgpJ4g6QeD&V(`L4zf)R0gQu$znwvC6% z9!@4c-2!juej??evh_L}_1C`{p;cuv<(i;5m#{*v#rS?+K~g71tMEeky6}ffh4Lm= zNZ3Pzw0*pcUCHbEU;rszP#0=#|<&<7mc!i0y&5YH$U+m3jbUx#iE#IED>j<2h9RwKkH;P;+YFH<~hk}Q5~7J z2#N-MEfcq_&&H;&5})S4vN+TyV?U$R3@V{9YxIqaRrf4ze#YT_f;Zll&{||KKS86h~ilC zc1uAxT9^H;5XbWhWUdf1YXSJWVOi{c9OCj$ZLiTf4N(79tS)oFK`i%xfVG;ASg2K$ z4x&MT*h0ddQ*RTEV#smPIP9E5AN(o&hzeE zK6Gs2i~Mfi)iuY3ZapV(JoKhrg1p2~agty}m;S}eo^PY_J=Pr2KPS^lo(%k$-4h4{ z*{6*wQR#R6ARxh4gyAyd>!a$rK60fQ6%Z5WUPmPfPx4p~ND|Dp4^S9kTU6cdBOfs?^UQABP?07HJ2_1k&^^(?i(bMg6NN>S_eYxW9O-ze9|2#B?< zr7_cLHhEw>-~ra#+UQ#U4;^yaY#7Tv&|eVaW>ACq@3j+=(E;q6bC>1<=bcAO#q7hi zif#S+k6ucZmurL4GQ{-AphTMG1OSLXlv>A^Y~owi9?R0B6OG3w8YRHo2rAL7)@0iP zI1xVO8V+P6Gd5{}q79M^mxzw1fA`kg2~UEbwTTrBeWHN%p^T}5RtAZ`WxYyp24>d% zu1#N%JSZAK`gc-&=d*dg_186Y+&0>2zud*A2xDy9M!Wi>=vd zb!r*1hurAr49b$~6hZWXr%bzKjKhe`<8?Dw6S}7U*>J%4m4vIjla*l&B{@5*_EW_+ zv7Dh#*KsR8H)WAqS^~%aHfU7BS8DaWB#F@)bzs+sj*Eke*Q56f zeA#Jdp4ncS7MqFlXCDLK{4cgMJZnfMzb03^3}%WEKZcp(Ln*eV+1D|8>TM!QB1=3@ zhATV;UHxld89C!PxSFEL>0iUWZxGr(a5mI1(_mJL4GQgVUu8WA{8q7_=uErx^PLpu z-$DFoo@Zv!p-wu%v}xUZ6SP%LMyt$}I%c^)w%NqM%D~23D)gyMX*e5UjkONR;R*i! zUPswRlP8E_)z->T7!bjum*|*vgwd|XJ}$WVseQHIZzZzRk%eQ$U|81kxTKFt8cbY> z;V8h5gUmRF3PGb_oKYN7Jb^lqKsr?Yf^*U|0vE(8x*ev?kjEgz^6vBc;NR>nzAlIR z<7IWG@sF2&(*1|Y{rvK*O})~sQM!;C9S-^uL8I_P_3Lm3l`?}+=QxqT_N?&CLYm+f zGpuN@$8V!Zq2pZq=PXjHVe?z^NspF%iXtP%YXQUhUFmr$LFbB7U z`i+4cA&U{LZmNOXg0zhIQ2xp$(qb)&dHO9qb&<{UJ^swucNxE2HSNa6HTUe;FMH;8 z_O#3S$%30-=Byl?PitQ?y0=|e;c-8SDG9Fc*~sq4*6!C`(0HW-;^fH44A%!uivBA3 zWw&lKM}uHj8e~x2ONw5!6OXyl+Sdj%Lmrc{O*EIu5F9?+96Re;8t9?H?gj##q#~Tz zsoy1?;Ol{a=uU(8!T#p}!{1VnKY4QE4;BX&eJAe^lRtABq#kfL`hLJk4%uRnpIoJb zxFn;(bl-5vI`g2+g{QL-Ev1MCf(uA=(g0F-d>>0F^ky8Zq<% zS$8{A<^sGkJ$CHn^{CA0eD`EV&Ma@E%WBAZ#mKi({c!BZ-I&PUFR!;ffCdl>>Gx(@ z3hHTfbiNWV^}xcqVPT=6A-a_m*mG)?B5A~3BW=zw=Z>K8;3*q#ksBY6>$snyBZ3nJD%2y}ZI+laW7;(A&J{&H=`X^FE(E@G?W(NWQB ze3z;}Lw+o|Z~OJMdl65k3A2!6hrGI)6QtJ5`%y*>MrZ6nF*l4lKuZ~640o1cgM8-C zHoH;$DBYF;SjZAm*)z@LGHNAiAm06uN^aJ7^P_9-a2T}l@vf%MN2k5#txB)!x6ldDC6v3aMf&9K^Orh&dl{bP_rZUe$C&p-7T}d@HRouon*T@GA#3&w1Ysl`+gYbPPz84;FmC8LoM(O8sZuhal6>`q$V@L>NRRpuEaYbOr$ zoifup+|bb9!epnUjVU2tKGr$^t8I^LvmEmcyyK~kdYi*dJhygOwpsKqK7DXsr7+H~ zExpU>dLeZ7E#^;PAilbm8q1(i*9WP&n1r~@KrC?AEi80QuI*O_zrM@iPn+r=Qk3OQnlgaJLmpcq^O(76S@O&17MF?{C55Pk%>rUaN~gxRI$(*9v=uR(DEw^ zE^B7jPg*I%yw(VYb~4_DZ1D+IMwSN?wk?c-`~su49@-gx8;wZ9GXoOS0h(sztRm<8 zXoLo>HvUb!NeLx3-lP*vElyF1F82pdIOeT6%kyM<(S*g2s4@1&DoTodG?ygr0wKCL zKXgduGT54{Eg_s|+3Bh!Jyp)4y=&D{A1fkkWdI1kXi#{M(VafG@#p4n!;T&wLgxUIL?r#q{2g&^fF?jw_o-`m{wODExD z#f;219}9zPmJapxj7yyVX-E@G5nnNvm%EZrW9g8&<=YRPHag?i2GxIstRnLu!0EL! zgwxPz8(yjL8FG2LNd6CsV*qBfo(UDBK=La6QKc$$Mk~FaljEpi$7$YK%fU4)pf@KnTpLX{2#E}u-g%f_;Q({Y3 zE-;FKpzr9b5oBb-=NnhEh0}e>E7N}LuhTRO{U>7p1Rbu5zSh@I2cTvt_0SYgq18}*6VdetP0Ru)W&L33SUEp!e*K9<~p zu~0its0K{t-Z+3IxsGCEg*h0Xgpy2n!tZ+ zyEt~8^OTM}=H0!%@Ez56o&GD~nB(;LpFiMo?#F0VFo5&_ZGHc{Dcz!8-kItDw7&n% zl>Vpn0U($E8tlJC{liqs9a!HV4H&W$J%~tXldF1)a6LlOX^!Y z!fmzT>+u+`R!g!%`lr45(V?}qCUgD96s6Xz2ry`H*hYjX;E(*uECWCx_l!BMG3&^$ zm-4f|!@tyZ@^_!Ob1L{M&uVN^#h=DBUY<6UZKaNFx(HlBL3p>KUzqPjJ;z09<-(i` z9`9cy`ADm%Yi}DbDUm?V@J^EEohg08@3{E21!}s_Gjf29)3gdivF6Q!sa5^xa#ZL$ z)~4hS8gw3u9nG!^yBR(E-Ubl5-svyk+gv<#$imkMm6y5aB4jLLBkZhV5YLY{apPy# zf3y~m$+_*?A8ZtCbTHue1#-&}d;1T769;29VKyN|ME^~ycnRa)=GlG(+K#&=kG;El zz#n&)EnM^{_HSBk(p_3H5#$0ojPuWUi@!Uzn6#SIsykO=zP?+SfD`>u1<-BFpqbW1 z`hOFzTE$izfV_k^KjdzymG5pu=svzC@gVg;CZfKjp1qswrnC1|f+rdO7It8`%SPzC z`nSSwsm+&Nj&x{k0wN)q-48NdRqQ;S;EA0OPf=J6gG$_v>DOr)ZGrHK7%QoUW_!=+ zeO5>{LWHWI#ba-(_t|}`DwNlJ

4$y&@xqh>-skAyeW`C3gCLhw`95cH}r5ZI*1N z6SQ9S{jU&k)mIpo`FmJ^N_VeQ{?(~d>0KY(qdWfXs=&RTg{~?T|8`#>_C8;Qz1Uxs zek;nOZsTZ!c6Kli=>N@W69Z@;+rW>g=4NsSXiaEK78w<7b2Jamn9|E@d}^4xl=pNM6%72{~Dmv%<&S) zW2GiNR9MZLgM$-PrhUMq;s)^|#VEIVnwQ|!tuS#i7Y}6E7Q_Qn!$aZkFfqr0uezWBRA~12=TUr87DUyA6fgX}rS@JHiKW~v&<3-RT7T1_ z@Y2ZnR9#J{_n7;Eizp_JD`0K6$dDdO`Pl*@7gZ3Ik=)$RjOWsc7nrG?twho#+Ax(FUPGcvmzK6{ zc@p!}Rn`Dx-A&X#P>BwkAk%68FU-muA7*s^|@>cbzp3kQYXc23irD%*OAV zEjcdNVelEl|GRMeM#}1E@A- z&F>EfmCyZN3Kz^QG9Z!TYnV57i75YYS1`CT9B^e6in6XKdF2Oieg@paF5crdUQwS) z?R5!Fjz#}6d=jCQd3kOOQ3cnb4Mx)Eh?XmOu_U^8-{^zqbI4-gm>}vqa>7AesI@b<$eQRIN z_6TGMTi=mD=2AN|hV$yzu75wBB!v9@Lb8{fM^{Y2K4Q-3Bl6iCf8(g;>}h!Gn0wv1 zhnnB=%vTsCyDzuGIFFb~EvO;NAXaQFUb39?1c;({RqnL5ww5DAJV7Krj3to_P2>X@ ztv3WQ-~Hf}%DhuLfaZDnbxmX++~g>x6EDqq`62@cG|9Mkbma#H8Fi&^XogYce@WUM zf&t@zVr_I{0b5PRWnzDpQ3&R@J!%-bNi^#HG`V`Db-6gS!PaT-&9O0}1+pfxFM{+< z`z4h?nwy)yyF^7rp$EX*xVQO{f%qq~G<)AHNFWxw6a`b24%w zP`-BIdek8K9sR_$px9V>Bs#q$MV7YU$Q7-!T#m4}CbIy1-1#Vb%A2vbadWKz_s0nf zH+Sxwyv`U=?jnZ5p#@swN|LM5*PI?-s|))PU70YV$4b|J3(d#xqh1kqG+A9_LZ3br z<7R?OM)7|%?p~J5rl?2to)W-(ei9S*pn-PH&{K!ZApZn^lBD(w;h$N6q6$jgU8Uck z|9xnE$-a)i_GA9TgShcZFhvQ=p0=fHg5r#BTNGbR&3>VIZ}qoj<I`9oX8{kFit z6VD0#Mox)~)FIEa;!}<)k%^xsJ5|N%P^dw*V$bmBLYmr$F?e${AfvLGwdATyR!wRU@D9J2JFlV=)DItAn;kS{Pn z0fEW6ISAXr`S~OwrX+z7*V7*}N(qv4{EKAYp0h*urI zNJ~1MWzCKqB_9Pr+Hf*CaAI2h)zM~<)f?sGnQ)yF#nE+WCvh1&b3?0Z8Obs7=X@I3 z`9prwC!2LXULw_fALm-n=DBwx%p<{l?zDHUb$`cP@Dr@zrrm2=sr{_1235FinfQSk z*}C3@nG**Ltl!cqy-VZog@&^Bd5=C)p5@^J~R9rkp>2!+dppgYQg;A9hpT(eVg++jAu0VwW zTU1O9WFiXw;kf}*xE$#HV#4A7?Ao%=b?&TMe^7k^O5I>Iyr+?4Jcj=E;o_c>@ey9v zxw{O`fo~HraB2GPV()GetRaJZGEK>s))|*?nYeE#CezDq)Lvg=MU}L5LJ<*9rN7_H zfDl`ibv9m`Qs39^vwdSr2%?nK+uf{IIGwlHg52t$BHXKXf}y7jEOF|fG+Ovgf=YhB zXBie72Q7zA^M@uU@pYsdbm!<6h9Lg1wwu)tG~wT41wNzbdzpKatoF#E(eSAjL9UWn zz2@bwrOz~#qgBcdzbjCM&V|9kR>EMcSke;%b4{}@OY!xSd!Vnce;*&afoQPTmzRM{ z&~=xd7R@_9f1~Lk64;!EG0+Ws7&eyv2REPrant&Ma_3`b_7wA*Fmc4ndTSP+I90O= z6$jMs%L=X6wBI(aF_D7lui z8PcU*swFn6czsnld-$wk^kW)bh@h&grNcgH5k!E}xG1+~t*JmoP&PKim8NlRE6LhK zCD2EYiU3ov3q1ESMCQx8_M&>d>$E!ROi_NKIk!p9M>1Sn?X zYx=q~1=4Yfsrz#r1d^U@P$kGME2sE~3G8>Y6G5eUq}p}`Dy$i72j6G3wd^`nUbK6momzWo_(qwz{;JkaH?tC&en;|5 zMU~8H^C5@dbE7lmgBVy=+(D%@`zzV4<&e1ffc#Bnj11CQ$wke1PZs4d9C{;$kKY#` zyJPs~H^vp@tO5BESpxG#l#|XY=0fsBq#gHZ7Z^}CNjle3lj3Q77Cq0=*+H6zw)~>; z($~0}-A9Fj!*VC|o~SUN{I5xF*E9iMW$5u?n&Nx({{Rzozm~rK3?Y9~n3%hmlW5qJ zVFnE^rU;+u=$9w{&Qho&_P&IizBg4N-&ydnxyOt4tu{P(=@_M@u{R%}j`=vDBxQ%* z{dPNFep@a9NCbF)=B;)a_yswn;o#-V&*_9Zu1hX2fAER9E{%QGt?vN@qNDo+--9+u^W_?O)B+I}RSgaqZP{=QUfh!)xVVAjOb~*{H(0HrQ{g3dO(u=>~1j za=*kkBX~FTT-B0wu<3ztg()fxC)}etTa5}hc?>>Q+&~0FHHNsv(Gg!l!;5G17_7)d zk@S}oqIzGuaxK1Kk$g(^LT%`|uMuX*hOmltRX#*B>&j75<#pf0 za_VZ98zjf*-`XnkfZFU}3SAo)JEyD4*x&gE){d@5O*H&`x|p$8SeZdJ=f`OfeZLg& zaKIs#$#j6rNWlj4L|~L-rTtt(;y+TE1|GXgMi}7?bnp*GuH{4g_~+bQ-mQUsx0p#; zOsYD^j^o=tfE%%sq;C2y`sbqRz%BwU9njf^9bhAWx0~mB4in7kfQA$_Sf9=G#Fvib zkN88ky`P*N2dWHw7oEN8sYac3#JM;cPoqwK{z3`FTsK)`;0O4{?l#{ac&`1ZZ}kMb zJm%$aTuD*EZXS&nmM8;x4_iTve}8WfWeGXj0olVYcrXQ*6{x|6W+PaK^TtoVJkNm2E2RJ#UN-4mzRzjf+l51;VzRXBD0P90K94%lKND_%inZe z<21M*PJEG(W&^g!NG7Mk*S<5$YlJQIT?w%PoA^5)2;6b%ov#FX=To^iK}hc1uD;~X zrE;J2^?sv|*-_vR`cu+U{?2!z7cp?~XPCOYOIYO^* zT_Sfq6F1rDs_3mKX}k5h+^2lKOWA_8ar_xD7@-9wEgOJK!n578|D(Xaq%r9qWo15Y zntjPd@r&r)t(zlwapX(L2H#uNi4&@OYgunS-k)wN0!5JMXeD~g6`dveKorhmefG+H ze>vP-SvAbo{@!8V=-EC?aqAuREh;WJfq_M^X=eyK{?xF$prY{_rOj-f;%ScBym_DJ zK>R{FlP$ z|ILwm%L?@8NMQ4i^Z(ABdrMgK=gt3QBl_d<1>XFjGWzG_;P0FNTVZ%%4{_imBY>v# zwSC}za?ArbUnbIfN+qZ3fP12_fNKob*;2*!Q z#On=+X%WiXVnmA@*0v0vY0*r&sH{y>+(#G58=LudI_^Ql9P%w##%Pkk^uvj_KnIu3 z{vr6N4_+Lg*BZk)1kP6gPOf5VvP~xeKXBaP%--7V&3vabNi>}tNi8irIMwSxw5can zda`iyyLqf?9?#Ke5pD})v4n3+8cpEA(R402eJ~P8SH8=efYUJ0YFIE51{{HuPHa^b za;5gs!DYg-ScgaZYl-3ZVd>>N9^~*q<5-^zIUIfA;W)n^iTJL1iO9l%vQ_!YokA%}(~V`P{p z5gq1LQ@J0qQ4r?evkQdaQZdD2zTC3NDBw=DL*=QiFV(*&;H~{gGv0gV zBSQW@Lxl;FKXOg~lN?Q*|8JNja2AhMVmD_7%KM`tfSVyO&S3$D%im_rF-r z*3Dd|_;PQ3S^fpy_3v+No|*1zPA{GAZ!QaJ8r{0cX8=|~yHw{qzIcS^J$!P;n+mK2 zY>@m?*KE{=;-6s$&o`y7Z%&lgwzsK0nuss2!DuaklmWF@vSZh{JP|65Nd%C|@Wps2 zWe2kn_~7GL>zvp*IYe<-)+(uv{;NFQ__*ijPpi7BI;c0Xdxk4mUY_|RzlH=STG^7J zeCvTW|LUy&JmIIW<~LlEd%g|)&z%-p9cJcw%Y_WL5rS-ODC2Am27WUv&3Iky*4yf$ zweVpEXer)(P5N%#>p(817ly1~a!GZbmofh?wiE80pmjLDoKR|m)9t(SLyvXe`lbPy zprks_9#xU*XzlPpd=FpoT<9jL+-57f%ciw<>#hdAbnNNQPYym1FO@9pFPIMQc?_fm z!|#{h`^iP`zvNjRywFwZ{bIYviJ7?LBN!3@{}CJSoj0Go;oN~Llz{W6*pl73#~Np6a@ztTz>Z|60bv{NAc(~a z#cid2VOa3xVv;Qh zocDdU*jvB!tw2_tW9=R1i>73?{2;6?xBk9-GvH7T-jw4`^s$RuXaD`xf3i@7brscZ zV)5jZS)WI(IJ)Kwh`2|HA`EpEMfvkXtpV$Bwl-c5%&4;UEcu(VQ-0Mnu_ir^CJ4jQ zPN*UMd?47^p|so+Znu3lUC0_L^@qq#~lizInsu)~^dVc1gjVE7x5^O;jJ@8!~n| z!)YlJRut{`VuRt~BA;LT9cBR8A9rw5-#Vl-!QJ(;dp?%K^%-LtS|%xc+ezxv|F*2$ zdEYKI4+kJ)V?nRi@9~BZ{XJ!-wcko@Ji}aY0a&V_w?=P`elu(XEH{9Unwgsxi3ge( zv~{r+1GMN+3!~kp+ul6-@K2A!csbT6NQp^x)&%WRi(!TY;~!FQ8?@HB#`VDSII;Mr z7Ya$vq!!)v-Sw8k)R?b^D^T@K?m8@W*WJgJ2ZEshIT9S)=s+WB^3^w{CAy_eF1c%E zG|=>Lg~)(H8ysA44j8Rz(XXUfxYKaV=fw`{p2{@>t>;Pl*;}Kse|r{niaB`V_Bzi& zIagFqxvgElldlxIWdR3wP7SoBC~Fu!gLNQg{<`1M@jI-R+mRDKxoW%lthbX{tThMd zoe-=4z2XIBI`c-Af7BOmUam)<#yDg3oRm`S%?WsZWyd=D6%S~GK?6`0kI+EJdAr<= z8QQ0GJ@PtpZ_CF_>vBfD+C(lZQR)NXlC{pYiE*ideV@v;2*(EK*F0*B1v0(S4&rsJ z1REng4C|KUz+b{IPdHf&1Je(zw29iEZRrycGsWA4TCxwz$r62fpBx59CNuf&Ua|V5 zI(lDj6-%}_lySqHZx(%Y)u>La&;|zRONBf%)JVW+DI)N0BT@<>?HJN%RB-R4-pj|# zz`xl<3lm2khDYa6Z;h=-L1WTkdXN!nW8<;sK9%=$3=q8FF9c}#eHbXmWuB%D^r(w|KQ?27 z3*9@uI2{mMYvs5jMd@9)R<<&H?)tj<#;elHs>bIl+t)!l^xwxD#LiLnWW6StbyODo zi^AL zev1s7O8-oi1v#p8NDT!uaUj0~dxV}u8uZr{Z<5R7=9yjbB?sSVudeEP1_B$5QytFr6{Rm);>BtFF?c zS++MX&}xleza1Ek;7d!}OIWd%IUvKviyZb9Vk6?j*I~$m9BhckFbT0l4Pd4)`0x)#ykx+bdVTBp`}SEf$&h*MUL>&V;QfwP%~>B=XlUCf0Po6^pZ-Q3Ey zKu#5(46%ly7iFA-)ttA5@R*hxt!Aq9O19>VZZ-Xc@;a@s6&fqml*yZ1j6UNk$QX)~ zXB@JV-+%t-r#Ywf!$7oQ4Pl|PL7eoW%}u70?yu>**l_E1Mpd&o7S_g-Pn7-c`4x=h zK!VnZ&Ii#0-jGh@kvC=sO@TK^gS6P?@H6{uZ+Osqx}4rRIME0QET95_&+|@JRB2Q& z+HThd9vl++j~XHzFM=Cz%>-Tz564U4JzbosZQmr5pmRfF`J`KN02?3XwDb3eP@LF;hrl17;xK^ zgkg%F`lwgidx(K}h5;Kf6Zi>oMWWS*o-tA)->}v#k*<%!KetEvtcNi7gq#l+6HsO3 zz{Ic$0O`INLi*COmqjf!Kd4uR zN#LP&6Xn-wryTh*Tf{I&>~Y{Rv#8vZsJQ}WWF`{(jAn^zRSChLfZ$8kPL0mjo>kuoqmhV<>g6DJ%Z@Jw+e2DRKalLN(d%uP;X#V(9}_;ujn zylN4_x}2??<$-iv1r=;)7xaivaieS3ynMyv6$-2c^_XSE+Ec?f-{9F?nf5Cj#kR?$ z-aNA;E7I#9x624QqnKDFhVZw4CX57V$;vSI^hai-eYSIHlP=CB%bK4~>iIlH7}y3m zeh7VT;ZMmO_$CG&z?9ZOT3t z)6sXX{>tOa9t7u8FDGWD*UBwm{`h!8OicRQQvt)Sq391$z;#K-u3gs>5z5V4FZ>H6 z+xIkWLGavf{r&bA#Eg!mh;ZVi{My|*i^Z>c6J8w_P1WMGGf%&@K*i>_I>Gs{T`iHQO~!10>}{JCQiuze z-${Ekw)DKfjGa4bkD5Dq>xMH$;G%Gf#&-9IDS0W{AZ!ZShvoNEMYN*$dOLCjaNzFA8hDRWQtXrjmQs}S)Z%1O`0cLNkA$> zBU3%xsIWzL8>31g2lAS)u!!F*H3)L0A7EDlx49H|1SjidY$uB}R2ukxd@60ohFlPO z92k8zo`|lgh`?{EkYf;A(@YV>R+3@dcPPrqF(1FoSvQ{oYhC#3zwo!^K6l(@Na$W_KEae?e}RdmaA;M zac{l}7_KlChs#6-pz?XeK_f(><-{GlJ1!WOzYO}W;^HW#xDIvtD^g!BzwB>jM8=Er zq3ZPiCaPKxq1C|=Fi?wGsQ4N!j+r81P2Z@u#mPpf$&d$K*0tQW{_q0$^5{f%k2UUQ zK^7h@WZHVIkNEmlN992L4_~>CuQilz?b8YyeYFGTbJ*U?Oo;q)%W}wdOkChW`WFRO z@CZNvGN0HOW#C5_eq{T(bxtP#}I)iqwivDh=!dtTScN$yj#Fx zlCP_R14~$8&i!oz%ACYRtH1gN{7ynyw{G1Gaj{u7)FC`xD_c?T#qLRM;cmz4@^gt; zaHAK80GHh)*LqozduZtb@$ry1RC4X5Z8o8V@(UNq&6iWLWyOmEH@M1P@RYwQ1!|a? z#Nb%Y;b@)^kLJpe&?`SeN5Jn;XGTYQL>CsAC7m7S(+k41@643N+}*}Lj)yTm?h=~y zCs)=u(>~MJ6V$WbsqrMVQBQqUKKE-un3E&J^r+E$8xti#LX3n&H$+75 z(R=hddKaQZ?|m5g&XDK5@4er>UpYU+KD(~H_gd>5v;S)??E=C!1=uWlpZ@cMJ2BMj z@o$l*-zR78nB1uIVU6qBrFcW23Kc;I9R~*>mzDK@uU*1k*v+_83yrHToxERDsOyl&{iNTHYP0%?f>e_FcDzHHAf?aucn^mV$Wip563iBV9UNpsEQ;6d zfd6;k`}4u*`E4_YDd407F&26 zH0l{}hZ%s+g5sdG)gu-mVXjRx7uL76wL0Yc$jKbvRyL^2L{JB;`uXJ*0-phoPsA<~ zg*#39M4DJHVyuq~jt+aY<@9#-#HRQOTlk*^YFw~9q@a$%A95=S9-sJg_Mo^I{v5c* zi98yj{+Rbu+|6)w+MfSY+q00hbL7slk67rgc!r5VhGqw?h8$XRtQ2?_wced5(#6GJ zoajcrT^SUFP9NoUdF3)6*of7pF2r`#xUCE=1d+)|^LF020$nFc93LCYy@id*ugx%r zyqCF3=F(WFQkT*%8cA`lmTJ!Pj;*A{T~Nil1$(0I2(d z_sB8)#j3L_QZq9t7+yQ4O#ie`h|99z@R#{cE{j4(Sy*W?S@9NZku~92&ed|w6%+S@ ziX|ZUF0`Py<>`BFUkFLm?&Q#x?OoEXt(K}2!vR+NN}oJ4J#B$mlcaoa5W$Synw7P6 z<>{iu*wuwc(=UuZ*pckMCVSrtUlTI*N4AITwLZVE*kf>G;r6Vc;}3y}mY$ylT@$^q zWtRDfNziM*0pc012#-)CvAVZb066D!RT^a;8%f>!-sB@`%qyiuGFqlD=-p!vKC!t2 z(pR3Y2MNWSmU9=TF6pry3d=60ofuX=Wox~WCl4e16nJRw2DxM##kH=%x*NepjZH6z zHImvYXR+Vy9|A5n!ye4x{=vt<`2%k*D(_M=J#@Et+!Qx?ScXV+>G(mpHayS2x?|Yv zuxU8gA|X~s{9C0e5Jw5b5;MO|Lh?ynvc>_oyBjxQ*#plhcYjeK7T=*)$hvzuQbU7S zk&^4Nk1q%*w~cXO{_4tw!gJ8Z1gfedH4{5JDui_8K;(x5V=?W-qu(8e4y+9~hjOSMF) zE@k+8ZNJa)@k9Ioun~+)*Vb6r))YkT#9dAiv7o3CJsxN>IAT}!KN$hSrQ(S5m>e_e z_*wU^Vk0~j7n0Fxtoh%@JjpA|OdPrNO|rR_rB_JX=^Rb3jeCo8>A7RC2-0?{t?Cg5^jgE3g zN#mPAd{fbl$)`e8k(?3b-k?_Nnkyo`*2xgYN_oF$N0y&f_N?7pD8du?tyPLpwc~GN zeDj{>@yzZV_47S%z9Zkfr_xadaJn7vc1nNz$wgQZ2;jqq0&H_`Ww&Th#cEXKv~QYz zIibMbZb}ce81rjDkrg*h;7?VeQPuutD!QB_(u;b`;pcC<+?OjCGcmUAc>`PE zqb?%z=@~uTRL^^Hp_m1BUl2Yy@<|M0lWOuKw<8Hn1BhniC1>WAtgjD0etv}MK8C!~ zQ)SFrn)xMc5W$=RX_LCDd#a&;{;Q+G4A>X}DsQ_Y&lX5f>|Q8fFM7Hy5jMLt#W11K zn8pw;!LKBPel>1M_`UV6z-H`QbE|4~=AIWMakpu>L&$fRxYWdyU@|F$DS9!yP=A{2 zmwjoodaQnFyk54`tcG>mq!YxWo1ynVKOaiJn%H?SWWNUt?&>zK$tlQQ@8xy#)KUt|5b~&5#nWN`mS!Wi~ojq}PZ_X5Q8}f5F*Wi(WHGP_=avZpZ zlhl2A$nEkx$sTsdJmN4w+MQCiQs=~_nD&*)znGC6pw)_DzV(5M+ElaFBdMj8db`G0 zmpR!c{%RQ|#WyeC$+*xmL=xsne&bG%FnUC%V^yK9b`l(YG3j#n?7=Y4nd@HPqa-?{R`#;I7nPON2nF)|cY5HFhFZW=*3d5dKh`Uk0mp3c?H?i!LJt2S@h6_d z)w#I`(ttO|@insH3)rZjb&q<$!4VNC*$@H}@P(=$Qc4J#ES%%gxaTLyda*B;S%mBrTtrFWxoym^edM zCpRlSW=6R+0A4W9BAq&noT#|7AG#e}6qzbKmdkP1Kvq%za~24|gYgu9_UjO=iB z;+MGVyw9~^?vIZ3Dd)U#%-@5Nj(c>mGFK0!pAcD8 z=vQN~#)Gx5#;>44E0QBaHy6{MVW^@UM=zFyX5sZeXff@LzV^@G8jBWK@T(q-9@&o8{4SZieqFufq8q{qZn z=be1-r)NvTBVQCY;{H(gIz{y0!{Jp)zW1j&nT(Pvzc*iLab%)jL8xZsB6hwQ+dt~f z&uiKGsx&OJFvO~lx-9^Jm@qa0`pl;X<3fl*SH$*0LrHa|9@4|ZLp01&eR>AIKi7`g z=5|+fuqB?Iw>|N+C`a^^c%JNV4xlQ-UhD_I+i|p^T?Iw&q629Fz_-dbCAais_4;;b2 zHOa%iKNZ(WO+qx>z@61;gy7)__-%$bM`Mk8iLUN2rz7SShhK!Qz?#bp?f^k=p7>!i zxJIrFM~IW|Qmnz~cDL2UZ9{MXl#hV?DhM5!d1x0F2)kZ=GAC0`CMX!~DZoU(#X3 z`%#hRkY&;1uh9?w;uuv8*DCNSj|l(mw^vL)rat`kd+9OevDFT*LK=;A5$mDX zQ57rcJ#rPuL$%nz`vDk?1}AMUH)b{+0^}OH=x-ak79W|$o1Vd1_ln+F>e$H*?=j3` zU)|2l^)>~<=$HadmQuyt?%}=)AQw7Y+o0J_ZeVlnrJOIwX!bg-3gk!tA72HxAkBcI zQ~_+D0JelZGGh?%y2Z1U)7KP{i|&m4P^F{mXX0tO1}vmhL~vr?o`%u@{7!S43Ird8 zC>`m>KxI81bhp7pwz*6I<#;Q~9zW;NYkND2Q`+t?hAXhi&fKE{<~`z~hhJN*x+KfE zm&rc$Qxi3FQ$e4-XW+nSbz^xexbG~87QOdZVtq&z!LRP11l|uaqQ7&`^L1Vpm#mG#hYwC+Z)Msdz-V8quk?#ux0KW zJcNrL#WRvP#{tYqfPNRk(NxfE$mqkiW03j!hiut8_M^X_q1w<@+APN`;!R1w;>oDjJ2HMdKEJ(WzF*8HvHV zI~oTQIT?qqFw%os(8$;Nz`<^VKtCAt4PvD;@q`hzk-A%$dtj>9fdS6Ys0BO9K#Qi> zOKuk`y~=;Tz!KE$HuQPv+nZy#H zS(yjI0>CIf4`Dw9jJ3s$qX0ZWKRDs5@%T7c!%KY_-L24kMKL<`qHj$a#5gM7x^{JO zr&#u^3x0UROv1dQpm`$TbtKk4E7CDF5*Uas?u=sE5RA$0Z5+r5n+!O;*)`R>ZC=c% zeey&E!~?}4()obSvU+CDsl71ZI{D3bWknkWJPm(ajVJ>o@yGn9iAw>4JzA)J&`{!p z927kk763`eoK2`&Z|8gzIi}Cfr_N0K-!p%7z_oQ~m?QU%#QnzV z#5U8LzuEC^ey>~7!N05Chs7$5TwmoA!y;n364P9)B&sABIze^2ibwdEcPA!O9~=qt ze>in-)w;{f3Q&@kDy3YGk2Ax(|1&!=I&m`3T`Dd{{mL5`8E^e(v2oGaP5}C}3*X+8 zjEv0NdF$<097k&z=&&u3B@RTcs7_b>DU|(dfeNo_cSitQv(&o#VQ_n(Yy7Y4Yl7O)T&e39e-yi^7%ojjG^@A5B@qvo|_c})Z;JN+@kpv9#UmA)37i`=a#_oSa z(w$-K{(<2Q?rC`EW}adAz5~#`Zwgxfl>GNFyuScF0I3H60RL0te0Q=YiUu_XCENVF z1bildwXoQND$Q`5G3FYrsT-w8kZEd~OG0c)}wumc#)1Mn{u z7~;e1mIM%^2?BseL1&+(pp9(=^trX?x^(ryYL!7A0f4In+BB@{TBr3}jhAHa$S_k6 zuejoy14Wx;rghxq3vQKWK2Y>cqCq5+n!&A(KJTft*Mp&vdMksG_7;r%M}nCpVs z2T8V4c_IL~gYP{a`NVBm0bQ}no%h855hl z6*i_`9R&%3$bsT;q(dT+?|<3ypZ|u2{NFK;|M*xqF9D3x|8atl`9EVG|8cEw4yDAS zE&nlw|EDp9w|i);9Nj$v7pVu=ifLE*8%N4OjK^G`T5W#FmXQvJUoRul-V`^ox3$>X zp?U51P(|V0W}D>0UxeSvCOqj0&VYYxe0bpB9Kic$P(Z}IS9Oi--9&HeHc(xyI}|OQ z@?*)R9S^?)UYavIfbdj6e$DnI2SEP~l0oMQKlu%}PW$%W2T-Uc><6k5+VhAFgs%ghl1JQti z{0FMQ2IISLkhPFnfV&&ujasf%-i$!UwSOYE(^_6G6`;($(E0By*XjW&7f%+7{3DBb9*D?^Rs`{9-pVe~N{yDc6^+&io>%aw&1g zsmn_js^8XbUxPj;lFF#4KpD5AvUXnv%0{m#>>_ zG2B7Kq3<23$8Mymrk3-ym-ZoUb(yIP+;=vJE&75`;f1&*b`bDSgiLNMyZ1-h*|D}} zINSj~lECL_ti+>oPw~m3eK^T%PXHw7bgqBB_G=S^1<+!m2)mWv;Wmt{Ja*rX6C4EB{m4S2co~C2 z6=koXE7frTG&Dm4cc?q=HoW{%0|(0ff_3C$2-1D8mP^xxbnmel&24Kqu;Z4g6uAwE;X~9_CM%QEQ-7_J+AF?2`;g0E^cnSfczGDj}N7r@roq!@OO`sm*e><1M zuI|wlC92ozF0B3>aM0wwnti}DNr^AF?>q^p?G7=!ZG)jT`K@Vz*MuQx(qL7#1n=Sg z@?Y&}hG*>8*iGOJ3(W7cw>)T5dn6I3JsorKI5A#FT?ef>D0l<^c zy*)+AyON^OTk>Zpvjxoo@FdHm{s~uUo*#f#Qn`!UG^U?d8*t*eOL7iu3w#1|ad&Yq zh+qbmgN^sBJzX(#-+t!A_RhPUPq4EQ#fy^{{n2 zkJ$i#xM;xeiTMjNlLX(H!8PEO4c9Z-J4rP(nGC4;Z-5pG;t3)Z!J9+_!^XeECRe6Z zhM?#6D;ybt?!x`gn!nSfev=Lu?cyPCYC!h_q52zBgT>9565ImU!54n_<=FN0GOi%} zYTUCc6Kco>D);WaC!E45q)}W z>+J{~=EP;V$trP-~dGe6zJr@8DL8EXn3X-qSXUVV}1g^{;b?#oueE1DveqlB4 z;Pbesy52?SrZTh7qwlD?%t8|B-n^?!LMnJ&+KLWgdQ4=+_zc+bcq4s7B<5L>bu`-XGn z1EvLwqSFvqNsE}}7jfV3^(AeGi@UWS%qohfcG7uHTk!042-yFwh&{Py9G&JTAUlu| z?&15%gZMkxm$>hb0KXwGEG*M^L+igq+-qVn-VApA5nCC*VaypeGstkzI@ zsBJGa;cJZHl;nazYUH8?)9&oIP_3vC@2uWiz<=E~%ngQzGB%gZ>N2OP^q3C5b&-)Z z-*P!{5DcmM7%_dKVlv4y+J(q}u$vP3L_`5Lc_qkK_^uP{)I*OCl)65iLp!2yjQ#JL zEfAMydjuV}EiJVrNLdE%)(H~-e6uy}*VR_f>pIDb3u|4s9j+3Hx7}0-abj%bZem|2 zS?S3k>d#Qky-%tSYujO`0lo?aP=7GD95@Z+s%OZF)g?$T>dAfHUQFa-%jL?k@|?q} zAxtW3^lP4L;{M3KVa4h3vh9`xl5D#qhC)wjx3|d1CojVm(<*0ZFxE`3RDP~z3Vz@D z&g$FZlMJa-8~;;@LMnJ>3Q92Wgs>%w9&4h&Hz%5Ap_5YcjO|yrAjDAiStAm6%W?Ly zQs_%!+*~=@eVlnwBp;F|7B@>DbQgA?H}x(|Ssunh>GlY@|FYee?tlkGdlIbuAT$NN zKRM?iC#3La%2l<$)5#jCCa-9Td zsF$Mx*-a71DwDg@8C4W`s@nZ1xg^s=YW=mvoO0iL7j`-G$v(3n;ih>IzLlGV&NdBz z`CAKJxP{Hw2LA*(G&qRMe_YzAucJRGY2<#Leh;P>pz2FAC{@CKC57@s@zdlI&zdW^ zo>@i0^|nujf>Bv(CD(wMnCka=uVW?ov63I^bGKTbLhqVa7HXU9KkJOA{~fAg2xiR{ zNS~h6shCc@ii?a1;WeHAPFksZjT>l~@v{e$x8myF#GXzSdPCBbj50C}AvW|&xqCS? z7n0kVQXA`2_{KRI9lxoTGBrw*U*Yq1ZnQ|UUrQ)f=HMdEVO3FJ96j=-CFrL0^vxB- zi}yZeA$2x0J6k&o+FeWfqr*r3d3HVU`H2UozwBO8?)H;yUBdCCM$+P#nQ;gbtYkrb zt|N$UrFq4O9Nn}Z?e>&3$R+<0rxKNO^QSu)EziBrCC#<|8z}8u2W72jNoqZGQU1-x_T4i<4Y}S5k3oXs#0{yR*0b(`XxvNR-+~; zt@}h2QAcsBVQ(?*RS<;ic+Y{e{y8Hz#$;RO;8ktS<64YHDFuG}o_s;8_FzuPDw}85 zs=u7XaJx;;%;7oo&c)sS7mmRtEcxAKTI5(yO|%^GdYw-?=#kNt)1?l+U_eUlzeRqN zhdHyX#@4kJ15QB1ZQ}97o4-bS@l)%siA^MTSv2wy;~uIpx6eg2C13l8;Y6-uW4ern zcN<&ACVQIy3S7%fcvEt~Gm^j3s@`*EOZ(_)WtPsRzx=PmTqxnf{4YkJ!xp$5pdW|5 zcseTJxF4YZ%C=N+ct!cUCB!i%%U7PgNucw|uj!Ju;u?BB#|J27wG@HVH`R%{+q zQ(JZ7Qc#AD@Gd_VfefF`AX9vIr^CyKJ7{y#>8Q8JA}=p5O^|v%zk7AMK@4L?*=*9t z5K8)oakakYHpA+CC+`M7^z~uYn9r(s$9z0lrS3n|@}yWyyJJCvc6a-+@JJQ0ysA}$ ztu7K-1n13xlB5UMM?fDlCvzGtm~jIf3uz_wOPLx&>&Txt>Z*_2HDzTW(#e+Q_bF(y zF$w%iCQxF3jkfEf1+Vs(9;wmHjK38og~X$$GFldJJ3GRpvjO_@u;b?G$)ckD0k@|; ziem_<=azqMFe+ikh@Sng$Z=p9H--WajdJqct4d^exHAhMCu)ejd=vkv*8QF&CF9Mx z0z2l052p$CQ5_?N=%-oP4vwD1d@IG-L4^imuTD>*s&|GzWxku)R2>2a0nVl$}OOyVyHj}`|63?CdAO1Lh6s;+*}p!N<-OL>G+=I!Ew z$0#P6vb3E{PF6znY7l$V)7W3ha7?H9NtIyRJ?z^ilL8AR?4>Kv1WYgQBDuLu{q%dB z&yb~(i3rHITGF3jR+&>XzmT5c#Kl34p^jLj=AJp=2EX>R@+65t6^HsJ`DT*cT|*Q@ z-4`%zek*MRzYyC-e_oK^2j$SY^7NG~W1U;LO-LS~9!=j}HRp^6F=Q{*f#x}o+gDM1 z>+clnKoA*eq_S?0VAum0&hFuOPvl8$e$t-`9haA3vOgXgRn>QXa2|`9dqW`SPc=Q< zjIQOsdgFqw42Y|a=-Y?n;b;aKwxgkPXBt9E2UkQwjDvp+n*O560u4PMKxPT#>QlK}VYVytg{s{94j5gfQilY$Wu zcHC~d8kSm+gDA!{r-he(^y7=b`RI=*#yXIG^E+xo2OoN(0Us`6k!2{YLMd51eSJW? zOJ`kS_e}TH_52%m`(kMb_`o)7_G(Hq86sAMgkWMKZ8!fbec-(5{lm?xveJG2@FV^e zX+JFvV~{2r3O??4kC5=^k4M{|=ZqA?_3BkR{RM+6%VE%{!ygHHul+c;R!Q(`4B>kN2Gc+<%5|RURnDbLQ+^{1ZCI7U59Is2ukrO*4 zCn~$gmJ0cdB#iYMQ@Esyf4?S1!MB8XMj3`5-jof!594q5m9HdY&PmI>Ya0GK zdbU}Bp>7P!xCLrbc2r&pN^6yyvpxECk^c9NeapVFiN6XAF9#vJ&hdSigdpdP?iyC+ zcZHtHCYO76t&}{|?;JS4btyU7bdFVD-$G?h3q4MS5ag1htbW9+PG?6Txqt;X$@=P9 z#e@jHfT;ec5w*`IY3;th7i>9&vd>teiE#A4ESg$3XOn1zzx*|LeUgT!%69rk4Waze zmg=98{(NtrPyf1-iY|Gk<3n~}t>E8jq%8C}d*;P-#YReM-86BvaDR*z^G7PVHy@Gr zF9_R9=QF5v$~}=u*I{hU8{H0ARGb*|E*e!#RyZRo3i-a9+Y+|G zUmN-fX;g>_^m9jjJ5_NylNXzVuV}I&zsdrMMP+yG!!=bDrpx=_vl|W}LTTEb#-PTS zo+6x+LLqtXl9||x4CHlO2MIWXiWF$iW1#I6tt)VI&qR#w;Xh!-o zBKli)Lr#LJZV=U1k_0Q({m%C1?T^RzcAA8u2h#dbGHPow3C?cdxuo{~`>;icMoZa6 zs2CxzAsS1{%2SRF!*Dkw3o|ppl`|cs!{3K7Petzu7<{UbaaJcQG2$h}U>%8I@@8^U zB0m@jQYC+KycIG9H6NKZ3vi;BY?V>mq9VGC5zCSG4up=jd^nGdK=H!T*FjE=?sa#B z{U<4wf(l&2tYl!Pt|*IK-x$_~&Zx>FWIQ;xH||!B-whk6L7RTPV>v4@(O|s)Ty{369Iv zUagYfV0+<{{uTXnaZ4bFnsPZC(oL4F5pU+3dWYS}*EhzNmZgOgDU%VzK&r;B0P?cp zXDY4$tGb(Ok&?1$sDN(K3@F(vT{TPOm~yFeSozZj_%}CXvM8MB-*^G|B&M`uC@qb8 z^t_WE96Z@~uZ~m2C}TH(&(sj}<`TDxKS9rr4sUNZV*3x@Ex$AW#}+iFLf$#8Oja)! zgiMMFsDy2^bZgj;=8pmfmar&L%VG&aDe+_dg~L(v@E2966lX64Na6It>TSd2 zVA-Z;nVhOU&1wm^H7%4(IPFEmY)9;-Id1UenU=E*)930D%=$|5z8|@-&+?PSHYO)2JLld^g zzWeR*H+e!0q4aB`>YjYO`f?rKT_))76S=@P*#`G=RmDsJg=naH~&(*Cw*Yb(%e5qpZ`?nP8kgU(e0Qo8qD48zy3#W*gj`oe0a}THfrSj z@m|D+TeNom5tj7BaQrR_j$PgF-K8l|Mp$G&1=6!&2$qW{?8+YewS6Jg;KYuwRu zk;pLlt$NghzfUUohuw&oo&r8-H79n&d*u%GxEx6;dhN@<^qOaUAK(o2`E|+4GRaCu zGCIh~rP~61aNs#1`@RqUPVxD@H+SAC+APgf)!s_>e_pHps>Gk-U(alvujqkwu@J}6 zkv*YPGS(&Tt07Hpcd@FDJn0TH=`?5eCLfSu*KuFnN}qk~XwD^z(ECTzb2lYx#nWhx zkn)}HFVI=neOSh%ZTwtSR)_`zgk|=Sx~B44rPqImrUQ}oJHh;oNk_1BD zbsHi7BFJL44@{r&&8_tP7>j1h+BKAl)6eF28xExG*La z%UkW4buZkSxrnRP1a1E~?BICHU0Q5Cn?cjVt$GR$H@k5(cM>1pm~_}>K3jO{`~FGE zxa1c|IsETB=@P_b!cFsaUinn`nS?(;#nTjN(C5iYNP@z zNFH`^vNB}}o71n6yZVE`&ZC_7PdvT-M=dfT^c~h^wMoR6WXmFde>tOLx1&I9} zA`W(he_}n_n-X-x@A+Cw7B}~Rk8-7Tw)K^=rdtjjB|TJ-RSLjN=LrRlnHISMje!MS!bW)K8Ev^XjkXYDx7D)0n zN;&LBxOCsW1d9xiyUixMadk;&6U?Uc)N%G1C>)64ZnAkbpVTWA2uX5?~ z5@!`>XrQHH_NW@x?JeHhO|nIZ_}@fZj@uzfh#dGrmvrKW;twzQPWb`GPcRsDkJc!B z(Sl%dOyk-aNvX-`4e>Zm#?=a$M%n&}rCTSy=Dus!bpvFZVqSC9ZvTG#s;F7H^)^u% zxwKC{0VMmW6mc&`oyx5%M*+XgPZk$IixwBW-JN%d77MJ$Qast9LTnhL#Ka~Qf0#3) z_FK$qw1p#v5*O;|lh^I`Ywm<~VQ0%$s^~!vSi6=uETAprM`zS4)gOuohu3JRQ>REx z*Na4-&nKtIKdFlgvF_H_@H-<#xLed(pUbTbOL0~f5n?9a_pRoTA(H%6R$AU7b36;} z0aMlXPk9;c@?s@(>jgOfLtSN8O9zz)TQ!+Oa4|WuRqjM7O4g`Pj#4RtYfSQQ5~L1h zn0wRSSbW~eAd@*J9lDf%P1l2UGBzbWC~kgz)sdw;^pvHg5@AJOT2q!_|NA})^-2B2 zth7Hy^;NEGsT+P3!pLy^uku&6>u4##LR})R46V>x)^u@^T62!E8~wF+O{vsPhIddZ|4ac0guVCnd-4 zk@b8UFEeP6mk~Ovdb#&+)jz#_uK{Jr`v#eK5o@)v&S&cQ+N}|dmi?N;_x7DjiB(m96()|`O^k!s>}sNYg-rTCv6+IBCu zGzbij_2w39!9a@HNdBO^Z!Fd2_lSI#Ed&QqlL^*qyOovuh(O@bdG{-DKKwv)1b%)P z{1!M2o-7}F%rZNs`go71Cu_OINky}j<@~sJ<|%NDTRzlHKMS1x`eY#uYHM!+eIw5M z=JwiiuGJgEH}}cGWt`0hIHpbym*GoC0w>^HImlJ~RL3UqB&(m1oblzH1%`p^M*x3|s*K<$EsuG#Bef*9C;bC!4 zoycL+z9vjLWy|!VBwr_%`HCGH{k-;JV&dI5YYQ`5Tb7~}Q$I&sg#fSt&l*M$0IK|8 zqa-yFeZKVcoz8{^W|m6kO{v2Hv8#(~_xcrP8_VPe$vkUnzp8mnyEk`8K4xYTK9Ap# zsgh(y`3(x(>lJ&uj0s!pjWO)+Z`f^s{C#wRv zt3GV_!>y|zH?!DptuGsdzK$l@jt{4YyKyLy0MH({=Df?Gx|S);_Y~(Y;_zUgGhcE4 zeF7g~1auA<0uWCBN}L0ffSmt6(FX>K+}&2?NCA&7a?vo?wVQMpm7qf8E;Gmo z08#A)0O!RzKo9?O!U*SkUgzHuKvU2V80g{up3s4v*XaynAqVIHfsSg$6aq8%`T-x{ zx-hL`B=BnxNDskR((oCBSSy^S)>%3Sa4?6{1Pb>ww1BiO4EU7^q*v0;GhX7pBhNB& z&_i&bCm@aQ6-eix|Mt2EL`y#(=0_MyD}H$Loiu?D=r3mf|1$vA30Nt)Art^oS|M8@ z1LXtf{{M3VSn2aRb=Z1f(Sgcm38VeBBnmki@}#8OD#nnRa6d|=B=Tovj&h00)t;9v zo~)kwT>O9L2Fj%tAnMB*>W%H3L;jCkw8?>+izho{mp<^ys2~w&+E@u4gM66-;1iHS zeFUJJOV!es2mox|Lh(?^gU~Gy5^(-E@wDbiJHd(8ecsp8;};*httF8f(FA{)rg+Kc zr!hE9NTs7dK(7$hc7+8OLSw*~}}7^!DSiKMODT0geS@x1J-lel1&(4GjLZ6?s15TBIIobTK$PIQ_18$bce5vN+ zIZt@;L2d9qXEFe+(to`@TM58>5`f&pmjbuI5B?UP1EgU9MiS2ipt(O|6Ux=^sxA2T5|4((mfb5-(*7~RTlCb7k=l^*OeCPj< zWBer&|L4EZao)k3|LhX*A}EF$FSmFx1-KS3?7+|2Es*m++~OPHOMwQaxc_@A9av`# zOPmlTfR@F%8CSixpfM%RiGKXtk_Fc^#3QNa@kNaR7r}T$<3Es2IlON(0RM94 zSQAi8Sb}NRA=meYFLS8Q@SYyus-abs2aEQ&dWSw)LUBsdNx?lC$h);M~$~yadpM07I0zGPlINs;XFg zyuWs}?JZmAtOJDbT`xIbAkUoC)7b@T9>iUK@2~VP*@+2_xT*x&lE_hl>Wd*3JfykCFdf>u1Hn`~Kx zzAkycj++;gG_;PbOiTMa@^@SBYOCgWK|Uokw#g#f5Mh!9d@j<3s`Co8&fb}DAtL=$N2!OTpQ z3Y z&EZ!yT;VL*%<@hQ;8X4_L-Q=-od#m86!6Z9pF`l|Ge>?OLNL{m=Tl%iZ-reyJ##w? zbS&3-=AG}#>_7bo-9PJx>0gV*`R&XHt$d(9UPl2fD9*j!VJz_e{O}o1&NI+>Z+UnX z#}$3%dzOP9{oMI)mDj*1do?XP<$~{Q6VX|Gh(Djb!_3OZfT%Q3CA^Svk0MwF5D&lgKhSq?PS}JsCb(@$Mw+#X$H^F)0+p1v&Ns^7%zLytT_E0j zG?~lNl18B)T|QA2nYM2s;bIX;Zrzvf3+NXcw|ue_8tdA>c~hI|>&S;3o*11@Nw300 zG|Rz}s5`0A;qHt4tf7uJuHLi_2@Dv#qibI)5LHf>e!nMg=tjTQ54FeG^&PYFhwE+k zaT&YLy{;P;Mezqd3*U7abRNF^Bni6}4;vltSOLC?`O882DzL;6trhp-!F28AhqQ2k zql|_cveZ3ObVMwyufC_k8!10S=V6Oc4F7+jfQ{k0Zx+f%*?=s3w;R$co`g& zMPCi6+UG7O)mZd#5l8X1;*u%NG9&P{(j5L+siSf4xS`V{c~_)l}Q zd}|GC+ry`JGa3g@k4f9dy_CGgG9c0ZUDurWM6&`Vg+U!0X_^V1tRbaD>t5UXW@*(iV1z^m$nFul@3p(C30(LYyR!!-K zv$VAx%0T^Oh2-6WPUXRvz8voM{vC!v7)Pmr5U3RRGY6ZTg47)9xl9`FcP9+z@R6sS zkTD?%Trw{0+0t&2<;tCaSl1g;90F)K20xLbsD;$FO76!y_})1EPF1aZ$(`*U)X**h^!hsfVt@3^vTT`CRQb(QZ2b@hG;*Y);sLA z?_8L2XZgu}^&1n<=L;3R2f@)3KOS>=no9u(j(597?j_oe-G@n^(BAg94V1ISE7>wPk`D0T2?pkJ*3p^3F zg<3m)kfqi9DietkcgsND#a{r~PM~Fr(rcCBf+J||8<)S(fscHMxDZ^dl2Yv+-KJ92 zk`hVEC{(}Vb;8B!CV!Mw$d>1?Ij$>&pZYV=c~5VChCUM*r+`FkKA)F8WTB*<2|V%q zDYl74bXUO*9^9_E0S^UZPSj}QyRDWNqH5BD*7OfKnbqD2V$2tq_5!**5yEZbmMPm4 zZK_P+z?Xn3VKwY6v)^ZBG(DbPmt9Qc;$i>kY1MjLf~zd~7~(Nq&_=A-Sf+OCgNCH^ ziMps-?fC683q`z*;@!K{^0?JsC%-!!uddwTgsJivM0W`Z&TW9b{I63|UpaX^*FBtd z<%%iI1^G8^EPCJzQ}-wf?pu5|i0P7O54!*TF^TI_)e@q%A2ez=d`xUV&b6{4uk&Ak zk{2n4tD(R@Cac6chw~#Y7B%p_{4KgCa_zL@e!h%&=>SH#A{a!I;s1D+Q}M@#RtF_ieUCsu&tA}HjncV1Na~a z!L7g2(XtaGO=@6LGJ?BoH)zCpY1f(sND&U=Tvwvm{H5yYm(qekKj_sm-s^U*sBNh;42(V!Q!*l zGwzmh^EO!!gHNb=ufo?go-d{DwVRHR5xnj<4iBREFsPIK{+1B~B>u4`wQkx8HN2Br zb$cxX&A?1~X)Evjneipw{E~x%3@{G$c>x^PbUe8z2H052>g6oTgN@&e-LC>S5sN@G zEFf2(rmlkDrSWAq4~OfqAidoD1_@+U7!z}_$uYO|)nOT$iQ4zJOp!t9s%YCiMOnjo zf?E|~CKqdeO=Y1OI4G%SB_(San;Q6A$H~G_wl9AB|svq?A zQ;|L5f?ejR-YUV9s_2yqc*E6mw%AWXy50WW-~6UN+9o;)9`(SKDmRUKH(9}gZ}r!G z67jqKE6FM_4Vjq5`i*H9Cw+Qk&~?5;7QTldTl-ao#<|z#CDvx#^eM72yBQbQnh_;~ zaDH%t&FYt!Di?5*8)5nY+*{e3+T*c-E*a!IK+kUS`$>bf+}x0cJSM8h56oVz;{L2$ zm-=P5pmX1gE7H0=F9axP$sZt~_i_9iO6NwEXX02Wz89b~`eDlo?9n`JW3y^97bN22 z)b2!B9zpS;ElGmyBk^alNyV=)z=$;ztg7Xpc-`mz%;w~TPh$5|B4dYb8*ZN-@iu2A zc+HhPjf3Ky65Ycud9015O8cSa08~&9{uR!#1GW8MjC}_&nB4 zn{fuml0L5S%?DQz^%sm3|FI5hKF!&@BXqp_X8x6T-c_A}8dH9%M@{)PipI^_i>V2# zmFob%S~@P@o6f7_7qAKYXb2eDsjA|vp4-`8L=RikglE?ke!O;zMfvUq1<}+Y>%};U z0ECdsozC1?Xz>NZ#9W77|6fPezdT+5KeF`+Swkx<50^Si z{Adn@JeT+kka~*OPO>-SiHo|WPYDh)X&!QNvGo*8ttYaD@&grCp%7N?dsnz@vpvT` zMRX-RQiaS*d-|GsVZ^%mjGADyDb~5(ZVJtaPnQ^xw!oB_b$WrF)y_S;M`F)2dKk{oKrepj;CxRkA{y=Udo<~OyD*Wk)oV|Z+cBo z3Sg87hqRUCr456e?Lic2)WxUJE@>@3L z>7${(XCwVRr^&S}Z(-x{qiilt<%{rPVt%53ZC=kSk@?O?75oX80R*G)fe-Wb)5XS~cz3=L%G`NTNhr~Nr_^v_?y}rq$LT8` zmtT(2bYsV_1Z2kdXlo`>)?fA~-~DnJGFz^MxXTZyZ zsHPM=h+?re0+EC6N|D*;PYsnhl+XLdw`KM-;db)pqBW0jn1^+xOSUspElRPd^I*HN zk(_TKO4Oba{LA?7iJ(E@#8hX&NTYkf%y)nlK8TWS&%XZY^Q)JdFrqvEVybj_r^hQz zRT$PB6Fe~eIFOmo5KA7XS#SUsKu(J0qzh)MW7uHo24Wv<;QAeu^h9$n%k=cyJl2-= z{RYGa;LVdB6l3ZRuRb77i|e65NRgn&kfvn4&09X9dsv?d=ayiCpsOy+r!Z~g9<`M; z-=OXU0kBHaCQlCeI`1_iH<;!44?YJ;Ngz$Wu$%KmFSuvIwH_Gp5kgz8+#;<>q_T%| zQCMY}%xe0g_-x(7v+a*3sn(^EKf?!PEq3L1fPe(zh?#8xmE{&Yso#R;0_ObCAhBg8 z&D`NkxGc|vQ-lhzXaN6EmAW3PoxL3I=CPcHoG^VS5ks7O_Ge~3TyVj=1CJ{Kv(;y_ zN46(vKRUMiy^tD}TWVf9ES20v(<2qJTj_l&9Es_LbmQD$gYgK4+Nfqk20EwGY)VC% z2QyJ!8ppn%58euIb{neshKF+foX;>46`6LZJRC)9!Si#NYAIXBVfwzR4_NX+I`h#q@p8uyupCu0u zt_YnU&}>zBxJY1S0KLh-zbopo!w4Yl6 zU*dTNxz2KdgD?Q#TWO@3Y&5Rd!JPT`T&@xaKQhd?2jJC`lm9*FrbDme!T%=u=fwB! z`T))T`#)!Jsp~U1(*Exu7j5MR9M_(|gL(Vm%^}+)_xf0FZjErG0Z?>N=Wenaw3y&u zr2iZz@}6Ex0A&6-#pEepr|YkwU4SF^%a_*`DY&8VU{FO6D8p9AP!KV}A<8pO`q?)! z4UVSci}tf-YgFibL&8RA({P2J4uC&_l0z)^Hq%bg6UZ1*HnG!FC!lHjmzv&He*#)(7`%tUdZXV~hc8-@~Lehr&bmmK{)F z(D$FWP$Gp^NnVGhNi2t=#D~zW2F`@_k>l)`n>(4E_FuTW_8#3g=;9`zXpZ|d4AHoj zcAS`#z=14)cn2lr0h$JU{%1D7f|1UA9wTt6PtJ>-Mxf6@2ges8SOI{DEQSIeG{XzRqpUG}(-ZQ~EE~2c^@pQi#X?kx*ZVLe!zf2>3hTzkY30>65Kq zm4?MH_=uhgKa<}7gY*+gN_a#5Y&JLJ{TCr$l@s)JH-CFM-io(bbp-&_(9jM(7q9^i z*ucM0KqEDGxxbV=Ju#uBudlDCr>;<3RP=uJ&mU_u!vxTr$E&Bxme^I_DVLH9(E&Xr zI$Bk~has6iPtLsIbMMbiTuN#r_I>_Qc{sw2CSPKhK++HUFD7OzAGeQH(j`eW*56AK zxr*A<7GU#v1K>CSl|KrtFRFG+&0Xi`e<;sMUHKv|JjH|L`M4Z|M)kea;#o4Pob`&a z+Z%x@!*+F`N>5YOB6v1H^o2tnmj>6qtSDM^Q&%%3kha~ZSyCFDv@w=Nzku#)88 z&z{|gvFBc(IHM{gjY3>ODf3a=eIvk$+%?sW3} z0<)hb1@9R05w;Wj%{F=hI6+_2vbVQx|9ni{Oiv9o%>7g6u)QqAXgnrRA80V(e2*qB z#aEK)DtH=NcuJG{Orxp#DF3^U1?5yJoYI+RE3uCs3}m{d)i7U)0LW{ug{?=f)1)Oi zK-=qt*DOXcJ5Ij?Eui$t^9nf6}4$voP z)Uo#bSH2JcK*fRhxrWFXR7i|aF{+1on0v>IFu5Mdn(}I*qinP2Q`ueyz)?VNTD?sn zOUTaV*+N;84kMao{I({H;Wflq&i)bx4uS~sIGuF}sjBp93pDuLh>cW&W<<7wU-hX2 zvN$(M5~NM=j!7|LmdfSR>Y_3L)f&;-oNO4Ue^0z~bTqIn3t}Zl-6=X8UZyxP`iBpE z9jC@;(L_%}qr1**ZZVl+snLQuP8MQ8Ns)XV)1~C@-%Jc9dJI@p<3B~V)^aSgRIOG{ zA6ZcHJ{CcTj5z=efZMrq5%>beO+I5g85B28#GK5JVtD2 zVQum1uW<-2&$j=r!F>u>JD6wT!Z!mr27q+L0l|SZadnl?LhNff(sCgj zJta@tS&oy_(bq-|HIc z($*8rXqKjl=~hpbEGBq?NJAx#7Mt}D>S9xEYMY_b2VN~|8mkx8ws&QyU7m44hrO}Z zyN->?oG(mwJYOUML6wliHpn~M)jd&4hxoyHXF6Tg+h)01nldsO_nctV$Z>?;bd%3t|!1mktzd;D2Axh?bi;Oc*&3(yoBihBf(XRdG(Ez{v))OktC_QEX zge397tb^T_>s1qrf#YzQ;%B4WOaR^mBA@uG5IKpoTm3jyY{PDK!OfA^}!?V1eNVAeU@f7*NG$7mJN{)gQZE zCP1tbHv(<9hMugg)$UzA1B=+)^My=py^_mBmg^pyB_0)|^(ZP*=stS%J|*3A2c*_-jWxVbG#)LGdAaA0JINvT`B_~6QPJQwSdH7`(x+HZzGClUdt{- zk^>UO{5p!rH91hlmYvE_cJAQ*YX;KK{$$}Prwy}X)z00cOoI(8|1BCo{!ld0Gtn~_ zTdMf9bx(Q6CHU9r)D-PdS$6@rxtzT(^*9PDpE%V_S1Ec>99I+}`$Klx4IZ8jUv{qw zGH1iBv|O@DFRmyu`U~*_yl??fJQTVf4^K*l>s@9cTb-%^122L?)8W+Q&a`wqQzMK! zq-lvoLU4HM)y%qIkW|_E~>*aJM@C~0yKSSP#jltkT4K= zZ>SA=cu>ItQ2O5JNchpogrwZI`6q))Ao^r-7~x+Ut5k;e2+4KI0RQ=$H>obf@{gxw z_KK1M9vx*H-6{QS)U;tfT#w_gFXeJEVSe%bWFkv6Oo;Y@&QxWVb}ug=0Fz3o4$D&f zxg*_^^}$l#nCpHd6X{4*S>Y>Gyap8Cjc_a}49A3qgoYH04cm*gch!Fs^&W{2kncFk zG0ID8@YmPqa#Wt5E$J&fR-Am`-tBQd>{&H8$%$zEjU1y82ZvQ0RDsk1`t9ahuv?z5#$W!2`6B>$AO5++2>XwM zXUq`OdJ7A!LZ=MiTOC$S(3oTEdD2iyjPL9fY5Ixo`dn4%M1V0Nmihfx~TE0OVu6|TWS#^G^(A2$C|qIJ5uL(@Ba9q zph`A()(kE#Els+{aZ0S%8?55L2~>gBT4bs>H6?XD4jO*I^vU6XxeS+0GbaYD%OpE^ z5$VWvs=^}u{jsSE*L>pQ@)8@7_0lFbrN zc7q(B$VvzQ03W4||F%ej<@bIcL1c{e0{%GpI>EP813;51t5 zcbQgyUmvE@fwsbfwv46J)AQQ8Up2*Q?!^{SJ#_**{LNaP1F->Ob=8OMx{B@2uCYz_ zOPy<9pC6$QV1>_3O&Rw~Nx+Pll~b4R6Sn(`ApRTzu2Cw25#`Rl8J5ANHGy9Pr|q{j9L;P=k>aFa&Rbr0QWqrk!HBR6 zFYURON{4jaNHxl~nBg)>mkqVV!&>uTbMA;){Yr8jiP*+;4|r zuYrC$F|gs|na4?Cw5kkyHX8@_h~M3K^lLQK*uTJ?BvKG?{-Nw-KrZSfvvy9P6MUw{ zIJOIl?va{t_*US~kX>QjGKqUpmR>UdK`pMQ)Mne$zW)3n2s z$;~$bYJ}I%|1=x8EBjrs&ksIwLAh@qLx_;i(X-V}<=+|SFMml`$x^36BrKtfpF?_S zFUR5ob;F6c<6XiLPfW#vPw}ACywAJ9X*R_ znv1g7BkA>6pV7BjV1U0WsV*k0YDBUZdQA05tHxP+07FwOAA0DW532`!c7%4I+k#A; zZcP}NhXqwJyZ7PmF%S$Zf8@2Kivn4tpN&(lF+0EfN!}+jLWBWfntH7(%L2MZ_k;zG z`4BlQ1tSLkd1zk1Lf7e5?{`=xi>((y8%MGx`Atw2l~<(r^pABBZF?gzZ_sv1+Gq)O! zHZd0Q`*dO$!s1|}52{+?KKsu#+Yb%&rQWEJJ?T#4&?&uDI=6&_hQ))v_GMZ>a+t*b zt%|f!tJHnyk3IYW%w7hxi&me0=XiQ8e>lc3aT7sHWyFE@FmV|<`mss%vzUO<)oD?Od#n` zpVh(n6K!a%mfl;c@c?1SL4473E`t=aN<&35lMmQ0>4)snnb$&3L$u^%`+`r(Fw+A` zM=Ju=(izr*FnEH#LRc`w+Pqx7q9y5BF!vaEQi%3?46(1m zK6LwzCvG%%vKGGSycnlAad_vXA}*?PSA=B}w@DMDlZbO^3Tx9MQIWN&VqKuW;`<&+ zFFs|J!|>Z*Qct%*;idN0sS+^83M#|x)<9!CqIwt>F*t+feB-TrSbq{GHVzZz`{+I? z0=loUxl+R6bPqDnyd;h8Ub)x5-?1AM0}Cr0sXp}u(J)uQ@u0TUrpN_!M`?3BPOf-K zfuU1$t`{2VU<^^@W2xtP?e~f2jc78#yTm<$-?*I5#NA>R95R+vh-oks8ihYf&u z)|_;wOZ>ivp5Lm1<@gp6Z5WA-MzKfxV0KG;w33Qlo((~t9`F6Xk& zW3tJjD!NVyQfZEv_6xX$B6Nn>i)A*FHlxNZDu5&tG{@iTxR;y)E?40x%>67!AdG7Ceh94RM<=8l$6ZJ&}Dm!zYE-R>&zW-WWcD(I5N#@a+e?hygKLf**q* z@N#ekhtTc)8fb8bi~Abxc6)%a*g2E0K`Gc2lZTEBsn_xGzdKl1;$BHd&zwQFQ42jo zUJ3gggmS7e~G~{-fm6P zj}gzy_uBntKkDGxaStN012rE?!6-d(Rr~KdVf>01W;5198|UY1-?x ztT$Y~%DdYUSYT}TJN9_LRogVHPToyDF$&eA`{ocscAe zEWtCDu&*Vp!fP>(ByzqW<1l#ML2$xIsyA2sS23m&;)SnbSQ{&(X=eQ*!nkWdPc`1| z@s0cnRKmCr?S=jLz5jgJZu*PF71%Lv5}u?iN}aO;0Tndg_)f1zh`J z1=u-@y)1Gt!CMWyK6%R<{I&rJuj5o>^)iSt^+w#aV~r!f_}xhlJalDI-IWpVr67}i z8h9nv&(JgSCQ!Sc1++ODCbvg?k*9atm+^-mC(h?tcl%)`9{NA5MQUUqrwgyV)KgHU z5Rzdta1hwgW9*f}0i@}Ty`OmRgW0&rWHkam)?uBjq@MOU9DVbj1w}MRR1?shlxRInwIRvc9UAX^<3Akm>+AFsvN9d)3_SmWiQ^u3I;e3JQ*hVHDl$#d| z5@E3p{FX*@VV8HGql{=*=9`?1uK0aN=?rjA(1VIc2syjeB;CP&!wnq@;g&{r8F{gA z7v@Y-O!xP21Njf_xFNpU7%@8!q5mecsl|lnrMiOcP|q*Q82gXs`@%H1kZAT++TUAP z$PYO^3bpt=G@Mzi&y3nt>>eEEt@FP0m-_H6a*44ytD;op1bs?k9k%5|+C{XGV30;D zeK%-_IJyPcF~3a_qxKAa!-1Xk4h*pdnwJD^*aw!P~dN&EK7-( z`4y{N5?Er#+>S*v5G2V@oUVuwDdK0%pv;ETpz(-(sh{dBj?=2I%w*rxWsWEYkBNI~ z#j)oKn~vg69nNwlkOVP1zG)P^jgX37K@)CIFy`#Y_%36YfV~EgEf8z*Ge$<Ke{We5X2(=XOaI64e)2u7k%-vPQkP>`jrQwH`jGoAEUo5t( z81{Jn`vU8LnrPBlp^Y(z3-dM;rM7dW)>9Y!B}!l3MWv_naz6KX5ob^9p?mv+#_Ja& z4EaqCj`$FMF7N@suK`}WCQ}%+BH)+gtfd>oMgNMqFr;eunEYMY^GWvS_bbrXc^woxSsiOGZ z4YJ;c51>nan}3m%K(XCbTVJ2r*{1wt!c_0dd}qsTCNGowoH6}7*AxHefpGSHTs*Kh zny<$bYE61ca|ubMrycPTvYt%oJ_NTSzIDI%$;)vOnS@R~AOy2ywQKJt4jcNCiZtLWYlVTkBP%dezwP zeN??SzOokBUJr3j!P;A-;e56<<>}@Czl-biadzU6{%?pPAi`H}MSy-nKP+#vv~jz< zNbcS^bfRvm#w+tu6OmQE$rP%8KK&MQwi1$jZ0C7a_fh{p)lIchAMGddZynB2ANdJz z>Uvb!=MxU}%A(oZ%Y4Yi968$8tBtL1*ZX1I!x1y&nANjp9Lho+Tn-!_93@pQvsSd6PaPs(^Qy4 zKt_v)bM*O;;|~_Q)zZ1b4oC@s>Ulv4EAKnq)Luy3cG3l-Xwu`Nth9p*#U}&pC0XCA zM(XzkLk=2qx)oD5yL7e>oJ{7i&2`t&E5Fils(} zV#PkXaJL_PXr#Rgg5`fEZa2{uNN*H$mo5&$9MaLy#J&~q9xU<9gGfxoE*Wu#br1XE zb7SD(uRq4r2gAhDB;O9y(mu}z>h7D4KR7M6cIo&R`SdFSQc|AH7ZijuNL0{S>oJ=w z6>Rob*?+{i2t|D3gr6?Zl@YnA1}TT??^65X``;pOyep)F^FvBRnTp0c}4UE zXUx@s@M2-v_YBSD$6Xj%U>&oN;KRbiM;^utzL(;@z8){d@nt~(VypWtWcQpHYh3zd zVEphBDULKoZ$^kY?ZvKZ@Phv)FGQS;9|;LUW6R{Q=Rua|_4>KfxWLo`(<4V!-K{2)vY zI3=zrA1U_PKqpfIZ{3<1N+12jszdt-m}?LJ96cWS*1is z?e|&9T7u9kx^rL*(2h}!J1Y^GK7h~WkDNX-x42?&t%x0*i>>DJ>u{6`s4)_;BwXq_ zR~;+sNe-Q6rWHMsqP{SChJj>+{_&dgbl{R2j5Q%qi*@vG(w&p0aoqj|!0& zgZg?F$-fmTP~uf)U#=P>P;wy&6Or;1$nC+tATZtp6W%LYS5?m z=cv;xX|?y-Dryj{t}smj2qjAVJ%&ruoiO{XfK2XCPG5d;89k|crlgF{Xd>c}eEO=$ zAyps9gU{Gl*t^t0cngGI=Bh;~7TNAHX5sOCDyQudl5@egSs6crRkiema2U}0P(BpfhZ2NW!`5RRPS+ZGc|7Z-A?783w$ z{o|$JTrplwWa71oEcGwm1KMR$K*#&N9mtlFzIWp%0J3-{&VKOhMyYl1v+G(5UBwx6lrxke`%rzB@`(r=y&*07i^r{Tlcolocl&U z_OapQ+YK%30Q7>|#)NM^6$w6mvTf8^-5&=!%jF57BYNsdTlJOA$ppXF-5+wuBEo41bhMOo@J18i#&Z*GO z%~mf}sd3uTh`hBvRl8_$SsNjuP3|D2OqK)<%%wZH5S}In zOUg40?u&kTAA!lVEH?*O8&ow=3+Qqhsu3uN=oOAm8}CJp)ftPHcXnJkK`E0vPs$B+ zw<;z^wf5u>nkV&4w7F!#k{E#L!NAlTtxs>iav?AHEcIx7MEj+F^Y)~!9#9{syU-=2 z1U5Ka4Tt6o7qdT(mUo-+-rnf5uH>7tEeU8>Vcn@pLs>5jAm2>5iZ++LCmxblB^#>~ zOc>K{h94UfY;Dy|&o;arS;umY%M1rGp#zRSCk~RkPtluWe}6R$MtQ3NAHbmD|8fC5 zbCc)a7r-^Zf57+uesN;lMTCX2S@1bGyvNXv%)fS81#Ci4Mj(KX;AZZPUC7N3*A^qd z4>!Jwfbqz+?Z{tqkVCZ_M?}DiKca|60L#qtRDclX25y{Jftk0R>6^L z5gc1%&4^fLg!Qi;2;L&IJ)H!UML;pb*=e5h4mM&=`d#r|OM92W$9PhlXxC10K9xCX zq8pPyz}9ECV~#cj4b4}D%-T5Kei2JvM?!G56yH}70W{e;J8g11 zf50IXzD`Oq>ccx+#dmEzWusmv^#7~SlX&II9S;CRHs<*a!8CQp`4e-K9d4R!@JBH0wavBh7^><& zC7Z!3anB2`Eyu(G6z4qv6ahtM;Enb!Xi4zJbtlt7Lguzy*Iif}6YU>NUe&%S+vao3 zFLTO(Tf3$J*3Yk?uiT-aJ3Hd`JL32LC^3te-e+|QRw`-5AA9$HUFq&8g{Y$^Ru4$a zBAD#To-iz*n2*jnRi(H$s3m&}vizc^(%0|ZK3HnG|KNurSv?1skE+JBXc^s2eAiP$qgh;OJ`4x6>_cGAmT zo+{CQ`)Hi4p3z0!?9ADlZ&vT|rsA7?y0NZ4Z*7f>g0ZPvDh$=nZ@kblkB31GVb8-WixbSY|F zSf|u<(l%c}tjOz6k=T>rGzG6)z7)v;=vQ~T%3U4?GiEAt4FV1$Gi?eL&%dA*VDqP5 z-8{Z1w8YkOWF5b{vIW^{ib-eBHL|C1pbl6(k8>C;y$eQ6_SJlVUVMO3OjkD^mKn@= zt7f93=1wbjnmxJY{LZq3%CSYl`+|^R?n|QZHnULH%VW;^-TO$yBc!A9c1!Ro%=41! zdYXyWA7gY-#aBf^lkLTR>U0!Y=d6RWp#~n*59LE(-(R)9E)L;3vq~V$R8GrMKCY=K z*7kmJDf3Iv`I@iM!d*KbU@xoo*SQ-O{@gzgA2SppY;Sb|3+)57F(6|m!)94Gl*wd> zu2<0XeseulGO;--%^k}_K{w(UrnL55{#+xc7=?3cVvhsS06)O7lJkQ?5!@I!81!{0rWrSV>uFHS_=%k zveGv9yLZ~XadALgtCbSK3N$kxs<>HOoM5lfGC5Rv4Xp*f*M1%r4aeb z2-Y!h>E;JK+8bgPxpn^LFw>LYH}^|BT7KNv-mw?X?b%{QJqB1_;jW%F2k9l;&QvbW zRNf14o|xRId)DvNIW>)#SepSG)z>R6JfV#*-$9T45NQA*D; zNl7Mm{+Qp;{bwE9%+}RB1ZL?iGuU+s*&-R{p1G2OkxU-Ce1IN?Lqj$Tn}h0}Cf_hO z_fmnzCaZ(}xfhhsqsEbX1*#B0r*$@BX(A_`CM%tG{`Ae4lCG`WD4y`)c-7evy7|tb zzv@@^`a}YkPRsgX>P%t)Yj#3FMkY6b%(51k)awnMSFnpspNuqX>~$SJN=f$E5`fj& z3uTW2LnA-{F$VdG-Rw!d5@m=Jb2((!FI&?%{dk>lF!ypW*VUs*?DEe|eQ}jzD}0>u z-akVNWr}lM#}&E4TKpFqPa!`6vNc{b99TFiAT0C{-8;PX%g3qLtp0j2w1=`i)bg-8 z|1hQna$)(?yf}OY@>CjV;&L=NQ<*fYU+~_lOCFKi2_|ZbMC0Q$)BE^cY42$ zGjQpbd?m%jea4-!IcjElKkF1`G&(AsDP?r>p~GdpBv|i)LZf*Gb3XtSG8lL0uj9R5 z5_ZFz4o)rZe{4TFfE7txVTxM08ywakD%M~FFGu)Zi-qhB+)MA2sc!|qx#_v?(EPV)(p;9n@{-?~O+CI?YX3XUS~079 zWyVkyQeQ~@g5$E_&4f|q=+UIX^^+V9HbmkyUH@s_kwl*T9tm@SeSW7?vaCnNzAC83?SQP4QT7IdGS>VMJvtyqD*)2Bav-_ClOw3o$I>P01ufDSRZ z4JemHL)BsCA?EWBb`s>O<;d=%LC^XZeS162283;+Ytb5_pdxVQMvGwPLU_%Ez~fJr zv6g677YrBTJ+IJr(~nxmszkr|YfdrzCh}nk(-{SkgV2O&aatXYlas!L@V=hwHRLeF zBF-&8AVO0jetZf&XIN(P;?V7V1jm%>$jBj<#A7&Qd{3-DT6fD|Vzf?uX@l*pm`7KE z(ddS2J|d~W2$pJG?%H4{3|l810g)1!0|Dg(P7a2eK9gg#-e&Ql^z^gzAcO|_iPGTx zishQQ9Y!>nSUSSu=PzMXGuGIg3)camn^@`zY>^VV-TqBPA0ucK9Juq5dV=};ipG7g&=0}0%eTKg$&h(?Ekn^}^LjDqi+&KY@ z2ED?Ij5iY?%+}xgzt2A_zqlyMDTMHkvyZbnLQj>)t}-Wi&`qqlbg>R!Niyew=ir6z zo3D_ACCiCJxM=eXuT#?Df@M-2ugW`L)cHu}0`axX0)v@<9-2t;m{f||Q)A+ZaXmxM z6wR4*huo&qLF0HBA|wy)k%pkt8$~Mvj2?j`k<9BHt?4B1szh;KHnrx?^BsXXTc+;$ zd4O)fY;^c?jJ7RvydMpfxJbtqrd9y|pba}mOK&q`@j=7N7#3cyR0M*{1jHBr;lL*L zDMBuOBb@!{A1#ps|GDm%372&0nL|f=K=ioMes0flkE8k-dPKBv3Q-dgAey%<^-F9npAx6lejtbh0VY=eK$?TILlljNz*1ksOWa zhHeH5bd5noezs3Z6}tPY;9zG+4FV&qA70$9-8q)gL_X$0(aho~tO{#e-@Oa{7AO)| zged0$_nqzXmQbyxkT(OsLC5umjmrKe&&-EyF|_10-8sD~HkgAm|6wZ?V08i(0f4Dg zH%`raqH?ZA-n3}4WfcVcxwlH=^V>uSv>yG)^V9aCUbqtSJ-=b`xoHT!i|cDI&XTlF z<;smfHq^kBn0s$@-7=hfk9&-@L;95#I^P8$9^Z22K^vpCgoq>`qerA6@FRioxMt?- zT0b7;bvi-x`sem8TbQ(iGC$~#mPszIst}6Qw17@~2870sIJ|jt;K0}rSU)R7^3;=t zf)uqv#o7W>xSkzPlz|>UroQKitx^&r#6mWrtRhNV045bq$-Dkad(qP#MbYBccc}IA z#LftO)}cmow3q;@ChGEb6?{R3$xWrS!sf++rjY$y?Juv;IDI+(%O~KEM^?1lA3F;i zyk8VPo_f)q7r-hVrjZ>&KtNzZJFvFK;=^^R2p{U&hk2|H-Rt-yYXooOko+vj{vv|B zDN%+u32#}6+hZtz8-}*#RVQEc-EO+-PHSbNxR5rzeUe!eIxjH=F=vhaJ9+1(QYU$i zH;Zl~atHaHqn!LU@r1oX?i#{}W16EiSz0H9ZY@Zj%g87YB5<=BL$fJ!CdPQZw=S7xt^i+hxIeOe{uTqUHLE-jIgoCLz$2M`j zJ%#(8kt?$0KkAk0(k0%@wypXTor&&HOWf7iy zqZBY$27;ap$34g!kkHE+s&m)v2 zA|1p-^Ni%4xVG+@9qDX1T}i4XI{_Wb3V$S?NH{*gNBuuCd`#Un@&tYc_oiOr4Ktb9q#&-7b%r{oN=Zl zWwh368nsOA^RS=vzL4m9q5wt6Z=QYiCwpsW52msTDaAhiQgyYk-xE#|Y-ZwY|9oey zYcQ2TTUltI(d&FqE)?1Xzi_7&&|g_v5TU_G&u1n0#uN4mNvRaLN6{!?VOCKcHqswP zEAsH+L+$`5BbpbAFDp2*z&#anrL{HDIJc%RfDV(3KyVS5T}FjgfvtOep$XoE~}x$4@7PYPatHI_~^Y31qo*~g2B zUgdK4wgt-%OtSaNiB?V6dfG0Z`jpUYE0HhKEWSAS#-<^=9Zmm+eW#-zA3H zKHGx2i^qET4`fu^2K4vII@_}N$OMs-xJkZvOG;u z@eJ;)=Jpx{b?s_(HU^hH{2A$AAP(!e*B%|xyWnr}V#-sFfwowc^=p=H!5~O^;ZC|- z?vIn8Ef#U6IyG4s0$rEmfKpS&pNCUmo7DsjcAp%N1jk!_AevAPM!kNkrLiKl3XN}W zm|IJa>!2Wres*W>F~V*5bNE1i`ao_dd?OHgTk*C!*U*Fxsw3CzD0`Xx(g^aj6o)-D z;7@p_!|ZmG=+&#`uDTcR;Ap~FZY)P*i(MLYC(qI3>TWE%)i&EdD&R`b-spH$BPKX6$l#~5l#r*f)_1<6VBF@HQ4hF)aL6gc<-4q(IG|I|AFQeF3DD2|6}an|JSh~XAuM4; zse*z@y)VSm)CTj-Co3(x`CoKe6q!Xa$1n9b+PK)_uBl1GZl7YXtUmW-MWmT2uhZCo z;Zb7vCaY&BXPC@H10=`{`1WOyVt8fOy!5=Cp2M0{?@HyA*qiQuc*h+i*M8zxF#lSs zaQM_C@lt^Lm7Y{4I0z(if-sBBF=tLkHFcUtQo&9@K!4J)G;th_-BX26K!aYuD3$@X;+<^D;P z6lFe79Qvg**k8YMsx^T@q9=Q^ltwp z@j&1&gTxvC$>}LwSy)<__pRYYwug{K5eUz`Q#0na9pk-iG(RoBcV~9wTIT&d` zI-CweB!PH0a=ha0ZlD^rXO(z}PyqHz>IuRH*-x||?f0}H>k(@VeLxYz%XbX2O+{5^ z8>qRzgfX z1tr}aKYYcse<{36Vd}{u{2nm4;&yy;5rq2z09h<*$EfS~@53G=JqMRYKYRC{2cTNwO~`B-UsGV!3FO*ZSpl^$E^}I*)S~NAqVF{ySg~pM+=;=NY z^n%nanL>__;uP1qYz;~+LT~HEM|zi!sRu8PROJGzOW`?rYB!gsYU1ki23&E>WBfW? zDg7N&OsbV%ct1#x3@cLa1BZ>(=yKYvD4K&j1Y;$W<%)m>>804dB+SIYncoFi(-P@Z zO#$10BSaftJGPGR)_Vr7J$n7;r2VcGPXl9#!7E?ZP}3<(m?~u}a@+Ag^Qj_$ijZ%U z3g=io3!Kw{V=gu-^F)qJS%eeR?_Mw%KHtf|OsSbwuK!Aph-2gtyoE=fp9zsK3)@#q zVO>mBF%~1s2M-Bn!l+N~3^0r^_J4dy&%X#A$n=57{9<)~f$@R`rx|bmWu3HyLmq1L zbW=?|NI96%#S!c1D_t)QKKRZzjm>>4A8g~D3IhxqfhAxtnL=~#Gkn7bTF^bVoTGH< zQvG*-jH^x@EKA8AEPEyYmaivu+|NTc?Jy<;pKA@&^=3xRve|_C5{FlhWJpvLss$RD?AIkA4BN1~J5P1~TkzTNse&LxRLaI}4 zT8RCuy(&KL!-?Go-z_`948UtJyew>>|EaK_(QWgiw`Ng_lq_VBz(T}35fwVoy1-s9 zthlY&pV{QH z4fkUiNliAr5F^|@jE7mjVfzx!G9o7vm%Z**XTqC`!S!_Nu!jL#x$<>urf-F7&?BsW zzfy!Rdm%;(kgOgh`{l`DUZrCz6E)D^k$-aF2#j^|k3e?`P40=_UZ!WB#<=ao}VXa`DNO zRIk6F?eKk){ex}_-V)-;56)GTUD1gAG?~iE&+s_eXXd?;z4_p2MAzs3M8=HDmGA2# z;l-F0esf)u6M=|wp&$@~ZrKaa`=AQ$94cdR&pPNc^#x4oQ7%-ADA6V}Z;7+ju|xbU zKf9L|nRPzK0h5;%bqrKMvy$EVfMaJ#9y#&k(IK)4&}gCj4|mTk5*)TOAY0Zn-j3VT zp3wVC#uoiOj$&?bK!Q2D&CXjgmTsVNyvHGA`2smnrHgd^(OAHj402LFGz|P($N&d2 z&&nbJDq5=0j&cC|Uf23qceZ%;l5>dp^}1SdlR6lPYkJ*kiiCi5slzLaiOn;}mULjs z|LW_y!ZgEHr5WBuJI0NR!@|BE?XJfQ4S9#72#RbfslMT2ML& zB!rGg5s)M_1wu;*+)3E|zWw9g`;doZ^3I$&bLPC0cTV}u)T6e_l&_UkIzOLrOyc7K z3x0g77P>n-en#PQ4#VxnI2u{8r$mJ@LQ5&h2e&m^%I^?^&K`T709Df!!2MIlthWRu zO;2=?V^oAEf#*A&mhxm`kWY2GGgN~b3dFHCw%}C(7-8fI-cJk1ev*#OR<<{f|5`c3 zwLc9(MP0{UXVw&H7D^Vbj`uqA57)9eHnf-0^Un~xE1MobbHE?RD(sFuYe)k0ypF(W zv*pGNbKs2weube*=?^0dFPu#>c7&(CmBhNDV_Esjf3uLeGATCgAfJrUJ4lZ0EfLx* zkdBSL^gUbIR90kauYlsVUfJOuh&b5}bM^sd8cZ|D<^;Ck8o#(x&!?_$Yku$EK8|a~ zrq8QP>>Je$dw#&@>+Rc${3fKlobC&=-A~z-IcgQV-?``z=aW4;JcFxL>!JC5W5UEa zjWxE{_Wme*@&4WtM2^kET9*5|!yRwrH-i;Ea84H z^Cw-M;ho(8S(!#D(5|vi4#~-0{q`*{g)TXo$7%)%om@A8{ECF$ih_Ervd{gQN4wear+j{7f$IW~#s zajD_T?PYQfn~kw^vrW00U*5v<@k(cI-|GO*LNTe zMCp(h1^(@p2KuTd8XM%{Zc{{6S$rtP2#n~x0=S*71<^_b>G=+su1X{0UnySo!jr=v zTCA2f;l-pVs9hu!tL7nZT4br?oWjN!g*7$D#yQ0jdQ58SDSH09vdi8(+>Z%j=IXYP z`ag-3qI1@$0Wph+^0;h+q2a2~rB6Nr#dwiUyQX^8FWP!XLlKp({*$d(#Lg<(V9&P* zEC~@olIjfS7iMb9=M8+W!ZBUR_s+ziQx)65<#>l1S2>|O5@5JzHfPie3avd0L!Zuq z8wNQxi)Z*55?UwEGX|sI2u%x*yIEZN%Ur*BJ*f05#U$OUnI0zSsUFw0QtLO?7K8rd zl>IaELxsyTw32uXYfTE~*hmWIaB2#MZ7v0Kq%g%Gbg~r^-0I|IqW22*dOjQFn{-#{ zLri=siHS3VP75!;|Iu;fyqL_XIA2=+_%C9>Vk~QLapS8wf77EU4$GZ%Z0xo3VQAN$ zA|1}pV!v4mRe!j(%aksMCZ!rM2FM7C077KONPr@EF^Y_D7@z-L{9ofL9s}(B#{je> z+$P~ARc@1JqIwW4JB;M;$AAj>(PsP{g4Lg~Br|ec!5ib^|6Awpi{Onx0MKC)m}PnF z&=0tpW$|BIeA8lc@@S){&Hk`EFJRiC>R0!xpL?Ok5vsa|8&;Zzro#s$KA{VVd>_W$ z*L8~1xB(wvDBJly7AyXPUT5e+BWElDX>fl#y+_~uYDLZ+cm!yJX?vD~Ca%ceGQcZm zzmWY!3;sNA@U*PXvKYX%08yRnph`GEqKHcPG$sD{SHV5cUOD8KSEQ;H0iQdATml)Q zUbH4erBGIaKL;L-?i{JG*u4@-hJ4IjO@9@9&3@#w>@Ad^3KK#3OCA~W%0YXweHERS zMogxl`1{5vTT;6?Gj`b^l81&4czO4309DX>*b$c^r)>pq~qm*n!(-L_PM# zm;8UnDKVXf45#PcAxsQ6C3rE4jBgm9x55T*!GhL(EI<(m=*@jG;b3P5N;yy=YnSo? ze=V`U3%=NO|Dkmxmsfu{hM^iuGS4gW zb5kdW6KouYrER)e&Y{^qUvS!3Mnf1=^ocAWS@GT@^%CSC)7O|Z1Bq1duLQZmFc8Y&eJMFmE((=)9s_QXYxceUPWrw;q*JMsFN7^oFLvS758Cav2qz`RSLh5asuqnZ@c1ky^W z<(QeJl~D+TqY2)?^_X5A?iQO3v;9JDnV7xig&cpLI+}4MFi+@Y(j#-3H^XXY&%Ft~ z*tRk2C5XcUgj?&q6iXSD5Cj@`VPk`Wboj}y3w>yQ@Qjapl{5iki;)Ul< zt(o{eS@5yNnL%$^LuQwIh6a6Wx<-lEKwjiQ9wfEKbQIR~uu5-NRq?$zlr86R{D>35 zse}OWK*@&qT~mpQtS;I`lb>H{!dv)2s7F<()5_&=7EKm@Iy1y!g7;Q0tckpJkHYtm zLjLtGI5M)?Hj#PDOeppaOJ9&(xJlvDrL>P&&|FePn2=|x z0RH0-MnHTRAWy26yde3NmEbf_fc&bfi;pZQ;0zg3MDgTSh&~?BJVZS(o7XaXovWsF zzZ+CF&d#i@b4*ROG?`qkcKmdvZM>zT+Yyg!x0`+$UXFaz5<{M2!uiI<=hVguKQxQX z*Wjz#-yS>=gIxOuV=;2#(3J!6F|9i2of^t{g=cJ~zP=KTsRtQQ&|H0kl^b73zj`O(0Xrlc?*@{6a z6M*m~sulJ(92cjY*mmt98g9-y1|uA_(3i27AsAGM9u?+^AMA|XQPp730idGSM*CdK zS21%CQ6xW4{F?OI6{W}_%W>S)ayI;=z|;M|5FdQp9K5O1qd~gJ4gnPOIHZg#51sV6 z;6K=gE>UVKs98$Y%kfFpt56|g8Djc(K25AW-HL7{R(t|LsY;)ADw7h*TEgrgyk zJFT2OPVYXKgi^>9#g0Vi3VrixydMwU_Gyok^bE@i{43-+?H|##-BZ^+z)N?DM$e$w(4Xf3Bo>F27DB01xiin*BH)2^pC?R{L4b1};YG?EO4^94T&2G#wl zBT5)K8)b5`MP7*K4p_HXKYN21?1lc6ne>y7XVP=-3eTd!`0!8^Ps5C7?WK#-H0ywj zmw5m8IKjlV&kJ=LBqa&--RTI)zb?DwJ$P&8q(|nuI*6V!PU)IaWr&?A_w?z2=V$#5 z>XO^^(Bt&+pwrP#e-*Q^G;w}ZmEQ1-WmQwZ3S(!fImAgtawwliY8A{z&_y#w<3^|1 zVtQmBQ6vQR10{@MV{`|*Gik)T=l?u%;#>@2I-%3kgT{| zc3%4Sy|z*%q1v!L4a>h52gi&ba!PZIT)Dm;9k~Cc>!wUBFtHR+qlCrT4;ziZc`r!r4IKGY228wBGIGLE-Z%st3g@L|YtOFoRh-y2sGe~7U2vQBp^HF< z*Rf#_en>UvQ4eFd^ds~I)`UjY4+wM1YrJaUFl=3Kiup600+iiTSu3sNhSGDR8+2=g zD5uc==m-Ek+{b==)+vb0dByh4`4p|EOuSsU+70mr_fX$HNbR2oE@%Un znBe1WZ8|I-|G5CIyP$jhEVR8>T7dJZn#z9dBW`RXwoln1wFxjG!_~%`#4P{3-ad6j z_TsmJwoUAXch2YDIOjdNCiyhf&iuo&ZFvLxo%;Du>JRI|1nPP1SxChl0U(Yu_~$EO zqwap^{#g!^Z}|54d7d7Gy@$^(y<39JT-9uVyoykLnvnHGt6>?;&omoXNW-f5yI?I_ z>ccb3M^+m-&?kSy%G>gA!4kFNl(^;lo9FpCafoC20^u|miY;-A?R3Lv6DQBjt>41; zY(u4@BcgDnDKi^K4U1-s8_D;4;4<-ic$4$Er=<%)w5^RE$fUkkD{U04Ior0sVokHa znQkdN3EbOHtho#z*su*2@2CU?*vdZcn0uLFCs?($cx+p)4Sh(0sd;lJozye>n2UZ? zEitf)#$OJ$b&KJAwz2#Ucrdk#Pkcl zGbM49cgZawcO4TV5Gh^?#D4jQ5oroRLR_K>OAjTB`~|kydX?LkVL5QEp4_^Km|K=w z0y)8%+SYjy32TTSuH6c&E66gXsrrw&6M`yBUw!qxZtn3C*7V9@KO3E(aOS7_@ktH= z7`$`U`stz7*skF&Khxi(Tao$lqHH%`Lq~&OKQgN|{4Z{5)x?3@Dp|Vd`i7aKriaqc zv!+VfhYj=HQ+bAT!zF}u^Ocpzs~a6kPk_-zk@^l9b+UB$`+tF`_q4K#7fV<@AR8E~ zphS?FC)aFfCwo{{8{LbKXXC-?Y@E!coq@OBbx#Gj`Han|dK2&Q95Mxz{6e&N2bG~3 z%pV=OATeaoV~SVfYsOm6z=NI!-bu=4NcO+_TQ+v@99O05rBHrISfq7iQTQ~dFs#_v ze}Sue%oqP!d>bqRqbj+p+@uNt5}=Mb<#Jwn(7%p5nCWpTcP#4CYj}^RP1094=Ky^1 zX5np>@dA&wb5IfTO7_;WHT{ot0j$7larQ31X2lbpKc|SxEA&;GX$DwS*sIhhgsr$) zH~fv#6RX^PWzi1!YOMR4vwP2Gbaf~x1a6Mq@uHMdtE=m%*FuszalP|mJ|<@@DUzA$ z&t>dhL-RB2;kN;r=jwkXbnN}8d)C30OUUiKV2-;na=c=}W4=y`V>ta8VUl!J%+Ghp z(JW+y#=80asDllw3FHayN}j!uUn~?ThqZv_fJC`VvqaxH*|SBga=d?r)76%KA2L?Z zb=CE*Ofqd${AL<5rYKNMiMnrUm7KC1lJ}3GQ*>TAe6|^wqGK?=`i<;TDMVxg)vHh7Yec zzSJXV?knXaaGechW9?S<=C2TFc_n#G<83^9^!c~%17}Nk-9R@kzQJqmbMF7@=4)A# zff7C={toB{puyi&5zCFcf2ZxOcLKtKe%n7yb3xPD!EK{qrUcFE)wtfYq#QapI!MX? zbNSwzvlsi=i5uPZQ|5P7xIfvVcpIUR{=+s|m?!(9Go>ua>glVv4a0fzT+H9n4KDRt z7yTG&b241+Jn6pMEy2n?25;`C4nNU2CFgmuv_#>YXk=J+x=EBuugSMu{nxg*eV30$ zz|LF5R!Lem*mH*5s802$dlL6j;fl-6!Z@gm4`HB#^0R}2=sV+3R*!Je$Mv~)S>>R` z$MRikX4CVPhO6Y;MOdo(E*1LBR-V{jj*!lj&3|q% za%H^SPg`j`8!f(~-J9<>P@O?Z|8`Yj*`^nHk#3(1&v2wxqFig1r7sIFz5l~-F+VPC z?z$;N4HCaPQq~NAFo$YQ`=FAti1|iB1oEkUEJxny_53!=;SGgBI*gdC773?^KxPm&NvgfIk6|*dOSNn$0 zIN4iafw}7#@-#i2r02QnFHGHfN-{7Gbl4$d>n(v{7;Gvr!{UQM3v9j`XrFkf%yk}!1yr^PW#u}5c_JLD$&YuZ%jmN*h-0C0LGpv^e>)!^i!etEldHkpJFCFG`NiciiP6&eCN5Ri#Vv?s^r#0MqXSF23?I~e+n*QwU}>=*Z$ zbGMBf{et9 zrr6%09q2A|h*6qz0QI|443q|DXVd3i956UD>}C}^i||VHSPV2U3VIde=nGYfH;7!I z0pl=379)MqU2o68{3`3JEI*vc#N#sIe(iIlY0;sU2^_U=>V14L?@Ll_Gt)r?xlg1>3E&n*8kkaZg zmozK!`<3VG3suoC|0?A^Cx#=WfBBgkNM}%|iDfs%J7&USMY4W61}qe1;?pzXN>lw- z{&5hyj@`j2QGTh=>BesiF+Mdo4^v8&Hf4Bbp*Ev6z*qp8l0&Xu1gZ;$$}dg?Im7~w zsm5O}>}$T*E%{6G=R-;26U4>bCkXM@CkWAqCz#8$Cn%lj%_e2z&Wj&UbWQ$T{YkTH zaWT|s{{`|S7y`CSgnX;`I7gLdtKSGC_e$TruhX8gE3Tb&iIQcpN8RD*7ne^2G3NuA f#6Y9}=l>yrB-!&Z{6c1Cj9S_n`mmxK)=&QjJP%hj literal 0 HcmV?d00001 diff --git a/app/Http/Requests/Request.php b/app/Http/Requests/Request.php new file mode 100644 index 00000000..4516ab2b --- /dev/null +++ b/app/Http/Requests/Request.php @@ -0,0 +1,9 @@ + 'api/v1', + 'before' => ['ssl', 'oauth2.enabled'], + 'after' => '', + 'middleware' => ['oauth2.protected', 'rate.limit','etags']), function () { + + Route::group(array('prefix' => 'marketplace'), function () { + + Route::group(array('prefix' => 'public-clouds'), function () { + Route::get('', 'OAuth2PublicCloudApiController@getClouds'); + Route::get('/{id}', 'OAuth2PublicCloudApiController@getCloud'); + Route::get('/{id}/data-centers', 'OAuth2PublicCloudApiController@getCloudDataCenters'); + }); + + Route::group(array('prefix' => 'private-clouds'), function () { + Route::get('', 'OAuth2PrivateCloudApiController@getClouds'); + Route::get('/{id}', 'OAuth2PrivateCloudApiController@getCloud'); + Route::get('/{id}/data-centers', 'OAuth2PrivateCloudApiController@getCloudDataCenters'); + }); + + Route::group(array('prefix' => 'consultants'), function () { + Route::get('', 'OAuth2ConsultantsApiController@getConsultants'); + Route::get('/{id}', 'OAuth2ConsultantsApiController@getConsultant'); + Route::get('/{id}/offices', 'OAuth2ConsultantsApiController@getOffices'); + }); + + }); + }); \ No newline at end of file diff --git a/app/Libs/oauth2/BearerAccessTokenAuthorizationHeaderParser.php b/app/Libs/oauth2/BearerAccessTokenAuthorizationHeaderParser.php new file mode 100644 index 00000000..f214185b --- /dev/null +++ b/app/Libs/oauth2/BearerAccessTokenAuthorizationHeaderParser.php @@ -0,0 +1,77 @@ +container = $values; + } + + /** + * arrayaccess methods + * */ + public function offsetSet($offset, $value) + { + if (is_null($offset)) + { + $this->container[] = $value; + } + else + { + $this->container[$offset] = $value; + } + } + + public function offsetExists($offset) + { + return isset($this->container[$offset]); + } + + public function offsetUnset($offset) + { + unset($this->container[$offset]); + } + + public function offsetGet($offset) + { + return isset($this->container[$offset]) ? $this->container[$offset] : null; + } +} \ No newline at end of file diff --git a/app/Libs/oauth2/HttpResponse.php b/app/Libs/oauth2/HttpResponse.php new file mode 100644 index 00000000..63feea5f --- /dev/null +++ b/app/Libs/oauth2/HttpResponse.php @@ -0,0 +1,52 @@ +http_code = $http_code; + $this->content_type = $content_type; + } + + abstract public function getContent(); + + public function getHttpCode() + { + return $this->http_code; + } + + protected function setHttpCode($http_code) + { + $this->http_code = $http_code; + } + + public function getContentType() + { + return $this->content_type; + } + + abstract public function getType(); + + public function addParam($name, $value) + { + $this[$name] = $value; + } +} \ No newline at end of file diff --git a/app/Libs/oauth2/InvalidGrantTypeException.php b/app/Libs/oauth2/InvalidGrantTypeException.php new file mode 100644 index 00000000..384f9826 --- /dev/null +++ b/app/Libs/oauth2/InvalidGrantTypeException.php @@ -0,0 +1,28 @@ +container); + return $json_encoded_format; + } + + public function getType() + { + return self::OAuth2DirectResponse; + } +} \ No newline at end of file diff --git a/app/Libs/oauth2/OAuth2InvalidIntrospectionResponse.php b/app/Libs/oauth2/OAuth2InvalidIntrospectionResponse.php new file mode 100644 index 00000000..db7c8a56 --- /dev/null +++ b/app/Libs/oauth2/OAuth2InvalidIntrospectionResponse.php @@ -0,0 +1,24 @@ + self::OAuth2Protocol_ResponseType_Code, + self::OAuth2Protocol_ResponseType_Token => self::OAuth2Protocol_ResponseType_Token + ); + public static $protocol_definition = array( + self::OAuth2Protocol_ResponseType => self::OAuth2Protocol_ResponseType, + self::OAuth2Protocol_ClientId => self::OAuth2Protocol_ClientId, + self::OAuth2Protocol_RedirectUri => self::OAuth2Protocol_RedirectUri, + self::OAuth2Protocol_Scope => self::OAuth2Protocol_Scope, + self::OAuth2Protocol_State => self::OAuth2Protocol_State + ); + +} \ No newline at end of file diff --git a/app/Libs/oauth2/OAuth2ResourceServerException.php b/app/Libs/oauth2/OAuth2ResourceServerException.php new file mode 100644 index 00000000..25f0b168 --- /dev/null +++ b/app/Libs/oauth2/OAuth2ResourceServerException.php @@ -0,0 +1,58 @@ +http_code = $http_code; + $this->error = $error; + $this->error_description = $error_description; + $this->scope = $scope; + $message = "Resource Server Exception : " . sprintf('http code : %s - error : %s - error description: %s', $http_code, $error, $error_description); + parent::__construct($message, 0, null); + } + + public function getError() + { + return $this->error; + } + + public function getErrorDescription() + { + return $this->error_description; + } + + public function getScope() + { + return $this->scope; + } + + public function getHttpCode() + { + return $this->http_code; + } +} \ No newline at end of file diff --git a/app/Libs/oauth2/OAuth2Response.php b/app/Libs/oauth2/OAuth2Response.php new file mode 100644 index 00000000..0d5f7eb9 --- /dev/null +++ b/app/Libs/oauth2/OAuth2Response.php @@ -0,0 +1,18 @@ +realm = $realm; + $this->error = $error; + $this->error_description = $error_description; + $this->scope = $scope; + $this->http_error = $http_error; + } + + public function getWWWAuthenticateHeaderValue() + { + $value=sprintf('Bearer realm="%s"', $this->realm); + $value=$value.sprintf(', error="%s"', $this->error); + $value=$value.sprintf(', error_description="%s"', $this->error_description); + if (!is_null($this->scope)) + { + $value=$value.sprintf(', scope="%s"', $this->scope); + } + return $value; + } + + + public function getContent() + { + $content = array( + 'error' => $this->error, + 'error_description' => $this->error_description + ); + if (!is_null($this->scope)) + { + $content['scope'] = $this->scope; + } + return $content; + } + + public function getType() + { + return null; + } +} \ No newline at end of file diff --git a/app/Libs/utils/ConfigurationException.php b/app/Libs/utils/ConfigurationException.php new file mode 100644 index 00000000..0eeb00a7 --- /dev/null +++ b/app/Libs/utils/ConfigurationException.php @@ -0,0 +1,29 @@ +getRoutes(); + $route = $routes->match($request); + if (!is_null($route)) + { + $route = $route->getPath(); + if (strpos($route, '/') != 0) + { + $route = '/' . $route; + } + return $route; + } + } + catch (\Exception $ex) + { + Log::error($ex); + } + return false; + } + +} \ No newline at end of file diff --git a/app/Models/Marketplace/CompanyService.php b/app/Models/Marketplace/CompanyService.php new file mode 100644 index 00000000..114bf2c3 --- /dev/null +++ b/app/Models/Marketplace/CompanyService.php @@ -0,0 +1,38 @@ +ID; + } +} \ No newline at end of file diff --git a/app/Models/Marketplace/Consultant.php b/app/Models/Marketplace/Consultant.php new file mode 100644 index 00000000..ef1beaa4 --- /dev/null +++ b/app/Models/Marketplace/Consultant.php @@ -0,0 +1,28 @@ +hasMany('models\marketplace\Office', 'ConsultantID', 'ID')->get(); + } +} \ No newline at end of file diff --git a/app/Models/Marketplace/DataCenterLocation.php b/app/Models/Marketplace/DataCenterLocation.php new file mode 100644 index 00000000..6e28b528 --- /dev/null +++ b/app/Models/Marketplace/DataCenterLocation.php @@ -0,0 +1,37 @@ +belongsTo('models\marketplace\DataCenterRegion', 'DataCenterRegionID'); + } +} \ No newline at end of file diff --git a/app/Models/Marketplace/DataCenterRegion.php b/app/Models/Marketplace/DataCenterRegion.php new file mode 100644 index 00000000..dbb74aa0 --- /dev/null +++ b/app/Models/Marketplace/DataCenterRegion.php @@ -0,0 +1,38 @@ +hasMany('models\marketplace\DataCenterLocation', 'DataCenterRegionID', 'ID')->get(); + } + +} \ No newline at end of file diff --git a/app/Models/Marketplace/ICloudService.php b/app/Models/Marketplace/ICloudService.php new file mode 100644 index 00000000..42931418 --- /dev/null +++ b/app/Models/Marketplace/ICloudService.php @@ -0,0 +1,26 @@ +belongsTo('models\marketplace\Consultant', 'ConsultantID'); + } +} \ No newline at end of file diff --git a/app/Models/Marketplace/PrivateCloudService.php b/app/Models/Marketplace/PrivateCloudService.php new file mode 100644 index 00000000..50865f11 --- /dev/null +++ b/app/Models/Marketplace/PrivateCloudService.php @@ -0,0 +1,31 @@ +hasMany('models\marketplace\DataCenterRegion', 'CloudServiceID', 'ID')->get(); + } + +} \ No newline at end of file diff --git a/app/Models/Marketplace/PublicCloudService.php b/app/Models/Marketplace/PublicCloudService.php new file mode 100644 index 00000000..efb0543c --- /dev/null +++ b/app/Models/Marketplace/PublicCloudService.php @@ -0,0 +1,30 @@ +hasMany('models\marketplace\DataCenterRegion', 'CloudServiceID', 'ID')->get(); + } +} \ No newline at end of file diff --git a/app/Models/ResourceServer/AccessTokenService.php b/app/Models/ResourceServer/AccessTokenService.php new file mode 100644 index 00000000..81131357 --- /dev/null +++ b/app/Models/ResourceServer/AccessTokenService.php @@ -0,0 +1,154 @@ +cache_service = $cache_service; + } + + /** + * @param string $token_value + * @return AccessToken + * @throws \Exception + */ + public function get($token_value) + { + $token = null; + + + $token_info = $this->cache_service->getHash(md5($token_value), array( + 'access_token', + 'scope', + 'client_id', + 'audience', + 'user_id', + 'expires_in', + 'application_type', + 'allowed_return_uris', + 'allowed_origins')); + + if (count($token_info) === 0) + { + $token_info = $this->makeRemoteCall($token_value); + $this->cache_service->storeHash(md5($token_value), $token_info, (int)$token_info['expires_in']); + } + else + { + $token_info['expires_in'] = $this->cache_service->ttl(md5($token_value)); + } + + $token = AccessToken::createFromParams( + $token_info['access_token'], + $token_info['scope'], + $token_info['client_id'], + $token_info['audience'], + $token_info['user_id'], + (int)$token_info['expires_in'], + $token_info['application_type'], + isset($token_info['allowed_return_uris']) ? $token_info['allowed_return_uris'] : null, + isset($token_info['allowed_origins']) ? $token_info['allowed_origins'] : null + ); + + return $token; + } + + /** + * @param $token_value + * @return mixed + * @throws ConfigurationException + * @throws InvalidGrantTypeException + * @throws OAuth2InvalidIntrospectionResponse + */ + private function makeRemoteCall($token_value) + { + + try + { + $client = new Client([ + 'defaults' => [ + 'timeout' => Config::get('curl.timeout', 60), + 'allow_redirects' => Config::get('curl.allow_redirects', false), + 'verify' => Config::get('curl.verify_ssl_cert', true) + ] + ]); + + $client_id = Config::get('app.openstackid_client_id', ''); + $client_secret = Config::get('app.openstackid_client_secret', ''); + $auth_server_url = Config::get('app.openstackid_base_url', ''); + + if (empty($client_id)) + { + throw new ConfigurationException('app.openstackid_client_id param is missing!'); + } + + if (empty($client_secret)) + { + throw new ConfigurationException('app.openstackid_client_secret param is missing!'); + } + + if (empty($auth_server_url)) + { + throw new ConfigurationException('app.openstackid_base_url param is missing!'); + } + + $response = $client->post( + $auth_server_url . '/oauth2/token/introspection', + [ + 'query' => ['token' => $token_value], + 'headers' => ['Authorization' => " Basic " . base64_encode($client_id . ':' . $client_secret)] + ] + ); + + $token_info = $response->json(); + + return $token_info; + + } + catch (RequestException $ex) + { + $response = $ex->getResponse(); + $body = $response->json(); + $code = $response->getStatusCode(); + if ($code === 400) + { + throw new InvalidGrantTypeException($body['error']); + } + throw new OAuth2InvalidIntrospectionResponse(sprintf('http code %s', $ex->getCode())); + } + } +} \ No newline at end of file diff --git a/app/Models/ResourceServer/Api.php b/app/Models/ResourceServer/Api.php new file mode 100644 index 00000000..2b6092ba --- /dev/null +++ b/app/Models/ResourceServer/Api.php @@ -0,0 +1,100 @@ +hasMany('models\resource_server\ApiScope', 'api_id'); + } + + /** + * @return IApiEndpoint[] + */ + public function endpoints() + { + return $this->hasMany('models\resource_server\ApiEndpoint', 'api_id'); + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * @return string + */ + public function getScope() + { + $scope = ''; + foreach ($this->scopes()->get() as $s) + { + if (!$s->active) + { + continue; + } + $scope = $scope .$s->name.' '; + } + $scope = trim($scope); + return $scope; + } + + /** + * @return bool + */ + public function isActive() + { + return $this->active; + } + + public function setName($name) + { + $this->name = $name; + } + + public function setDescription($description) + { + $this->description = $description; + } + + public function setStatus($active) + { + $this->active = $active; + } +} \ No newline at end of file diff --git a/app/Models/ResourceServer/ApiEndpoint.php b/app/Models/ResourceServer/ApiEndpoint.php new file mode 100644 index 00000000..9706f9a3 --- /dev/null +++ b/app/Models/ResourceServer/ApiEndpoint.php @@ -0,0 +1,134 @@ +belongsTo('models\resource_server\Api', 'api_id'); + } + + /** + * @return IApiScope[] + */ + public function scopes() + { + return $this->belongsToMany('models\resource_server\ApiScope', 'endpoint_api_scopes', 'api_endpoint_id', 'scope_id'); + } + + public function getRoute() + { + return $this->route; + } + + public function getHttpMethod() + { + return $this->http_method; + } + + public function setRoute($route) + { + $this->route = $route; + } + + public function setHttpMethod($http_method) + { + $this->http_method = $http_method; + } + + /** + * @return string + */ + public function getScope() + { + $scope = ''; + foreach ($this->scopes()->get() as $s) + { + if (!$s->active) + { + continue; + } + $scope = $scope .$s->name.' '; + } + $scope = trim($scope); + return $scope; + } + + public function isActive() + { + return $this->active; + } + + /** + * @param bool $active + */ + public function setStatus($active) + { + $this->active = $active; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + */ + public function setName($name) + { + $this->name= $name; + } + + /** + * @return bool + */ + public function supportCORS() + { + return $this->allow_cors; + } + + /** + * @return bool + */ + public function supportCredentials() + { + return (bool)$this->allow_credentials; + } +} \ No newline at end of file diff --git a/app/Models/ResourceServer/ApiScope.php b/app/Models/ResourceServer/ApiScope.php new file mode 100644 index 00000000..bf91ea27 --- /dev/null +++ b/app/Models/ResourceServer/ApiScope.php @@ -0,0 +1,57 @@ +belongsTo('models\resource_server\Api', 'api_id'); + } + + public function getShortDescription() + { + return $this->short_description; + } + + public function getName() + { + return $this->name; + } + + public function getDescription() + { + return $this->description; + } + + public function isActive() + { + return $this->active; + } +} \ No newline at end of file diff --git a/app/Models/ResourceServer/IAccessTokenService.php b/app/Models/ResourceServer/IAccessTokenService.php new file mode 100644 index 00000000..4befc6ca --- /dev/null +++ b/app/Models/ResourceServer/IAccessTokenService.php @@ -0,0 +1,30 @@ +where($filter['name'], $filter['op'], $filter['value']); + } + return $query; + } + + public function __construct($attributes = array()) + { + parent::__construct($attributes); + $this->class = new ReflectionClass(get_class($this)); + if ($this->useSti()) + { + $this->setAttribute($this->stiClassField, $this->class->getName()); + } + } + + private function useSti() + { + return ($this->stiClassField && $this->stiBaseClass); + } + + public function newQuery($excludeDeleted = true) + { + $builder = parent::newQuery($excludeDeleted); + // If I am using STI, and I am not the base class, + // then filter on the class name. + if ($this->useSti() && get_class(new $this->stiBaseClass) !== get_class($this)) + { + $builder->where($this->stiClassField, "=", $this->class->getShortName()); + } + return $builder; + } + + public function newFromBuilder($attributes = array(), $connection = null) + { + if ($this->useSti() && $attributes->{$this->stiClassField}) + { + $class = $this->class->getName(); + $instance = new $class; + $instance->exists = true; + $instance->setRawAttributes((array) $attributes, true); + return $instance; + } + else + { + return parent::newFromBuilder($attributes, $connection); + } + } +} \ No newline at end of file diff --git a/app/Models/Utils/IBaseRepository.php b/app/Models/Utils/IBaseRepository.php new file mode 100644 index 00000000..9943057b --- /dev/null +++ b/app/Models/Utils/IBaseRepository.php @@ -0,0 +1,24 @@ +value = $value; + $instance->scope = $scope; + $instance->client_id = $client_id; + $instance->user_id = $user_id; + $instance->auth_code = null; + $instance->audience = $audience; + $instance->refresh_token = null; + $instance->lifetime = intval($lifetime); + $instance->is_hashed = false; + $instance->allowed_return_uris = $allowed_return_uris; + $instance->application_type = $application_type; + $instance->allowed_origins = $allowed_origins; + return $instance; + } + + public function getAuthCode() + { + return $this->auth_code; + } + + public function getRefreshToken() + { + return $this->refresh_token; + } + + public function getApplicationType() + { + return $this->application_type; + } + + public function getAllowedOrigins() + { + return $this->allowed_origins; + } + + public function getAllowedReturnUris() + { + return $this->allowed_return_uris; + } + + public function toJSON() + { + return '{}'; + } + + public function fromJSON($json) + { + + } +} \ No newline at end of file diff --git a/app/Models/oauth2/IResourceServerContext.php b/app/Models/oauth2/IResourceServerContext.php new file mode 100644 index 00000000..69672dde --- /dev/null +++ b/app/Models/oauth2/IResourceServerContext.php @@ -0,0 +1,58 @@ +auth_context['scope'])? explode(' ', $this->auth_context['scope']):array(); + } + + /** + * @return null|string + */ + public function getCurrentAccessToken() + { + return isset($this->auth_context['access_token'])?$this->auth_context['access_token']:null; + } + + + /** + * @return null|string + */ + public function getCurrentAccessTokenLifetime() + { + return isset($this->auth_context['expires_in'])?$this->auth_context['expires_in']:null; + } + + /** + * @return null + */ + public function getCurrentClientId() + { + return isset($this->auth_context['client_id'])?$this->auth_context['client_id']:null; + } + + /** + * @return null|int + */ + public function getCurrentUserId() + { + return isset($this->auth_context['user_id'])?intval($this->auth_context['user_id']):null; + } + + /** + * @param array $auth_context + * @return void + */ + public function setAuthorizationContext(array $auth_context) + { + $this->auth_context = $auth_context; + } +} \ No newline at end of file diff --git a/app/Models/oauth2/Token.php b/app/Models/oauth2/Token.php new file mode 100644 index 00000000..5b233f95 --- /dev/null +++ b/app/Models/oauth2/Token.php @@ -0,0 +1,90 @@ +len = $len; + $this->is_hashed = false; + } + + public function getValue() + { + return $this->value; + } + + public function getLifetime() + { + return intval($this->lifetime); + } + + public function getScope() + { + return $this->scope; + } + + public function getClientId() + { + return $this->client_id; + } + + public function getAudience() + { + return $this->audience; + } + + public function getFromIp() + { + return $this->from_ip; + } + + public function getUserId() + { + return $this->user_id; + } + + public function isHashed() + { + return $this->is_hashed; + } + + public abstract function toJSON(); + + + public abstract function fromJSON($json); +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 00000000..1c786225 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,47 @@ +pushHandler($handler); + } + + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + App::singleton('models\\oauth2\\IResourceServerContext', 'models\\oauth2\\ResourceServerContext'); + App::singleton('models\resource_server\\IAccessTokenService', 'models\resource_server\\AccessTokenService'); + App::singleton('models\\resource_server\\IApi', 'models\\resource_server\\Api'); + App::singleton('models\\resource_server\\IApiEndpoint', 'models\\resource_server\\ApiEndpoint'); + App::singleton('models\\resource_server\\IApiScope', 'models\\resource_server\\ApiScope'); + } + +} \ No newline at end of file diff --git a/app/Providers/BusServiceProvider.php b/app/Providers/BusServiceProvider.php new file mode 100644 index 00000000..17778ab6 --- /dev/null +++ b/app/Providers/BusServiceProvider.php @@ -0,0 +1,34 @@ +mapUsing(function($command) + { + return Dispatcher::simpleMapping( + $command, 'App\Commands', 'App\Handlers\Commands' + ); + }); + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + // + } + +} \ No newline at end of file diff --git a/app/Providers/ConfigServiceProvider.php b/app/Providers/ConfigServiceProvider.php new file mode 100644 index 00000000..dc9a1269 --- /dev/null +++ b/app/Providers/ConfigServiceProvider.php @@ -0,0 +1,23 @@ + [ + 'EventListener', + ], + ]; + + /** + * Register any other events for your application. + * + * @param \Illuminate\Contracts\Events\Dispatcher $events + * @return void + */ + public function boot(DispatcherContract $events) + { + parent::boot($events); + + // + } + +} \ No newline at end of file diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100644 index 00000000..40477cbe --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,62 @@ +group(['namespace' => $this->namespace], function ($router) { + require app_path('Http/routes.php'); + }); + } + +} \ No newline at end of file diff --git a/app/Repositories/RepositoriesProvider.php b/app/Repositories/RepositoriesProvider.php new file mode 100644 index 00000000..f73594b8 --- /dev/null +++ b/app/Repositories/RepositoriesProvider.php @@ -0,0 +1,49 @@ +entity->find($id); + } + + /** + * @param int $page + * @param int $per_page + * @param string $status + * @param string $order_by + * @param string $order_dir + * @return IEntity[] + */ + public function getAll( + $page = 1, + $per_page = 1000, + $status = ICompanyServiceRepository::Status_All, + $order_by = ICompanyServiceRepository::Order_date, + $order_dir = 'asc' + ) { + $fields = array('*'); + $filters = array(); + switch($status) + { + case ICompanyServiceRepository::Status_active: + array_push( + $filters, + array( + 'name'=>'Active', + 'op' => '=', + 'value'=> true + ) + ); + break; + case ICompanyServiceRepository::Status_non_active: + array_push( + $filters, + array( + 'name'=>'Active', + 'op' => '=', + 'value'=> false + ) + ); + break; + } + + $query = $this->entity->Filter($filters); + + switch($order_by) + { + case ICompanyServiceRepository::Order_date: + $query = $query->orderBy('Created', $order_dir); + break; + case ICompanyServiceRepository::Order_name: + $query = $query->orderBy('Name', $order_dir); + break; + } + + return $query->paginate($per_page, $fields)->toArray(); + } +} \ No newline at end of file diff --git a/app/Repositories/marketplace/EloquentConsultantRepository.php b/app/Repositories/marketplace/EloquentConsultantRepository.php new file mode 100644 index 00000000..8f3c188c --- /dev/null +++ b/app/Repositories/marketplace/EloquentConsultantRepository.php @@ -0,0 +1,32 @@ +entity = $consultant; + } +} \ No newline at end of file diff --git a/app/Repositories/marketplace/EloquentPrivateCloudServiceRepository.php b/app/Repositories/marketplace/EloquentPrivateCloudServiceRepository.php new file mode 100644 index 00000000..3d4b2937 --- /dev/null +++ b/app/Repositories/marketplace/EloquentPrivateCloudServiceRepository.php @@ -0,0 +1,35 @@ +entity = $private_cloud; + } + +} \ No newline at end of file diff --git a/app/Repositories/marketplace/EloquentPublicCloudServiceRepository.php b/app/Repositories/marketplace/EloquentPublicCloudServiceRepository.php new file mode 100644 index 00000000..9620d7eb --- /dev/null +++ b/app/Repositories/marketplace/EloquentPublicCloudServiceRepository.php @@ -0,0 +1,34 @@ +entity = $public_cloud; + } +} \ No newline at end of file diff --git a/app/Repositories/resource_server/EloquentApiEndpointRepository.php b/app/Repositories/resource_server/EloquentApiEndpointRepository.php new file mode 100644 index 00000000..9cff7151 --- /dev/null +++ b/app/Repositories/resource_server/EloquentApiEndpointRepository.php @@ -0,0 +1,67 @@ +entity = $endpoint; + } + /** + * @param string $url + * @param string $http_method + * @return IApiEndpoint + */ + public function getApiEndpointByUrlAndMethod($url, $http_method) + { + return $this->entity->Filter(array( array( + 'name'=>'route', + 'op' => '=', + 'value'=> $url + ), array( + 'name'=>'http_method', + 'op' => '=', + 'value'=> $http_method + )))->firstOrFail(); + } + + /** + * @param int $id + * @return IEntity + */ + public function getById($id) + { + return $this->entity->find($id); + } +} \ No newline at end of file diff --git a/app/Services/ServicesProvider.php b/app/Services/ServicesProvider.php new file mode 100644 index 00000000..0e2da689 --- /dev/null +++ b/app/Services/ServicesProvider.php @@ -0,0 +1,34 @@ +redis = Redis::connection(); + } + + + public function boot() + { + if (is_null($this->redis)) + { + $this->redis = Redis::connection(); + } + } + /** + * @param $key + * @return mixed + */ + public function delete($key) + { + $res = 0; + if ($this->redis->exists($key)) + { + $res = $this->redis->del($key); + } + return $res; + } + + public function deleteArray(array $keys) + { + if (count($keys)>0) + { + $this->redis->del($keys); + } + } + + /** + * @param $key + * @return bool + */ + public function exists($key) + { + $res = $this->redis->exists($key); + return $res>0; + } + + /** + * @param $name + * @param array $values + * @return mixed + */ + public function getHash($name, array $values) + { + $res = array(); + if ($this->redis->exists($name)) + { + $cache_values = $this->redis->hmget($name, $values); + for ($i=0; $iredis->exists($name)) + { + $this->redis->hmset($name, $values); + $res = true; + //sets expiration time + if ($ttl>0) + { + $this->redis->expire($name, $ttl); + } + } + return $res; + } + + public function incCounter($counter_name, $ttl = 0) + { + if ($this->redis->setnx($counter_name, 1)) + { + $this->redis->expire($counter_name, $ttl); + return 1; + } + else + { + return (int)$this->redis->incr($counter_name); + } + } + + public function incCounterIfExists($counter_name) + { + $res = false; + if ($this->redis->exists($counter_name)) + { + $this->redis->incr($counter_name); + $res = true; + } + return $res; + } + + public function addMemberSet($set_name, $member) + { + return $this->redis->sadd($set_name, $member); + } + + public function deleteMemberSet($set_name, $member) + { + return $this->redis->srem($set_name, $member); + } + + public function getSet($set_name) + { + return $this->redis->smembers($set_name); + } + + public function getSingleValue($key) + { + return $this->redis->get($key); + } + + public function setSingleValue($key, $value, $ttl = 0) + { + if ($ttl>0) + { + return $this->redis->setex($key, $ttl, $value); + } + else + { + return $this->redis->set($key, $value); + } + } + + public function addSingleValue($key, $value, $ttl = 0) + { + $res = $this->redis->setnx($key, $value); + if ($res && $ttl>0) + { + $this->redis->expire($key, $ttl); + } + return $res; + } + + public function setKeyExpiration($key, $ttl) + { + $this->redis->expire($key, intval($ttl)); + } + + /**Returns the remaining time to live of a key that has a timeout. + * @param string $key + * @return int + */ + public function ttl($key) + { + return (int)$this->redis->ttl($key); + } +} \ No newline at end of file diff --git a/artisan b/artisan new file mode 100755 index 00000000..eb5e2bb6 --- /dev/null +++ b/artisan @@ -0,0 +1,51 @@ +#!/usr/bin/env php +make('Illuminate\Contracts\Console\Kernel'); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +/* +|-------------------------------------------------------------------------- +| Shutdown The Application +|-------------------------------------------------------------------------- +| +| Once Artisan has finished running. We will fire off the shutdown events +| so that any final work may be done by the application before we shut +| down the process. This is the last thing to happen to the request. +| +*/ + +$kernel->terminate($input, $status); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 00000000..f9e94b4b --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,57 @@ +singleton( + 'Illuminate\Contracts\Http\Kernel', + 'App\Http\Kernel' +); + +$app->singleton( + 'Illuminate\Contracts\Console\Kernel', + 'App\Console\Kernel' +); + +$app->singleton( + 'Illuminate\Contracts\Debug\ExceptionHandler', + 'App\Exceptions\Handler' +); + + +/* +|-------------------------------------------------------------------------- +| Return The Application +|-------------------------------------------------------------------------- +| +| This script returns the application instance. The instance is given to +| the calling script so we can separate the building of the instances +| from the actual running of the application and sending responses. +| +*/ + + +return $app; \ No newline at end of file diff --git a/bootstrap/autoload.php b/bootstrap/autoload.php new file mode 100644 index 00000000..17718f5d --- /dev/null +++ b/bootstrap/autoload.php @@ -0,0 +1,35 @@ +=5.4.0", + "guzzlehttp/guzzle": "5.2.0" + }, + "require-dev": { + "phpunit/phpunit": "4.6.6", + "phpspec/phpspec": "~2.1", + "mockery/mockery": "0.9.4", + "squizlabs/php_codesniffer": "2.*", + "pragmarx/laravelcs": "*", + "glenscott/url-normalizer" : "1.4.0" + }, + "autoload": { + "classmap": [ + "database", + "app" + ], + "psr-4": { + "App\\": "app/" + } + }, + "autoload-dev": { + "classmap": [ + "tests" + ] + }, + "scripts": { + "post-install-cmd": [ + "php artisan clear-compiled", + "php artisan optimize" + ], + "post-update-cmd": [ + "php artisan clear-compiled", + "php artisan optimize" + ], + "post-create-project-cmd": [ + "php -r \"copy('.env.example', '.env');\"", + "php artisan key:generate" + ] + }, + "config": { + "preferred-install": "dist" + } +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 00000000..876c189f --- /dev/null +++ b/config/app.php @@ -0,0 +1,202 @@ + env('APP_OAUTH_2_0_CLIENT_ID'), + 'openstackid_client_secret' => env('APP_OAUTH_2_0_CLIENT_SECRET'), + 'openstackid_base_url' => env('APP_OAUTH_2_0_AUTH_SERVER_BASE_URL'), + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | your application so that it is used when running Artisan tasks. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. We have gone + | ahead and set this to a sensible default for you out of the box. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by the translation service provider. You are free to set this value + | to any of the locales which will be supported by the application. + | + */ + + 'locale' => 'en', + + /* + |-------------------------------------------------------------------------- + | Application Fallback Locale + |-------------------------------------------------------------------------- + | + | The fallback locale determines the locale to use when the current one + | is not available. You may change the value to correspond to any of + | the language folders that are provided through your application. + | + */ + + 'fallback_locale' => 'en', + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is used by the Illuminate encrypter service and should be set + | to a random, 32 character string, otherwise these encrypted strings + | will not be safe. Please do this before deploying an application! + | + */ + + 'key' => env('APP_KEY', 'SomeRandomString'), + + 'cipher' => MCRYPT_RIJNDAEL_128, + + /* + |-------------------------------------------------------------------------- + | Logging Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure the log settings for your application. Out of + | the box, Laravel uses the Monolog PHP logging library. This gives + | you a variety of powerful log handlers / formatters to utilize. + | + | Available Settings: "single", "daily", "syslog", "errorlog" + | + */ + + 'log' => 'daily', + + /* + |-------------------------------------------------------------------------- + | Autoloaded Service Providers + |-------------------------------------------------------------------------- + | + | The service providers listed here will be automatically loaded on the + | request to your application. Feel free to add your own services to + | this array to grant expanded functionality to your applications. + | + */ + + 'providers' => [ + + /* + * Laravel Framework Service Providers... + */ + 'Illuminate\Foundation\Providers\ArtisanServiceProvider', + 'Illuminate\Auth\AuthServiceProvider', + 'Illuminate\Bus\BusServiceProvider', + 'Illuminate\Cache\CacheServiceProvider', + 'Illuminate\Foundation\Providers\ConsoleSupportServiceProvider', + 'Illuminate\Routing\ControllerServiceProvider', + 'Illuminate\Cookie\CookieServiceProvider', + 'Illuminate\Database\DatabaseServiceProvider', + 'Illuminate\Encryption\EncryptionServiceProvider', + 'Illuminate\Filesystem\FilesystemServiceProvider', + 'Illuminate\Foundation\Providers\FoundationServiceProvider', + 'Illuminate\Hashing\HashServiceProvider', + 'Illuminate\Mail\MailServiceProvider', + 'Illuminate\Pagination\PaginationServiceProvider', + 'Illuminate\Pipeline\PipelineServiceProvider', + 'Illuminate\Queue\QueueServiceProvider', + 'Illuminate\Redis\RedisServiceProvider', + 'Illuminate\Auth\Passwords\PasswordResetServiceProvider', + 'Illuminate\Session\SessionServiceProvider', + 'Illuminate\Translation\TranslationServiceProvider', + 'Illuminate\Validation\ValidationServiceProvider', + 'Illuminate\View\ViewServiceProvider', + + /* + * Application Service Providers... + */ + 'App\Providers\AppServiceProvider', + 'App\Providers\BusServiceProvider', + 'App\Providers\ConfigServiceProvider', + 'App\Providers\EventServiceProvider', + 'App\Providers\RouteServiceProvider', + 'repositories\RepositoriesProvider', + 'services\ServicesProvider', + ], + + /* + |-------------------------------------------------------------------------- + | Class Aliases + |-------------------------------------------------------------------------- + | + | This array of class aliases will be registered when this application + | is started. However, feel free to register as many as you wish as + | the aliases are "lazy" loaded so they don't hinder performance. + | + */ + + 'aliases' => [ + + 'App' => 'Illuminate\Support\Facades\App', + 'Artisan' => 'Illuminate\Support\Facades\Artisan', + 'Auth' => 'Illuminate\Support\Facades\Auth', + 'Blade' => 'Illuminate\Support\Facades\Blade', + 'Bus' => 'Illuminate\Support\Facades\Bus', + 'Cache' => 'Illuminate\Support\Facades\Cache', + 'Config' => 'Illuminate\Support\Facades\Config', + 'Cookie' => 'Illuminate\Support\Facades\Cookie', + 'Crypt' => 'Illuminate\Support\Facades\Crypt', + 'DB' => 'Illuminate\Support\Facades\DB', + 'Eloquent' => 'Illuminate\Database\Eloquent\Model', + 'Event' => 'Illuminate\Support\Facades\Event', + 'File' => 'Illuminate\Support\Facades\File', + 'Hash' => 'Illuminate\Support\Facades\Hash', + 'Input' => 'Illuminate\Support\Facades\Input', + 'Inspiring' => 'Illuminate\Foundation\Inspiring', + 'Lang' => 'Illuminate\Support\Facades\Lang', + 'Log' => 'Illuminate\Support\Facades\Log', + 'Mail' => 'Illuminate\Support\Facades\Mail', + 'Password' => 'Illuminate\Support\Facades\Password', + 'Queue' => 'Illuminate\Support\Facades\Queue', + 'Redirect' => 'Illuminate\Support\Facades\Redirect', + 'Redis' => 'Illuminate\Support\Facades\Redis', + 'Request' => 'Illuminate\Support\Facades\Request', + 'Response' => 'Illuminate\Support\Facades\Response', + 'Route' => 'Illuminate\Support\Facades\Route', + 'Schema' => 'Illuminate\Support\Facades\Schema', + 'Session' => 'Illuminate\Support\Facades\Session', + 'Storage' => 'Illuminate\Support\Facades\Storage', + 'URL' => 'Illuminate\Support\Facades\URL', + 'Validator' => 'Illuminate\Support\Facades\Validator', + 'View' => 'Illuminate\Support\Facades\View', + + ], + +]; \ No newline at end of file diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 00000000..ee6316fd --- /dev/null +++ b/config/auth.php @@ -0,0 +1,67 @@ + 'eloquent', + + /* + |-------------------------------------------------------------------------- + | Authentication Model + |-------------------------------------------------------------------------- + | + | When using the "Eloquent" authentication driver, we need to know which + | Eloquent model should be used to retrieve your users. Of course, it + | is often just the "User" model but you may use whatever you like. + | + */ + + 'model' => 'App\User', + + /* + |-------------------------------------------------------------------------- + | Authentication Table + |-------------------------------------------------------------------------- + | + | When using the "Database" authentication driver, we need to know which + | table should be used to retrieve your users. We have chosen a basic + | default value but you may easily change it to any table you like. + | + */ + + 'table' => 'users', + + /* + |-------------------------------------------------------------------------- + | Password Reset Settings + |-------------------------------------------------------------------------- + | + | Here you may set the options for resetting passwords including the view + | that is your password reset e-mail. You can also set the name of the + | table that maintains all of the reset tokens for your application. + | + | The expire time is the number of minutes that the reset token should be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + */ + + 'password' => [ + 'email' => 'emails.password', + 'table' => 'password_resets', + 'expire' => 60, + ], + +]; \ No newline at end of file diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 00000000..f9f5b576 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,50 @@ + env('CACHE_DRIVER', 'redis'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + */ + + 'stores' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing a RAM based store such as APC or Memcached, there might + | be other applications utilizing the same cache. So, we'll specify a + | value to get prefixed to all our keys so we can avoid collisions. + | + */ + + 'prefix' => 'laravel', + +]; \ No newline at end of file diff --git a/config/compile.php b/config/compile.php new file mode 100644 index 00000000..fc20d82c --- /dev/null +++ b/config/compile.php @@ -0,0 +1,41 @@ + [ + + realpath(__DIR__.'/../app/Providers/AppServiceProvider.php'), + realpath(__DIR__.'/../app/Providers/BusServiceProvider.php'), + realpath(__DIR__.'/../app/Providers/ConfigServiceProvider.php'), + realpath(__DIR__.'/../app/Providers/EventServiceProvider.php'), + realpath(__DIR__.'/../app/Providers/RouteServiceProvider.php'), + + ], + + /* + |-------------------------------------------------------------------------- + | Compiled File Providers + |-------------------------------------------------------------------------- + | + | Here you may list service providers which define a "compiles" function + | that returns additional files that should be compiled, providing an + | easy way to get common files from any packages you are utilizing. + | + */ + + 'providers' => [ + // + ], + +]; \ No newline at end of file diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 00000000..89f90ea8 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,30 @@ + env('CORS_ALLOWED_HEADERS', 'origin, content-type, accept, authorization, x-requested-with'), + /** + * http://www.w3.org/TR/cors/#access-control-allow-methods-response-header + */ + 'allowed_methods' => env('CORS_ALLOWED_METHODS', 'GET, POST, OPTIONS, PUT, DELETE'), + 'use_pre_flight_caching' => env('CORS_USE_PRE_FLIGHT_CACHING', true), + /** + * http://www.w3.org/TR/cors/#access-control-max-age-response-header + */ + 'max_age' => env('CORS_MAX_AGE', 3200), + 'exposed_headers' => env('CORS_EXPOSED_HEADERS', ''), +); \ No newline at end of file diff --git a/config/curl.php b/config/curl.php new file mode 100644 index 00000000..3e1be332 --- /dev/null +++ b/config/curl.php @@ -0,0 +1,19 @@ + env('CURL_TIMEOUT', 60), + 'allow_redirects' => env('CURL_ALLOWS_REDIRECT', false), + 'verify_ssl_cert' => env('CURL_VERIFY_SSL_CERT', true), +); \ No newline at end of file diff --git a/config/database.php b/config/database.php new file mode 100644 index 00000000..299d15a5 --- /dev/null +++ b/config/database.php @@ -0,0 +1,109 @@ + PDO::FETCH_CLASS, + + /* + |-------------------------------------------------------------------------- + | Default Database Connection Name + |-------------------------------------------------------------------------- + | + | Here you may specify which of the database connections below you wish + | to use as your default connection for all database work. Of course + | you may use many connections at once using the Database library. + | + */ + + 'default' => 'openstackid_resources', + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Here are each of the database connections setup for your application. + | Of course, examples of configuring each database platform that is + | supported by Laravel is shown below to make development simple. + | + | + | All database work in Laravel is done through the PHP PDO facilities + | so make sure you have the driver for your particular database of + | choice installed on your machine before you begin development. + | + */ + + 'connections' => [ + //primary DB + 'openstackid_resources' => array( + 'driver' => 'mysql', + 'host' => env('DB_HOST'), + 'database' => env('DB_DATABASE'), + 'username' => env('DB_USERNAME'), + 'password' => env('DB_PASSWORD'), + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + ), + //secondary DB (SS OS) + 'ss' => array( + 'driver' => 'mysql', + 'host' => env('SS_DB_HOST'), + 'database' => env('SS_DATABASE'), + 'username' => env('SS_DB_USERNAME'), + 'password' => env('SS_DB_PASSWORD'), + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + ), + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run in the database. + | + */ + + 'migrations' => 'migrations', + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer set of commands than a typical key-value systems + | such as APC or Memcached. Laravel makes it easy to dig right in. + | + */ + + 'redis' => [ + + 'cluster' => false, + + 'default' => [ + 'host' => env('REDIS_HOST'), + 'port' => env('REDIS_PORT'), + 'database' => env('REDIS_DB'), + 'password' => env('REDIS_PASSWORD'), + ], + + ], + +]; \ No newline at end of file diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 00000000..300b790b --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,71 @@ + 'local', + + /* + |-------------------------------------------------------------------------- + | Default Cloud Filesystem Disk + |-------------------------------------------------------------------------- + | + | Many applications store files both locally and in the cloud. For this + | reason, you may specify a default "cloud" driver here. This driver + | will be bound as the Cloud disk implementation in the container. + | + */ + + 'cloud' => 's3', + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Here you may configure as many filesystem "disks" as you wish, and you + | may even configure multiple disks of the same driver. Defaults have + | been setup for each driver as an example of the required options. + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path().'/app', + ], + + 's3' => [ + 'driver' => 's3', + 'key' => 'your-key', + 'secret' => 'your-secret', + 'region' => 'your-region', + 'bucket' => 'your-bucket', + ], + + 'rackspace' => [ + 'driver' => 'rackspace', + 'username' => 'your-username', + 'key' => 'your-key', + 'container' => 'your-container', + 'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/', + 'region' => 'IAD', + 'url_type' => 'publicURL' + ], + + ], + +]; \ No newline at end of file diff --git a/config/log.php b/config/log.php new file mode 100644 index 00000000..130fafaf --- /dev/null +++ b/config/log.php @@ -0,0 +1,10 @@ + env('LOG_EMAIL_TO'), + //The sender of the mail + 'from_email' => env('LOG_EMAIL_FROM'), + ); \ No newline at end of file diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 00000000..5905b1dd --- /dev/null +++ b/config/mail.php @@ -0,0 +1,124 @@ + env('MAIL_DRIVER', 'smtp'), + + /* + |-------------------------------------------------------------------------- + | SMTP Host Address + |-------------------------------------------------------------------------- + | + | Here you may provide the host address of the SMTP server used by your + | applications. A default option is provided that is compatible with + | the Mailgun mail service which will provide reliable deliveries. + | + */ + + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + + /* + |-------------------------------------------------------------------------- + | SMTP Host Port + |-------------------------------------------------------------------------- + | + | This is the SMTP port used by your application to deliver e-mails to + | users of the application. Like the host we have set this value to + | stay compatible with the Mailgun e-mail application by default. + | + */ + + 'port' => env('MAIL_PORT', 587), + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all e-mails sent by your application to be sent from + | the same address. Here, you may specify a name and address that is + | used globally for all e-mails that are sent by your application. + | + */ + + 'from' => ['address' => null, 'name' => null], + + /* + |-------------------------------------------------------------------------- + | E-Mail Encryption Protocol + |-------------------------------------------------------------------------- + | + | Here you may specify the encryption protocol that should be used when + | the application send e-mail messages. A sensible default using the + | transport layer security protocol should provide great security. + | + */ + + 'encryption' => 'tls', + + /* + |-------------------------------------------------------------------------- + | SMTP Server Username + |-------------------------------------------------------------------------- + | + | If your SMTP server requires a username for authentication, you should + | set it here. This will get used to authenticate with your server on + | connection. You may also set the "password" value below this one. + | + */ + + 'username' => env('MAIL_USERNAME'), + + /* + |-------------------------------------------------------------------------- + | SMTP Server Password + |-------------------------------------------------------------------------- + | + | Here you may set the password required by your SMTP server to send out + | messages from your application. This will be given to the server on + | connection so that the application will be able to send messages. + | + */ + + 'password' => env('MAIL_PASSWORD'), + + /* + |-------------------------------------------------------------------------- + | Sendmail System Path + |-------------------------------------------------------------------------- + | + | When using the "sendmail" driver to send e-mails, we will need to know + | the path to where Sendmail lives on this server. A default path has + | been provided here, which will work well on most of your systems. + | + */ + + 'sendmail' => '/usr/sbin/sendmail -bs', + + /* + |-------------------------------------------------------------------------- + | Mail "Pretend" + |-------------------------------------------------------------------------- + | + | When this option is enabled, e-mail will not actually be sent over the + | web and will instead be written to your application's logs files so + | you may inspect the message. This is great for local development. + | + */ + + 'pretend' => false, + +]; \ No newline at end of file diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 00000000..d5c7ea90 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,92 @@ + env('QUEUE_DRIVER', 'sync'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection information for each server that + | is used by your application. A default configuration has been added + | for each back-end shipped with Laravel. You are free to add more. + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'expire' => 60, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => 'localhost', + 'queue' => 'default', + 'ttr' => 60, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => 'your-public-key', + 'secret' => 'your-secret-key', + 'queue' => 'your-queue-url', + 'region' => 'us-east-1', + ], + + 'iron' => [ + 'driver' => 'iron', + 'host' => 'mq-aws-us-east-1.iron.io', + 'token' => 'your-token', + 'project' => 'your-project-id', + 'queue' => 'your-queue-name', + 'encrypt' => true, + ], + + 'redis' => [ + 'driver' => 'redis', + 'queue' => 'default', + 'expire' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control which database and table are used to store the jobs that + | have failed. You may change them to any database / table you wish. + | + */ + + 'failed' => [ + 'database' => 'mysql', 'table' => 'failed_jobs', + ], + +]; \ No newline at end of file diff --git a/config/services.php b/config/services.php new file mode 100644 index 00000000..65abbac7 --- /dev/null +++ b/config/services.php @@ -0,0 +1,37 @@ + [ + 'domain' => '', + 'secret' => '', + ], + + 'mandrill' => [ + 'secret' => '', + ], + + 'ses' => [ + 'key' => '', + 'secret' => '', + 'region' => 'us-east-1', + ], + + 'stripe' => [ + 'model' => 'App\User', + 'secret' => '', + ], + +]; \ No newline at end of file diff --git a/config/session.php b/config/session.php new file mode 100644 index 00000000..901149db --- /dev/null +++ b/config/session.php @@ -0,0 +1,153 @@ + env('SESSION_DRIVER', 'redis'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to immediately expire on the browser closing, set that option. + | + */ + + 'lifetime' => 120, + + 'expire_on_close' => false, + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it is stored. All encryption will be run + | automatically by Laravel and you can use the Session like normal. + | + */ + + 'encrypt' => false, + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When using the native session driver, we need a location where session + | files may be stored. A default has been set for you but a different + | location may be specified. This is only needed for file sessions. + | + */ + + 'files' => storage_path().'/framework/sessions', + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => null, + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table we + | should use to manage the sessions. Of course, a sensible default is + | provided for you; however, you are free to change this as needed. + | + */ + + 'table' => 'sessions', + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the cookie used to identify a session + | instance by ID. The name specified here will get used every time a + | new session cookie is created by the framework for every driver. + | + */ + + 'cookie' => 'openstackid_resources', + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application but you are free to change this when necessary. + | + */ + + 'path' => '/', + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | Here you may change the domain of the cookie used to identify a session + | in your application. This will determine which domains the cookie is + | available to in your application. A sensible default has been set. + | + */ + + 'domain' => env('SESSION_COOKIE_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you if it can not be done securely. + | + */ + + 'secure' => env('SESSION_COOKIE_SECURE', false), + +]; \ No newline at end of file diff --git a/config/view.php b/config/view.php new file mode 100644 index 00000000..48e9f929 --- /dev/null +++ b/config/view.php @@ -0,0 +1,33 @@ + [ + realpath(base_path('resources/views')) + ], + + /* + |-------------------------------------------------------------------------- + | Compiled View Path + |-------------------------------------------------------------------------- + | + | This option determines where all the compiled Blade templates will be + | stored for your application. Typically, this is within the storage + | directory. However, as usual, you are free to change this value. + | + */ + + 'compiled' => realpath(storage_path().'/framework/views'), + +]; \ No newline at end of file diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 00000000..9b1dffd9 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite diff --git a/database/migrations/.gitkeep b/database/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/database/migrations/2015_04_27_141330_create_apis_table.php b/database/migrations/2015_04_27_141330_create_apis_table.php new file mode 100644 index 00000000..4617f615 --- /dev/null +++ b/database/migrations/2015_04_27_141330_create_apis_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->string('name',255)->unique(); + $table->text('description')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('apis'); + } + +} diff --git a/database/migrations/2015_04_27_141832_create_api_scopes_table.php b/database/migrations/2015_04_27_141832_create_api_scopes_table.php new file mode 100644 index 00000000..ee5eeb55 --- /dev/null +++ b/database/migrations/2015_04_27_141832_create_api_scopes_table.php @@ -0,0 +1,50 @@ +bigIncrements('id'); + $table->string('name', 512); + $table->string('short_description', 512); + $table->text('description'); + $table->boolean('active')->default(true); + $table->boolean('default')->default(false); + $table->boolean('system')->default(false); + $table->timestamps(); + // FK + $table->bigInteger("api_id")->unsigned()->nullable(); + $table->index('api_id'); + $table->foreign('api_id') + ->references('id') + ->on('apis') + ->onDelete('cascade') + ->onUpdate('no action'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('api_scopes', function ($table) { + $table->dropForeign('api_id'); + }); + + Schema::drop('api_scopes'); + } + +} diff --git a/database/migrations/2015_04_27_141848_create_api_endpoints_table.php b/database/migrations/2015_04_27_141848_create_api_endpoints_table.php new file mode 100644 index 00000000..878362d7 --- /dev/null +++ b/database/migrations/2015_04_27_141848_create_api_endpoints_table.php @@ -0,0 +1,81 @@ +bigIncrements('id'); + $table->boolean('active')->default(true); + $table->boolean('allow_cors')->default(true); + $table->boolean('allow_credentials')->default(true); + $table->text('description')->nullable(); + $table->string('name', 255)->unique(); + $table->timestamps(); + $table->text("route"); + $table->enum('http_method', array('GET', 'HEAD','POST', 'PUT', 'DELETE', 'TRACE', 'CONNECT', 'OPTIONS', 'PATCH')); + $table->bigInteger("rate_limit")->unsigned()->nullable(); + //FK + $table->bigInteger("api_id")->unsigned(); + $table->index('api_id'); + $table->foreign('api_id') + ->references('id') + ->on('apis') + ->onDelete('cascade') + ->onUpdate('no action'); + }); + + Schema::create('endpoint_api_scopes', function ($table) { + $table->timestamps(); + $table->bigInteger("api_endpoint_id")->unsigned(); + $table->index('api_endpoint_id'); + $table->foreign('api_endpoint_id') + ->references('id') + ->on('api_endpoints') + ->onDelete('cascade') + ->onUpdate('no action');; + // FK 2 + $table->bigInteger("scope_id")->unsigned(); + $table->index('scope_id'); + $table->foreign('scope_id') + ->references('id') + ->on('api_scopes') + ->onDelete('cascade') + ->onUpdate('no action'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('endpoint_api_scopes', function ($table) { + $table->dropForeign('api_endpoint_id'); + }); + + Schema::table('endpoint_api_scopes', function ($table) { + $table->dropForeign('scope_id'); + }); + + Schema::dropIfExists('endpoint_api_scopes'); + + Schema::table('api_endpoints', function ($table) { + $table->dropForeign('api_id'); + }); + + Schema::drop('api_endpoints'); + } + +} \ No newline at end of file diff --git a/database/seeds/.gitkeep b/database/seeds/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/database/seeds/ApiEndpointsSeeder.php b/database/seeds/ApiEndpointsSeeder.php new file mode 100644 index 00000000..aef51ed4 --- /dev/null +++ b/database/seeds/ApiEndpointsSeeder.php @@ -0,0 +1,185 @@ +delete(); + DB::table('api_endpoints')->delete(); + + $this->seedPublicCloudsEndpoints(); + $this->seedPrivateCloudsEndpoints(); + $this->seedConsultantsEndpoints(); + } + + private function seedPublicCloudsEndpoints() + { + + $public_clouds = Api::where('name', '=', 'public-clouds')->first(); + $current_realm = Config::get('app.url'); + // endpoints scopes + + ApiEndpoint::create( + array( + 'name' => 'get-public-clouds', + 'active' => true, + 'api_id' => $public_clouds->id, + 'route' => '/api/v1/marketplace/public-clouds', + 'http_method' => 'GET' + ) + ); + + ApiEndpoint::create( + array( + 'name' => 'get-public-cloud', + 'active' => true, + 'api_id' => $public_clouds->id, + 'route' => '/api/v1/marketplace/public-clouds/{id}', + 'http_method' => 'GET' + ) + ); + + ApiEndpoint::create( + array( + 'name' => 'get-public-cloud-datacenters', + 'active' => true, + 'api_id' => $public_clouds->id, + 'route' => '/api/v1/marketplace/public-clouds/{id}/data-centers', + 'http_method' => 'GET' + ) + ); + + $public_cloud_read_scope = ApiScope::where('name', '=', sprintf('%s/public-clouds/read', $current_realm))->first(); + + $endpoint_get_public_clouds = ApiEndpoint::where('name', '=', 'get-public-clouds')->first(); + $endpoint_get_public_clouds->scopes()->attach($public_cloud_read_scope->id); + + $endpoint_get_public_cloud = ApiEndpoint::where('name', '=', 'get-public-cloud')->first(); + $endpoint_get_public_cloud->scopes()->attach($public_cloud_read_scope->id); + + $endpoint_get_public_cloud_datacenters = ApiEndpoint::where('name', '=', 'get-public-cloud-datacenters')->first(); + $endpoint_get_public_cloud_datacenters->scopes()->attach($public_cloud_read_scope->id); + } + + private function seedPrivateCloudsEndpoints() + { + $private_clouds = Api::where('name', '=', 'private-clouds')->first(); + $current_realm = Config::get('app.url'); + // endpoints scopes + + ApiEndpoint::create( + array( + 'name' => 'get-private-clouds', + 'active' => true, + 'api_id' => $private_clouds->id, + 'route' => '/api/v1/marketplace/private-clouds', + 'http_method' => 'GET' + ) + ); + + ApiEndpoint::create( + array( + 'name' => 'get-private-cloud', + 'active' => true, + 'api_id' => $private_clouds->id, + 'route' => '/api/v1/marketplace/private-clouds/{id}', + 'http_method' => 'GET' + ) + ); + + ApiEndpoint::create( + array( + 'name' => 'get-private-cloud-datacenters', + 'active' => true, + 'api_id' => $private_clouds->id, + 'route' => '/api/v1/marketplace/private-clouds/{id}/data-centers', + 'http_method' => 'GET' + ) + ); + + $private_cloud_read_scope = ApiScope::where('name', '=', sprintf('%s/private-clouds/read', $current_realm))->first(); + + $endpoint_get_private_clouds = ApiEndpoint::where('name', '=', 'get-private-clouds')->first(); + $endpoint_get_private_clouds->scopes()->attach($private_cloud_read_scope->id); + + $endpoint_get_private_cloud = ApiEndpoint::where('name', '=', 'get-private-cloud')->first(); + $endpoint_get_private_cloud->scopes()->attach($private_cloud_read_scope->id); + + $endpoint_get_private_cloud_datacenters = ApiEndpoint::where('name', '=', 'get-private-cloud-datacenters')->first(); + $endpoint_get_private_cloud_datacenters->scopes()->attach($private_cloud_read_scope->id); + + } + + private function seedConsultantsEndpoints() + { + + $consultants = Api::where('name', '=', 'consultants')->first(); + $current_realm = Config::get('app.url'); + // endpoints scopes + + ApiEndpoint::create( + array( + 'name' => 'get-consultants', + 'active' => true, + 'api_id' => $consultants->id, + 'route' => '/api/v1/marketplace/consultants', + 'http_method' => 'GET' + ) + ); + + ApiEndpoint::create( + array( + 'name' => 'get-consultant', + 'active' => true, + 'api_id' => $consultants->id, + 'route' => '/api/v1/marketplace/consultants/{id}', + 'http_method' => 'GET' + ) + ); + + ApiEndpoint::create( + array( + 'name' => 'get-consultant-offices', + 'active' => true, + 'api_id' => $consultants->id, + 'route' => '/api/v1/marketplace/consultants/{id}/offices', + 'http_method' => 'GET' + ) + ); + + $consultant_read_scope = ApiScope::where('name', '=', sprintf('%s/consultants/read', $current_realm))->first(); + + $endpoint = ApiEndpoint::where('name', '=', 'get-consultants')->first(); + $endpoint->scopes()->attach($consultant_read_scope->id); + + $endpoint = ApiEndpoint::where('name', '=', 'get-consultant')->first(); + $endpoint->scopes()->attach($consultant_read_scope->id); + + $endpoint = ApiEndpoint::where('name', '=', 'get-consultant-offices')->first(); + $endpoint->scopes()->attach($consultant_read_scope->id); + } + +} \ No newline at end of file diff --git a/database/seeds/ApiScopesSeeder.php b/database/seeds/ApiScopesSeeder.php new file mode 100644 index 00000000..652fc5ef --- /dev/null +++ b/database/seeds/ApiScopesSeeder.php @@ -0,0 +1,88 @@ +delete(); + DB::table('api_scopes')->delete(); + + $this->seedPublicCloudScopes(); + $this->seedPrivateCloudScopes(); + $this->seedConsultantScopes(); + } + + private function seedPublicCloudScopes() + { + + $current_realm = Config::get('app.url'); + $public_clouds = Api::where('name', '=', 'public-clouds')->first(); + + ApiScope::create( + array( + 'name' => sprintf('%s/public-clouds/read', $current_realm), + 'short_description' => 'Get Public Clouds', + 'description' => 'Grants read only access for Public Clouds', + 'api_id' => $public_clouds->id, + 'system' => false + ) + ); + } + + private function seedPrivateCloudScopes() + { + + $current_realm = Config::get('app.url'); + $private_clouds = Api::where('name', '=', 'private-clouds')->first(); + + ApiScope::create( + array( + 'name' => sprintf('%s/private-clouds/read', $current_realm), + 'short_description' => 'Get Private Clouds', + 'description' => 'Grants read only access for Private Clouds', + 'api_id' => $private_clouds->id, + 'system' => false + ) + ); + } + + private function seedConsultantScopes() + { + + $current_realm = Config::get('app.url'); + $consultants = Api::where('name', '=', 'consultants')->first(); + + ApiScope::create( + array( + 'name' => sprintf('%s/consultants/read', $current_realm), + 'short_description' => 'Get Consultants', + 'description' => 'Grants read only access for Consultants', + 'api_id' => $consultants->id, + 'system' => false + ) + ); + } + +} \ No newline at end of file diff --git a/database/seeds/ApiSeeder.php b/database/seeds/ApiSeeder.php new file mode 100644 index 00000000..f6b6d7e5 --- /dev/null +++ b/database/seeds/ApiSeeder.php @@ -0,0 +1,54 @@ +delete(); + DB::table('api_scopes')->delete(); + DB::table('api_endpoints')->delete(); + DB::table('apis')->delete(); + // public clouds + Api::create( + array( + 'name' => 'public-clouds', + 'active' => true, + 'Description' => 'Marketplace Public Clouds' + ) + ); + // private clouds + Api::create( + array( + 'name' => 'private-clouds', + 'active' => true, + 'Description' => 'Marketplace Private Clouds' + ) + ); + // consultants + Api::create( + array( + 'name' => 'consultants', + 'active' => true, + 'Description' => 'Marketplace Consultants' + ) + ); + } +} \ No newline at end of file diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php new file mode 100644 index 00000000..a4a72eda --- /dev/null +++ b/database/seeds/DatabaseSeeder.php @@ -0,0 +1,22 @@ +call('ApiSeeder'); + $this->call('ApiScopesSeeder'); + $this->call('ApiEndpointsSeeder'); + } + +} diff --git a/database/seeds/TestSeeder.php b/database/seeds/TestSeeder.php new file mode 100644 index 00000000..60596f82 --- /dev/null +++ b/database/seeds/TestSeeder.php @@ -0,0 +1,32 @@ +call('ApiSeeder'); + $this->call('ApiScopesSeeder'); + $this->call('ApiEndpointsSeeder'); + } + +} \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 00000000..b369b7cf --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import os +import sys + +sys.path.insert(0, os.path.abspath('../..')) +# -- General configuration ---------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinxcontrib.httpdomain', + #'sphinx.ext.intersphinx', + 'oslosphinx', + 'yasfb', +] + +# Feed configuration for yasfb +feed_base_url = 'http://ci.openstack.org/openstackid-resources' +feed_author = 'OpenStack Infrastructure Team' + +exclude_patterns = [ + 'template.rst', +] + +# Optionally allow the use of sphinxcontrib.spelling to verify the +# spelling of the documents. +try: + import sphinxcontrib.spelling + extensions.append('sphinxcontrib.spelling') +except ImportError: + pass + +# autodoc generation is a bit aggressive and a nuisance when doing heavy +# text edit cycles. +# execute "export SPHINX_DEBUG=1" in your terminal to disable + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'openstackid-resources' +copyright = u'%s, OpenStack Foundation' % datetime.date.today().year + +# If true, '()' will be appended to :func: etc. cross-reference text. +add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +add_module_names = True + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# -- Options for HTML output -------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +# html_theme_path = ["."] +# html_theme = '_theme' +# html_static_path = ['static'] + +# Output file base name for HTML help builder. +htmlhelp_basename = '%sdoc' % project + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass +# [howto/manual]). +latex_documents = [ + ('index', + '%s.tex' % project, + u'%s Documentation' % project, + u'OpenStack Foundation', 'manual'), +] + +# Example configuration for intersphinx: refer to the Python standard library. +#intersphinx_mapping = {'http://docs.python.org/': None} \ No newline at end of file diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 00000000..0253d7c7 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,17 @@ +======================================================= +Welcome to OpenStackId's Resource Server documentation! +======================================================= + +Introduction +============ + +Table of contents +================= + +Client API Reference +-------------------- + +.. toctree:: + :maxdepth: 1 + + restapi/v1 \ No newline at end of file diff --git a/doc/source/restapi/v1.rst b/doc/source/restapi/v1.rst new file mode 100644 index 00000000..0ca4ed0c --- /dev/null +++ b/doc/source/restapi/v1.rst @@ -0,0 +1,560 @@ +================== +OAuth 2.0 Rest API +================== + +Schema +^^^^^^ + +All API access is over HTTPS, and accessed from the **https://openstackid-resources.org/** +domain. All data is sent and received as JSON. + +Parameters +^^^^^^^^^^ + +Many API methods take optional parameters. For GET requests, any parameters not +specified as a segment in the path can be passed as an HTTP query string + +Pagination +^^^^^^^^^^ + +Requests that return multiple items will be paginated to 10 items by default. +You can specify further pages with the **?page** parameter. For some +resources, you can also set a custom page size up to 100 with the **?per_page** +parameter. + +Rate Limiting +^^^^^^^^^^^^^ + +This is configured per API endpoint. +You can check the returned HTTP headers of any API request to see your current +rate limit status:: + + X-RateLimit-Limit: 60 + X-RateLimit-Remaining: 56 + X-RateLimit-Reset: 1372700873 + + +The headers tell you everything you need to know about your current rate limit +status : + +======================= ============================================================================== +Header Name Description +======================= ============================================================================== +X-RateLimit-Limit The maximum number of requests that the consumer is permitted to make per hour. +X-RateLimit-Remaining The number of requests remaining in the current rate limit window. +X-RateLimit-Reset The number of seconds remaining until the current rate limit window resets. +======================= ============================================================================== + +If your application triggers this rate limit, you'll receive an informative +response: + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 403 Forbidden + Content-Type: application/json; charset=utf-8 + Connection: close + + { + + "message": "You have triggered an abuse detection mechanism and have been + temporarily blocked. Please retry your request again later." + + } + +Conditional requests +^^^^^^^^^^^^^^^^^^^^ + +Most responses return an **ETag** header. You can use the values +of this headers to make subsequent requests to those resources using the +**If-None-Match** header, respectively. If the resource +has not changed, the server will return a **304 Not Modified**. + + +Cross Origin Resource Sharing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The API supports Cross Origin Resource Sharing (CORS) for AJAX requests from +any origin. +You can read the [CORS W3C Recommendation](http://www.w3.org/TR/cors), or +[this intro] +(http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity) from +the HTML 5 Security Guide. + +JSON-P Callbacks +^^^^^^^^^^^^^^^^ + +You can send a **?callback** parameter to any GET call to have the results +wrapped in a JSON function. This is typically used when browsers want to +embed OpenStack content in web pages by getting around cross domain issues. +The response includes the same data output as the regular API, plus the +relevant HTTP Header information. + + +MarketPlace API +^^^^^^^^^^^^^^^ + +Public Clouds Endpoints +----------------------- + +Allows to get read only access to public clouds related data ( clouds and data +centers locations) + +.. http:get:: /api/v1/marketplace/public-clouds + + Get a list of public clouds + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/public-clouds HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: text/javascript + + { + + "total":20, + "per_page":10, + "current_page":1, + "last_page":2, + "from":1, + "to":10, + "data":[ + { + "ID":"YYYY", + "Created":"2014-04-23 05:36:10", + "LastEdited":"2015-02-04 11:13:58", + "Name":"Next-Generation AgileCLOUD", + "Slug":"next-generation-agilecloud", + "Overview":"....", + "Call2ActionUri":"http:\/\/....", + "Active":"1", + "CompanyID":"XXX" + } + ,{...} + ] + + } + + :query page: used in conjunction with "per_page" query string parameter. + indicates the desired page number, when we want paginate + over results + :query per_page: used in conjunction with "page" query string parameter. + indicates the desired page size + :query status: (optional filter) allow us to get active, non active or all + public clouds + :query order_by: (optional) used in conjunction with query string parameter + "order_dir", point out the desired order of the result (date or name) + :query order_dir: (optional) used in conjunction with query string parameter + "order", point out the desired order direction of the result (asc or desc) + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 412: invalid parameters + :statuscode 500: server error + +.. http:get:: api/v1/marketplace/public-clouds/(int:id) + + Get desired public cloud point out by `id` param + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/public-clouds/123456 HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "ID":"123456", + "Created":"2014-04-23 05:36:10", + "LastEdited":"2015-02-04 11:13:58", + "Name":"test public cloud", + "Slug":"test-public-cloud", + "Overview":"lorep ip sum", + "Call2ActionUri":"http:\/\/.../", + "Active":"1", + "CompanyID":"123456" + } + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 404: entity not found + :statuscode 500: server error + + +.. http:get:: /api/v1/marketplace/public-clouds/(int:id)/data-centers + + Get data center locations for public cloud pointed out by `id` param + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/public-clouds/123456/data-centers HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + {"datacenters":[ + { + "ID":"72", + "Created":"2014-05-07 15:19:39", + "LastEdited":"2014-05-07 15:19:39", + "Name":"West", + "Endpoint":"https:\/\/identity.uswest1.cloud.io.com\/v2.0", + "Color":"000000", + "locations":[ + { + "ID":"109", + "Created":"2014-05-07 15:19:39", + "LastEdited":"2014-05-07 15:19:39", + "City":"Phoenix", + "State":"AZ", + "Country":"US", + "Lat":"33.45", + "Lng":"-112.07" + } + ] + },... + ] + } + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 404: entity not found (cloud) + :statuscode 500: server error + +Private Clouds Endpoints +------------------------ + +Allows to get read only access to private clouds related data ( clouds and data +centers locations) + +.. http:get:: /api/v1/marketplace/private-clouds + + Get a list of private clouds + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/private-clouds HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: text/javascript + + { + + "total":20, + "per_page":10, + "current_page":1, + "last_page":2, + "from":1, + "to":10, + "data":[ + { + "ID":"YYYY", + "Created":"2014-04-23 05:36:10", + "LastEdited":"2015-02-04 11:13:58", + "Name":"test private cloud", + "Slug":"test-private-cloud", + "Overview":"....", + "Call2ActionUri":"http:\/\/....", + "Active":"1", + "CompanyID":"XXX" + } + ,{...} + ] + + } + + :query page: used in conjunction with "per_page" query string parameter. + indicates the desired page number, when we want paginate + over results + :query per_page: used in conjunction with "page" query string parameter. + indicates the desired page size + :query status: (optional filter) allow us to get active, non active or all + public clouds + :query order_by: (optional) used in conjunction with query string parameter + "order_dir", point out the desired order of the result (date or name) + :query order_dir: (optional) used in conjunction with query string parameter + "order", point out the desired order direction of the result (asc or desc) + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 412: invalid parameters + :statuscode 500: server error + +.. http:get:: /api/v1/marketplace/private-clouds/(int:id) + + Get desired private cloud point out by `id` param + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/private-clouds/123456 HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "ID":"123456", + "Created":"2014-04-23 05:36:10", + "LastEdited":"2015-02-04 11:13:58", + "Name":"test private cloud", + "Slug":"test-private-cloud", + "Overview":"lorep ip sum", + "Call2ActionUri":"http:\/\/..", + "Active":"1", + "CompanyID":"123456" + } + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 404: entity not found + :statuscode 500: server error + + +.. http:get:: /api/v1/marketplace/private-clouds/(int:id)/data-centers + + Get data center locations for private cloud pointed out by `id` param + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/private-clouds/123456/data-centers HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + {"datacenters":[ + { + "ID":"72", + "Created":"2014-05-07 15:19:39", + "LastEdited":"2014-05-07 15:19:39", + "Name":"West", + "Endpoint":"https:\/\/identity.uswest1.cloud.io.com\/v2.0", + "Color":"000000", + "locations":[ + { + "ID":"109", + "Created":"2014-05-07 15:19:39", + "LastEdited":"2014-05-07 15:19:39", + "City":"Phoenix", + "State":"AZ", + "Country":"US", + "Lat":"33.45", + "Lng":"-112.07" + } + ] + },... + ] + } + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 404: entity not found (cloud) + :statuscode 500: server error + + +Consultants Endpoints +--------------------- + +Allows to get read only access to consultants related data ( consultants and +offices locations) + +.. http:get:: /api/v1/marketplace/consultants + + Get a list of consultants + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/consultants HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: text/javascript + + { + + "total":20, + "per_page":10, + "current_page":1, + "last_page":2, + "from":1, + "to":10, + "data":[ + { + "ID":"YYYY", + "Created":"2014-04-23 05:36:10", + "LastEdited":"2015-02-04 11:13:58", + "Name":"Consultant Name", + "Slug":"consultant-name", + "Overview":"....", + "Call2ActionUri":"http:\/\/....", + "Active":"1", + "CompanyID":"XXX" + } + ,{...} + ] + + } + + :query page: used in conjunction with "per_page" query string parameter. + indicates the desired page number, when we want paginate + over results + :query per_page: used in conjunction with "page" query string parameter. + indicates the desired page size + :query status: (optional filter) allow us to get active, non active or all + public clouds + :query order_by: (optional) used in conjunction with query string parameter + "order_dir", point out the desired order of the result (date or name) + :query order_dir: (optional) used in conjunction with query string parameter + "order", point out the desired order direction of the result (asc or desc) + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 412: invalid parameters + :statuscode 500: server error + +.. http:get:: /api/v1/marketplace/consultants/(int:id) + + Get desired consultant point out by `id` param + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/consultants/123456 HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "ID":"123456", + "Created":"2014-04-23 05:36:10", + "LastEdited":"2015-02-04 11:13:58", + "Name":"Consultant Name", + "Slug":"consultant_name", + "Overview":"lorep ip sum", + "Call2ActionUri":"http:\/\/...", + "Active":"1", + "CompanyID":"123456" + } + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 404: entity not found + :statuscode 500: server error + +.. http:get:: /api/v1/marketplace/consultants/(int:id)/offices + + Get offices locations for consultant pointed out by `id` param + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/marketplace/consultants/123456/offices HTTP/1.1 + Host: openstackid.org + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "offices":[ + { + "ID":"45", + "Created":"2014-04-29 16:02:50", + "LastEdited":"2014-04-29 16:02:50", + "Address":null, + "Address2":null, + "State":"CA", + "ZipCode":null, + "City":"Mountain View", + "Country":"US", + "Lat":"37.39", + "Lng":"-122.08" + },... + ] + } + + :reqheader Authorization: OAuth 2.0 Bearer Access Token + + :statuscode 200: no error + :statuscode 404: entity not found (consultant) + :statuscode 500: server error \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 00000000..7cf62673 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,16 @@ +var elixir = require('laravel-elixir'); + +/* + |-------------------------------------------------------------------------- + | Elixir Asset Management + |-------------------------------------------------------------------------- + | + | Elixir provides a clean, fluent API for defining some basic Gulp tasks + | for your Laravel application. By default, we are compiling the Less + | file for our application, as well as publishing vendor resources. + | + */ + +elixir(function(mix) { + mix.less('app.less'); +}); diff --git a/package.json b/package.json new file mode 100644 index 00000000..5595f071 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "devDependencies": { + "gulp": "^3.8.8", + "laravel-elixir": "*" + } +} diff --git a/phpspec.yml b/phpspec.yml new file mode 100644 index 00000000..eb57939e --- /dev/null +++ b/phpspec.yml @@ -0,0 +1,5 @@ +suites: + main: + namespace: App + psr4_prefix: App + src_path: app \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..b22af540 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + ./tests/ + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 00000000..77827ae7 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,15 @@ + + + Options -MultiViews + + + RewriteEngine On + + # Redirect Trailing Slashes... + RewriteRule ^(.*)/$ /$1 [L,R=301] + + # Handle Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..37f19c23 --- /dev/null +++ b/public/index.php @@ -0,0 +1,57 @@ + + */ + +/* +|-------------------------------------------------------------------------- +| Register The Auto Loader +|-------------------------------------------------------------------------- +| +| Composer provides a convenient, automatically generated class loader for +| our application. We just need to utilize it! We'll simply require it +| into the script here so that we don't have to worry about manual +| loading any of our classes later on. It feels nice to relax. +| +*/ + +require __DIR__.'/../bootstrap/autoload.php'; + +/* +|-------------------------------------------------------------------------- +| Turn On The Lights +|-------------------------------------------------------------------------- +| +| We need to illuminate PHP development, so let us turn on the lights. +| This bootstraps the framework and gets it ready for use, then it +| will load up this application so that we can run it and send +| the responses back to the browser and delight our users. +| +*/ + +$app = require_once __DIR__.'/../bootstrap/app.php'; + +/* +|-------------------------------------------------------------------------- +| Run The Application +|-------------------------------------------------------------------------- +| +| Once we have the application, we can handle the incoming request +| through the kernel, and send the associated response back to +| the client's browser allowing them to enjoy the creative +| and wonderful application we have prepared for them. +| +*/ + +$kernel = $app->make('Illuminate\Contracts\Http\Kernel'); + +$response = $kernel->handle( + $request = Illuminate\Http\Request::capture() +); + +$response->send(); + +$kernel->terminate($request, $response); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..eb053628 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/readme.md b/readme.md new file mode 100644 index 00000000..a322e088 --- /dev/null +++ b/readme.md @@ -0,0 +1,23 @@ +# OpenStackId Resource Server + +## Prerequisites + + * LAMP/LEMP environment + * PHP >= 5.4.0 + * Redis + * composer (https://getcomposer.org/) + +## Install + +run following commands on root folder + * curl -s https://getcomposer.org/installer | php + * php composer.phar install --prefer-dist + * php composer.phar dump-autoload --optimize + * php artisan migrate --env=YOUR_ENVIRONMENT + * php artisan db:seed --env=YOUR_ENVIRONMENT + * phpunit --bootstrap vendor/autoload.php + * give proper rights to storage folder (775 and proper users) + +## Permissions + +Laravel may require some permissions to be configured: folders within storage and vendor require write access by the web server. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..585de733 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +oslosphinx +sphinx>=1.1.2,<1.2 +sphinxcontrib-httpdomain +yasfb>=0.5.1 \ No newline at end of file diff --git a/resources/assets/less/app.less b/resources/assets/less/app.less new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/resources/assets/less/app.less @@ -0,0 +1 @@ + diff --git a/resources/lang/en/pagination.php b/resources/lang/en/pagination.php new file mode 100644 index 00000000..13b4dcb3 --- /dev/null +++ b/resources/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/resources/lang/en/passwords.php b/resources/lang/en/passwords.php new file mode 100644 index 00000000..1fc0e1ef --- /dev/null +++ b/resources/lang/en/passwords.php @@ -0,0 +1,22 @@ + "Passwords must be at least six characters and match the confirmation.", + "user" => "We can't find a user with that e-mail address.", + "token" => "This password reset token is invalid.", + "sent" => "We have e-mailed your password reset link!", + "reset" => "Your password has been reset!", + +]; diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php new file mode 100644 index 00000000..764f0563 --- /dev/null +++ b/resources/lang/en/validation.php @@ -0,0 +1,107 @@ + "The :attribute must be accepted.", + "active_url" => "The :attribute is not a valid URL.", + "after" => "The :attribute must be a date after :date.", + "alpha" => "The :attribute may only contain letters.", + "alpha_dash" => "The :attribute may only contain letters, numbers, and dashes.", + "alpha_num" => "The :attribute may only contain letters and numbers.", + "array" => "The :attribute must be an array.", + "before" => "The :attribute must be a date before :date.", + "between" => [ + "numeric" => "The :attribute must be between :min and :max.", + "file" => "The :attribute must be between :min and :max kilobytes.", + "string" => "The :attribute must be between :min and :max characters.", + "array" => "The :attribute must have between :min and :max items.", + ], + "boolean" => "The :attribute field must be true or false.", + "confirmed" => "The :attribute confirmation does not match.", + "date" => "The :attribute is not a valid date.", + "date_format" => "The :attribute does not match the format :format.", + "different" => "The :attribute and :other must be different.", + "digits" => "The :attribute must be :digits digits.", + "digits_between" => "The :attribute must be between :min and :max digits.", + "email" => "The :attribute must be a valid email address.", + "filled" => "The :attribute field is required.", + "exists" => "The selected :attribute is invalid.", + "image" => "The :attribute must be an image.", + "in" => "The selected :attribute is invalid.", + "integer" => "The :attribute must be an integer.", + "ip" => "The :attribute must be a valid IP address.", + "max" => [ + "numeric" => "The :attribute may not be greater than :max.", + "file" => "The :attribute may not be greater than :max kilobytes.", + "string" => "The :attribute may not be greater than :max characters.", + "array" => "The :attribute may not have more than :max items.", + ], + "mimes" => "The :attribute must be a file of type: :values.", + "min" => [ + "numeric" => "The :attribute must be at least :min.", + "file" => "The :attribute must be at least :min kilobytes.", + "string" => "The :attribute must be at least :min characters.", + "array" => "The :attribute must have at least :min items.", + ], + "not_in" => "The selected :attribute is invalid.", + "numeric" => "The :attribute must be a number.", + "regex" => "The :attribute format is invalid.", + "required" => "The :attribute field is required.", + "required_if" => "The :attribute field is required when :other is :value.", + "required_with" => "The :attribute field is required when :values is present.", + "required_with_all" => "The :attribute field is required when :values is present.", + "required_without" => "The :attribute field is required when :values is not present.", + "required_without_all" => "The :attribute field is required when none of :values are present.", + "same" => "The :attribute and :other must match.", + "size" => [ + "numeric" => "The :attribute must be :size.", + "file" => "The :attribute must be :size kilobytes.", + "string" => "The :attribute must be :size characters.", + "array" => "The :attribute must contain :size items.", + ], + "unique" => "The :attribute has already been taken.", + "url" => "The :attribute format is invalid.", + "timezone" => "The :attribute must be a valid zone.", + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap attribute place-holders + | with something more reader friendly such as E-Mail Address instead + | of "email". This simply helps us make messages a little cleaner. + | + */ + + 'attributes' => [], + +]; diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..f1bd62f0 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,12 @@ +@extends('layouts.master') +@section('title', '404') +@section('content') +

+@stop \ No newline at end of file diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..669dcb80 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,41 @@ + + + + + + + +
+
+
Be right back.
+
+
+ + diff --git a/resources/views/layouts/master.blade.php b/resources/views/layouts/master.blade.php new file mode 100644 index 00000000..61dea881 --- /dev/null +++ b/resources/views/layouts/master.blade.php @@ -0,0 +1,39 @@ + + + OpenStackId - Resource Server - @yield('title') + + + + +
+ @yield('content') +
+ + \ No newline at end of file diff --git a/resources/views/vendor/.gitkeep b/resources/views/vendor/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/server.php b/server.php new file mode 100644 index 00000000..c7e378df --- /dev/null +++ b/server.php @@ -0,0 +1,21 @@ + + */ + +$uri = urldecode( + parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) +); + +// This file allows us to emulate Apache's "mod_rewrite" functionality from the +// built-in PHP web server. This provides a convenient way to test a Laravel +// application without having installed a "real" web server software here. +if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) +{ + return false; +} + +require_once __DIR__.'/public/index.php'; diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..dfea3492 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,23 @@ +[metadata] +name = infra-specs +summary = OpenStackID Resource Server for the OpenStack Foundation site +description-file = + readme.md +author = OpenStack +author-email = openstack-infra@lists.openstack.org +home-page = http://www.openstack.org/ +classifier = + Environment :: OpenStack + Intended Audience :: Developers + Operating System :: POSIX :: Linux + +[build_sphinx] +source-dir = doc/source +build-dir = doc/build +all_files = 1 + +[pbr] +warnerrors = True + +[upload_sphinx] +upload-dir = doc/build/html \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..5229c561 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import setuptools + +setuptools.setup( + setup_requires=['pbr'], + pbr=True) \ No newline at end of file diff --git a/storage/.gitignore b/storage/.gitignore new file mode 100755 index 00000000..78eac7b6 --- /dev/null +++ b/storage/.gitignore @@ -0,0 +1 @@ +laravel.log \ No newline at end of file diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100755 index 00000000..c96a04f0 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100755 index 00000000..1670e906 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,6 @@ +config.php +routes.php +compiled.php +services.json +events.scanned.php +routes.scanned.php diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100755 index 00000000..c96a04f0 --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100755 index 00000000..d6b7ef32 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100755 index 00000000..d6b7ef32 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100755 index 00000000..d6b7ef32 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/OAuth2ConsultantApiTest.php b/tests/OAuth2ConsultantApiTest.php new file mode 100644 index 00000000..237a2838 --- /dev/null +++ b/tests/OAuth2ConsultantApiTest.php @@ -0,0 +1,154 @@ + 1 , + 'per_page' => 10, + 'status' => ICompanyServiceRepository::Status_active, + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2ConsultantsApiController@getConsultants", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $consultants = json_decode($content); + + $this->assertResponseStatus(200); + } + + public function testGetConsultantsCORS() + { + + $params = array( + 'page' => 1 , + 'per_page' => 10, + 'status' => ICompanyServiceRepository::Status_active, + ); + + $headers = array( + "HTTP_Authorization" => " Bearer " .$this->access_token, + 'HTTP_Origin' => array('www.test.com'), + 'HTTP_Access-Control-Request-Method'=>'GET', + ); + + $response = $this->action( + "OPTIONS", + "OAuth2ConsultantsApiController@getConsultants", + $params, + array(), + array(), + array(), + $headers + ); + + + $content = $response->getContent(); + $consultants = json_decode($content); + + $this->assertResponseStatus(403); + } + + public function testGetConsultantNotFound() + { + + $params = array( + 'id' => 0 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2ConsultantsApiController@getConsultant", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(404); + } + + public function testGetConsultantFound() + { + + $params = array( + 'id' => 18 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2ConsultantsApiController@getConsultant", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(200); + } + + public function testGetOffices() + { + + $params = array( + 'id' => 19 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2ConsultantsApiController@getOffices", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(200); + + } +} \ No newline at end of file diff --git a/tests/OAuth2PrivateCloudApiTest.php b/tests/OAuth2PrivateCloudApiTest.php new file mode 100644 index 00000000..2163e66b --- /dev/null +++ b/tests/OAuth2PrivateCloudApiTest.php @@ -0,0 +1,123 @@ + 1 , + 'per_page' => 10, + 'status' => ICompanyServiceRepository::Status_active, + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2PrivateCloudApiController@getClouds", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $clouds = json_decode($content); + + $this->assertResponseStatus(200); + } + + public function testGetPrivateCloudNotFound() + { + + $params = array( + 'id' => 0 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2PrivateCloudApiController@getCloud", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(404); + } + + public function testGetPrivateCloudFound() + { + + $params = array( + 'id' => 60 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2PrivateCloudApiController@getCloud", + $params, + array(), + array(), + array(), + $headers + ); + + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(200); + } + + public function testGetDataCenterRegions() + { + + $params = array( + 'id' => 60 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2PrivateCloudApiController@getCloudDataCenters", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(200); + + } +} \ No newline at end of file diff --git a/tests/OAuth2PublicCloudApiTest.php b/tests/OAuth2PublicCloudApiTest.php new file mode 100644 index 00000000..3f5a6fca --- /dev/null +++ b/tests/OAuth2PublicCloudApiTest.php @@ -0,0 +1,121 @@ + 1 , + 'per_page' => 10, + 'status' => ICompanyServiceRepository::Status_active, + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + + $response = $this->action( + "GET", + "OAuth2PublicCloudApiController@getClouds", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $clouds = json_decode($content); + + $this->assertResponseStatus(200); + } + + public function testGetPublicCloudNotFound() + { + + $params = array( + 'id' => 0 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2PublicCloudApiController@getCloud", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(404); + } + + public function testGetPublicCloudFound() + { + + $params = array( + 'id' => 17 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2PublicCloudApiController@getCloud", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + + $this->assertResponseStatus(200); + } + + public function testGetDataCenterRegions() + { + + $params = array( + 'id' => 53 + ); + + $headers = array("HTTP_Authorization" => " Bearer " .$this->access_token); + $response = $this->action( + "GET", + "OAuth2PublicCloudApiController@getCloudDataCenters", + $params, + array(), + array(), + array(), + $headers + ); + + $content = $response->getContent(); + $res = json_decode($content); + $this->assertResponseStatus(200); + + } +} \ No newline at end of file diff --git a/tests/ProtectedApiTest.php b/tests/ProtectedApiTest.php new file mode 100644 index 00000000..36340120 --- /dev/null +++ b/tests/ProtectedApiTest.php @@ -0,0 +1,74 @@ +access_token = '123456789'; + parent::setUp(); + } + + public function tearDown() + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..ca3ba9f2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,47 @@ +make('Illuminate\Contracts\Console\Kernel'); + $app->loadEnvironmentFrom('.env.testing'); + $instance->bootstrap(); + return $app; + } + + public function setUp() + { + parent::setUp(); + $this->redis = Redis::connection(); + $this->redis->flushall(); + $this->prepareForTests(); + } + + protected function prepareForTests() + { + Artisan::call('migrate'); + Mail::pretend(true); + $this->seed('TestSeeder'); + } + + public function tearDown() + { + + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..30192fe5 --- /dev/null +++ b/tox.ini @@ -0,0 +1,22 @@ +[tox] +minversion = 1.6 +envlist = docs + +[testenv] +install_command = pip install -U {opts} {packages} +setenv = +VIRTUAL_ENV={envdir} +deps = -r{toxinidir}/requirements.txt + +[testenv:venv] +commands = {posargs} + +[testenv:docs] +commands = python setup.py build_sphinx + +[testenv:spelling] +deps = +-r{toxinidir}/requirements.txt +sphinxcontrib-spelling +PyEnchant +commands = sphinx-build -b spelling doc/source doc/build/spelling \ No newline at end of file