From 10ccf2f800f2226db152430bfd76c7a61cc9e69a Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Sat, 6 Jul 2024 18:07:13 +0500 Subject: [PATCH] Implemented interaction with docker registry. --- .env.example | 1 + app/application/.env.example | 8 + .../Contracts/Services/Registry/Base32.php | 9 + .../app/Dto/Builder/Repository.php | 15 ++ .../Dto/Service/Admin/User/StoreUpdate.php | 4 +- .../Registry/V2/AuthorizationConfig.php | 39 +++ .../V2/AuthorizationService/Authorization.php | 30 +++ .../V2/AuthorizationService/Scope.php | 41 +++ .../V2/EventService/DataProcessing.php | 17 ++ .../Registry/V2/EventService/Event.php | 29 +++ .../Registry/V2/EventService/Events.php | 21 ++ .../V2/EventService/ImageDataPull.php | 33 +++ .../V2/EventService/ImageDataPush.php | 39 +++ .../Dto/Service/Site/AccessTokens/Index.php | 14 + .../Service/Site/AccessTokens/StoreUpdate.php | 24 ++ .../Service/Site/Repository/Repositories.php | 14 + .../Service/Site/Repository/StoreUpdate.php | 35 +++ .../Dto/Service/Site/TagRepository/Tags.php | 14 + .../app/Dto/Service/Site/User/User.php | 14 + .../app/Enums/AccessTokenPermission.php | 35 +++ app/application/app/Enums/CacheTag.php | 16 ++ app/application/app/Enums/Morph.php | 31 +++ .../Jobs/Registry/V2/JobException.php | 8 + .../V2/ArchitectureCommand/BlobException.php | 8 + .../ArchitectureCommand/ManifestException.php | 8 + .../EventService/DataProcessingException.php | 8 + app/application/app/Helpers/Helpers.php | 6 + app/application/app/Helpers/Number.php | 74 ++++++ .../app/Http/Controllers/Controller.php | 12 +- .../Http/Controllers/Registry/Controller.php | 10 + .../Http/Controllers/Registry/Version2.php | 40 +++ .../Site/AccessTokensController.php | 94 +++++++ .../app/Http/Controllers/Site/Controller.php | 6 +- .../Http/Controllers/Site/HomeController.php | 13 - .../Site/RepositoriesController.php | 96 +++++++ .../Site/TagRepositoriesController.php | 42 +++ .../Http/Controllers/Site/UserController.php | 30 +++ .../app/Http/Middleware/RegistryAuth.php | 69 +++++ .../Middleware/RegistryAuthNotification.php | 34 +++ .../Admin/Users/StoreUpdateRequest.php | 4 +- .../Registry/Version2/AuthRequest.php | 36 +++ .../Registry/Version2/EventRequest.php | 42 +++ .../Site/AccessTokens/IndexRequest.php | 28 ++ .../Site/AccessTokens/StoreUpdateRequest.php | 34 +++ .../Site/Repositories/RepositoriesRequest.php | 27 ++ .../Site/Repositories/StoreUpdateRequest.php | 40 +++ .../Site/TagRepositories/TagsRequest.php | 27 ++ .../Http/Requests/Site/User/UserRequest.php | 27 ++ .../Resources/Site/AccessTokens/Store.php | 24 ++ .../app/Jobs/Registry/V2/ProcessEventPull.php | 60 +++++ .../app/Jobs/Registry/V2/ProcessEventPush.php | 70 +++++ app/application/app/Models/Architecture.php | 28 ++ .../app/Models/OperatingSystem.php | 28 ++ app/application/app/Models/Repository.php | 71 ++++++ app/application/app/Models/TagRepository.php | 53 ++++ app/application/app/Models/User.php | 9 +- app/application/app/Policies/Policy.php | 16 +- .../app/Policies/RepositoryPolicy.php | 89 +++++++ .../app/Policies/TagRepositoryPolicy.php | 15 ++ .../app/Providers/AppServiceProvider.php | 24 +- .../app/Providers/BladeProvider.php | 35 +++ .../app/Providers/RegistryServiceProvider.php | 45 ++++ .../Repositories/AccessTokenRepository.php | 27 ++ .../app/Repositories/RepositoryRepository.php | 61 +++++ .../Repositories/TagRepositoryRepository.php | 27 ++ .../app/Repositories/UserRepository.php | 10 + app/application/app/Rules/Repository.php | 4 +- .../Registry/V2/ArchitectureResult.php | 18 ++ .../AccessTokenService/StoreTokenResult.php | 30 +++ .../TagRepository/IncrementPullResult.php | 15 ++ .../app/Services/Admin/UserService.php | 26 +- .../Services/Registry/V2/AccessCommand.php | 70 +++++ .../Services/Registry/V2/Api/ApiService.php | 67 +++++ .../V2/Api/GenerateJwtTokenCommand.php | 22 ++ .../Registry/V2/Api/RequestCommandHandler.php | 31 +++ .../Registry/V2/ArchitectureCommand.php | 62 +++++ .../Registry/V2/AuthorizationService.php | 41 +++ .../Registry/V2/Base32CommandHandler.php | 239 ++++++++++++++++++ .../Registry/V2/ContentPrivateKeyCommand.php | 18 ++ .../app/Services/Registry/V2/EventService.php | 120 +++++++++ .../app/Services/Registry/V2/JwtCommand.php | 26 ++ .../app/Services/Registry/V2/KidCommand.php | 56 ++++ .../Services/Registry/V2/PayloadCommand.php | 22 ++ .../Services/Repository/BuilderCommand.php | 17 ++ .../Repository/RepositoryCommandHandler.php | 34 +++ .../app/Services/Site/AccessTokenService.php | 113 +++++++++ .../app/Services/Site/RepositoryService.php | 209 +++++++++++++++ .../Services/Site/TagRepositoryService.php | 94 +++++++ .../app/Services/Site/UserService.php | 51 ++++ .../TagRepository/IncrementPullCommand.php | 37 +++ .../TagRepositoryCommandHandler.php | 34 +++ .../TagRepository/UpdateOrCreateCommand.php | 29 +++ .../app/Services/User/UserCommandHandler.php | 3 +- .../app/View/Components/Layout/Site.php | 3 + .../app/View/Components/Site/Forms/Input.php | 14 + .../View/Components/Site/Forms/Textarea.php | 44 ++++ app/application/bootstrap/app.php | 32 ++- app/application/bootstrap/providers.php | 2 + app/application/composer.json | 5 +- app/application/composer.lock | 129 +++++++++- app/application/config/registry.php | 103 ++++++++ app/application/config/sanctum.php | 83 ++++++ ...47_create_personal_access_tokens_table.php | 33 +++ .../2024_06_23_170638_create_repositories.php | 74 ++++++ app/application/lang/en.json | 20 +- .../lang/en/access-token-permission.php | 7 + app/application/lang/en/site.php | 19 ++ app/application/lang/en/validation.php | 2 + app/application/lang/ru.json | 20 +- .../lang/ru/access-token-permission.php | 7 + app/application/lang/ru/site.php | 19 ++ app/application/lang/ru/validation.php | 2 + app/application/resources/certs/.gitignore | 3 + app/application/resources/certs/readme.txt | 2 + .../resources/site/images/icons/download.svg | 3 + app/application/resources/site/js/app.js | 1 + .../resources/site/js/block-copy.js | 21 ++ app/application/resources/site/scss/app.scss | 5 + .../resources/site/scss/block-profile.scss | 18 ++ .../resources/site/scss/forms.scss | 21 ++ .../resources/site/scss/generated-token.scss | 33 +++ .../resources/site/scss/layout.scss | 69 ++++- .../resources/site/scss/pagination.scss | 41 +++ .../resources/site/scss/repository.scss | 155 ++++++++++++ .../resources/site/scss/table.scss | 18 ++ .../views/admin/users/_from.blade.php | 2 +- .../components/site/forms/input.blade.php | 8 + .../components/site/forms/textarea.blade.php | 12 + .../resources/views/layout/site.blade.php | 15 +- .../resources/views/pagination/site.blade.php | 46 ++++ .../views/site/access-tokens/create.blade.php | 89 +++++++ .../views/site/access-tokens/edit.blade.php | 16 ++ .../views/site/access-tokens/index.blade.php | 47 ++++ .../resources/views/site/home/index.blade.php | 6 - .../views/site/repositories/_from.blade.php | 8 + .../repositories/_repository-page.blade.php | 39 +++ .../views/site/repositories/create.blade.php | 8 + .../views/site/repositories/edit.blade.php | 9 + .../site/repositories/repositories.blade.php | 26 ++ .../site/repositories/repository.blade.php | 23 ++ .../views/site/repositories/tags.blade.php | 64 +++++ .../resources/views/site/user/user.blade.php | 37 +++ app/application/routes/console.php | 8 +- app/application/routes/registry.php | 6 + app/application/routes/web.php | 20 +- app/docker/start_dev.sh | 5 +- app/docker/start_prod.sh | 5 +- certs/.gitignore | 3 + certs/readme.txt | 2 + docker-compose-prod.yml | 39 ++- docker-compose-prod_docker-hub.yml | 54 +++- docker-compose.yml | 40 ++- registry/.gitignore | 2 + registry/config.yml.exmplate | 48 ++++ registry/nginx/nginx.conf | 50 ++++ 155 files changed, 5142 insertions(+), 89 deletions(-) create mode 100644 app/application/app/Contracts/Services/Registry/Base32.php create mode 100644 app/application/app/Dto/Builder/Repository.php create mode 100644 app/application/app/Dto/Service/Registry/V2/AuthorizationConfig.php create mode 100644 app/application/app/Dto/Service/Registry/V2/AuthorizationService/Authorization.php create mode 100644 app/application/app/Dto/Service/Registry/V2/AuthorizationService/Scope.php create mode 100644 app/application/app/Dto/Service/Registry/V2/EventService/DataProcessing.php create mode 100644 app/application/app/Dto/Service/Registry/V2/EventService/Event.php create mode 100644 app/application/app/Dto/Service/Registry/V2/EventService/Events.php create mode 100644 app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPull.php create mode 100644 app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPush.php create mode 100644 app/application/app/Dto/Service/Site/AccessTokens/Index.php create mode 100644 app/application/app/Dto/Service/Site/AccessTokens/StoreUpdate.php create mode 100644 app/application/app/Dto/Service/Site/Repository/Repositories.php create mode 100644 app/application/app/Dto/Service/Site/Repository/StoreUpdate.php create mode 100644 app/application/app/Dto/Service/Site/TagRepository/Tags.php create mode 100644 app/application/app/Dto/Service/Site/User/User.php create mode 100644 app/application/app/Enums/AccessTokenPermission.php create mode 100644 app/application/app/Enums/CacheTag.php create mode 100644 app/application/app/Enums/Morph.php create mode 100644 app/application/app/Exceptions/Jobs/Registry/V2/JobException.php create mode 100644 app/application/app/Exceptions/Services/Registry/V2/ArchitectureCommand/BlobException.php create mode 100644 app/application/app/Exceptions/Services/Registry/V2/ArchitectureCommand/ManifestException.php create mode 100644 app/application/app/Exceptions/Services/Registry/V2/EventService/DataProcessingException.php create mode 100644 app/application/app/Helpers/Number.php create mode 100644 app/application/app/Http/Controllers/Registry/Controller.php create mode 100644 app/application/app/Http/Controllers/Registry/Version2.php create mode 100644 app/application/app/Http/Controllers/Site/AccessTokensController.php delete mode 100644 app/application/app/Http/Controllers/Site/HomeController.php create mode 100644 app/application/app/Http/Controllers/Site/RepositoriesController.php create mode 100644 app/application/app/Http/Controllers/Site/TagRepositoriesController.php create mode 100644 app/application/app/Http/Controllers/Site/UserController.php create mode 100644 app/application/app/Http/Middleware/RegistryAuth.php create mode 100644 app/application/app/Http/Middleware/RegistryAuthNotification.php create mode 100644 app/application/app/Http/Requests/Registry/Version2/AuthRequest.php create mode 100644 app/application/app/Http/Requests/Registry/Version2/EventRequest.php create mode 100644 app/application/app/Http/Requests/Site/AccessTokens/IndexRequest.php create mode 100644 app/application/app/Http/Requests/Site/AccessTokens/StoreUpdateRequest.php create mode 100644 app/application/app/Http/Requests/Site/Repositories/RepositoriesRequest.php create mode 100644 app/application/app/Http/Requests/Site/Repositories/StoreUpdateRequest.php create mode 100644 app/application/app/Http/Requests/Site/TagRepositories/TagsRequest.php create mode 100644 app/application/app/Http/Requests/Site/User/UserRequest.php create mode 100644 app/application/app/Http/Resources/Site/AccessTokens/Store.php create mode 100644 app/application/app/Jobs/Registry/V2/ProcessEventPull.php create mode 100644 app/application/app/Jobs/Registry/V2/ProcessEventPush.php create mode 100644 app/application/app/Models/Architecture.php create mode 100644 app/application/app/Models/OperatingSystem.php create mode 100644 app/application/app/Models/Repository.php create mode 100644 app/application/app/Models/TagRepository.php create mode 100644 app/application/app/Policies/RepositoryPolicy.php create mode 100644 app/application/app/Policies/TagRepositoryPolicy.php create mode 100644 app/application/app/Providers/BladeProvider.php create mode 100644 app/application/app/Providers/RegistryServiceProvider.php create mode 100644 app/application/app/Repositories/AccessTokenRepository.php create mode 100644 app/application/app/Repositories/RepositoryRepository.php create mode 100644 app/application/app/Repositories/TagRepositoryRepository.php create mode 100644 app/application/app/ServiceResults/Registry/V2/ArchitectureResult.php create mode 100644 app/application/app/ServiceResults/Site/AccessTokenService/StoreTokenResult.php create mode 100644 app/application/app/ServiceResults/TagRepository/IncrementPullResult.php create mode 100644 app/application/app/Services/Registry/V2/AccessCommand.php create mode 100644 app/application/app/Services/Registry/V2/Api/ApiService.php create mode 100644 app/application/app/Services/Registry/V2/Api/GenerateJwtTokenCommand.php create mode 100644 app/application/app/Services/Registry/V2/Api/RequestCommandHandler.php create mode 100644 app/application/app/Services/Registry/V2/ArchitectureCommand.php create mode 100644 app/application/app/Services/Registry/V2/AuthorizationService.php create mode 100644 app/application/app/Services/Registry/V2/Base32CommandHandler.php create mode 100644 app/application/app/Services/Registry/V2/ContentPrivateKeyCommand.php create mode 100644 app/application/app/Services/Registry/V2/EventService.php create mode 100644 app/application/app/Services/Registry/V2/JwtCommand.php create mode 100644 app/application/app/Services/Registry/V2/KidCommand.php create mode 100644 app/application/app/Services/Registry/V2/PayloadCommand.php create mode 100644 app/application/app/Services/Repository/BuilderCommand.php create mode 100644 app/application/app/Services/Repository/RepositoryCommandHandler.php create mode 100644 app/application/app/Services/Site/AccessTokenService.php create mode 100644 app/application/app/Services/Site/RepositoryService.php create mode 100644 app/application/app/Services/Site/TagRepositoryService.php create mode 100644 app/application/app/Services/Site/UserService.php create mode 100644 app/application/app/Services/TagRepository/IncrementPullCommand.php create mode 100644 app/application/app/Services/TagRepository/TagRepositoryCommandHandler.php create mode 100644 app/application/app/Services/TagRepository/UpdateOrCreateCommand.php create mode 100644 app/application/app/View/Components/Site/Forms/Textarea.php create mode 100644 app/application/config/registry.php create mode 100644 app/application/config/sanctum.php create mode 100644 app/application/database/migrations/2024_06_18_195947_create_personal_access_tokens_table.php create mode 100644 app/application/database/migrations/2024_06_23_170638_create_repositories.php create mode 100644 app/application/lang/en/access-token-permission.php create mode 100644 app/application/lang/ru/access-token-permission.php create mode 100644 app/application/resources/certs/.gitignore create mode 100644 app/application/resources/certs/readme.txt create mode 100644 app/application/resources/site/images/icons/download.svg create mode 100644 app/application/resources/site/js/block-copy.js create mode 100644 app/application/resources/site/scss/block-profile.scss create mode 100644 app/application/resources/site/scss/generated-token.scss create mode 100644 app/application/resources/site/scss/pagination.scss create mode 100644 app/application/resources/site/scss/repository.scss create mode 100644 app/application/resources/site/scss/table.scss create mode 100644 app/application/resources/views/components/site/forms/textarea.blade.php create mode 100644 app/application/resources/views/pagination/site.blade.php create mode 100644 app/application/resources/views/site/access-tokens/create.blade.php create mode 100644 app/application/resources/views/site/access-tokens/edit.blade.php create mode 100644 app/application/resources/views/site/access-tokens/index.blade.php delete mode 100644 app/application/resources/views/site/home/index.blade.php create mode 100644 app/application/resources/views/site/repositories/_from.blade.php create mode 100644 app/application/resources/views/site/repositories/_repository-page.blade.php create mode 100644 app/application/resources/views/site/repositories/create.blade.php create mode 100644 app/application/resources/views/site/repositories/edit.blade.php create mode 100644 app/application/resources/views/site/repositories/repositories.blade.php create mode 100644 app/application/resources/views/site/repositories/repository.blade.php create mode 100644 app/application/resources/views/site/repositories/tags.blade.php create mode 100644 app/application/resources/views/site/user/user.blade.php create mode 100644 app/application/routes/registry.php create mode 100644 certs/.gitignore create mode 100644 certs/readme.txt create mode 100644 registry/.gitignore create mode 100644 registry/config.yml.exmplate create mode 100644 registry/nginx/nginx.conf diff --git a/.env.example b/.env.example index b9a55e7..509cd52 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ DOCKER_APP_PORT=8080 DOCKER_CAPTCHA_PORT=8081 DOCKER_CAPTCHA_WEBSOCKET_PORT=8082 +DOCKER_REGISTRY_WEB=8083 DOCKER_DB_PORT=3306 MYSQL_ROOT_PASSWORD=root_pass DB_DATABASE=registry diff --git a/app/application/.env.example b/app/application/.env.example index 1bd7e39..40be2a7 100644 --- a/app/application/.env.example +++ b/app/application/.env.example @@ -6,6 +6,14 @@ DEBUGBAR_OPEN_STORAGE=true APP_TIMEZONE=UTC APP_URL= +REGISTRY_SERVICE_NAME=container_registry +REGISTRY_SERVICE_HTTP=http://real_address_registry +REGISTRY_PRIVATE_KEY_NAME=registry-auth1.key +REGISTRY_ISSUER=token_issuer +REGISTRY_ALGORITHM=RS256 +REGISTRY_EXPIRES_IN_SECONDS=600 +REGISTRY_TOKEN_FOR_NOTIFICATIONS=token_for_notifications + APP_CAPTCHA=false CAPTCHA_API_DOMAIN=http://your-domain-captcha-or-IP:8081 CAPTCHA_PRIVATE_TOKEN= diff --git a/app/application/app/Contracts/Services/Registry/Base32.php b/app/application/app/Contracts/Services/Registry/Base32.php new file mode 100644 index 0000000..3568cc4 --- /dev/null +++ b/app/application/app/Contracts/Services/Registry/Base32.php @@ -0,0 +1,9 @@ +isPublic; + } +} diff --git a/app/application/app/Dto/Service/Admin/User/StoreUpdate.php b/app/application/app/Dto/Service/Admin/User/StoreUpdate.php index 28316f1..2963301 100644 --- a/app/application/app/Dto/Service/Admin/User/StoreUpdate.php +++ b/app/application/app/Dto/Service/Admin/User/StoreUpdate.php @@ -10,9 +10,9 @@ public function __construct( private string $name, private string $email, - private string $username, private bool $isActive, private ManyRoleDto $roles, + private ?string $username = null, private ?string $password = null ) { } @@ -36,7 +36,7 @@ public function getRoles(): ManyRoleDto return $this->roles; } - public function getUsername(): string + public function getUsername(): ?string { return $this->username; } diff --git a/app/application/app/Dto/Service/Registry/V2/AuthorizationConfig.php b/app/application/app/Dto/Service/Registry/V2/AuthorizationConfig.php new file mode 100644 index 0000000..fb62e6c --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/AuthorizationConfig.php @@ -0,0 +1,39 @@ +serviceName; + } + + public function getPrivateKeyName(): string + { + return $this->privateKeyName; + } + + public function getIssuer(): string + { + return $this->issuer; + } + + public function getAlgorithm(): string + { + return $this->algorithm; + } + + public function getExpiresInSeconds(): int + { + return $this->expiresInSeconds; + } +} diff --git a/app/application/app/Dto/Service/Registry/V2/AuthorizationService/Authorization.php b/app/application/app/Dto/Service/Registry/V2/AuthorizationService/Authorization.php new file mode 100644 index 0000000..0cd25eb --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/AuthorizationService/Authorization.php @@ -0,0 +1,30 @@ +service; + } + + public function getScope(): ?Scope + { + return $this->scope; + } + + public function getUser(): ?User + { + return $this->user; + } +} diff --git a/app/application/app/Dto/Service/Registry/V2/AuthorizationService/Scope.php b/app/application/app/Dto/Service/Registry/V2/AuthorizationService/Scope.php new file mode 100644 index 0000000..c43a14a --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/AuthorizationService/Scope.php @@ -0,0 +1,41 @@ +username; + } + + public function getRepositoryName(): string + { + return $this->repositoryName; + } + + public function getActions(): array + { + return $this->actions; + } +} diff --git a/app/application/app/Dto/Service/Registry/V2/EventService/DataProcessing.php b/app/application/app/Dto/Service/Registry/V2/EventService/DataProcessing.php new file mode 100644 index 0000000..cd8ea9c --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/EventService/DataProcessing.php @@ -0,0 +1,17 @@ +events; + } +} diff --git a/app/application/app/Dto/Service/Registry/V2/EventService/Event.php b/app/application/app/Dto/Service/Registry/V2/EventService/Event.php new file mode 100644 index 0000000..948309d --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/EventService/Event.php @@ -0,0 +1,29 @@ +timestamp; + } + + public function getAction(): string + { + return $this->action; + } + + public function getTarget(): array + { + return $this->target; + } +} diff --git a/app/application/app/Dto/Service/Registry/V2/EventService/Events.php b/app/application/app/Dto/Service/Registry/V2/EventService/Events.php new file mode 100644 index 0000000..47462bc --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/EventService/Events.php @@ -0,0 +1,21 @@ +events[] = $event; + } + + public function getEvents(): array + { + return $this->events; + } +} diff --git a/app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPull.php b/app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPull.php new file mode 100644 index 0000000..b4c90a7 --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPull.php @@ -0,0 +1,33 @@ +username; + } + + public function getRepositoryName(): string + { + return $this->repositoryName; + } + + public function getTag(): string + { + return $this->tag; + } + + public function getDigest(): string + { + return $this->digest; + } +} diff --git a/app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPush.php b/app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPush.php new file mode 100644 index 0000000..1e2fe91 --- /dev/null +++ b/app/application/app/Dto/Service/Registry/V2/EventService/ImageDataPush.php @@ -0,0 +1,39 @@ +username; + } + + public function getRepositoryName(): string + { + return $this->repositoryName; + } + + public function getTag(): string + { + return $this->tag; + } + + public function getDigest(): string + { + return $this->digest; + } + + public function getSize(): int + { + return $this->size; + } +} diff --git a/app/application/app/Dto/Service/Site/AccessTokens/Index.php b/app/application/app/Dto/Service/Site/AccessTokens/Index.php new file mode 100644 index 0000000..086a577 --- /dev/null +++ b/app/application/app/Dto/Service/Site/AccessTokens/Index.php @@ -0,0 +1,14 @@ +name; + } + + public function getPermission(): AccessTokenPermission + { + return $this->permission; + } +} diff --git a/app/application/app/Dto/Service/Site/Repository/Repositories.php b/app/application/app/Dto/Service/Site/Repository/Repositories.php new file mode 100644 index 0000000..bde0d0c --- /dev/null +++ b/app/application/app/Dto/Service/Site/Repository/Repositories.php @@ -0,0 +1,14 @@ +description; + } + + public function getOverview(): ?string + { + return $this->overview; + } + + public function isPublic(): bool + { + return $this->isPublic; + } + + public function getName(): ?string + { + return $this->name; + } +} diff --git a/app/application/app/Dto/Service/Site/TagRepository/Tags.php b/app/application/app/Dto/Service/Site/TagRepository/Tags.php new file mode 100644 index 0000000..292999a --- /dev/null +++ b/app/application/app/Dto/Service/Site/TagRepository/Tags.php @@ -0,0 +1,14 @@ +name); + } + + public static function toArray(): array + { + $choices = []; + foreach (self::cases() as $lang) { + $choices[] = [ + 'name' => $lang->name, + 'value' => $lang->value, + 'title' => $lang->getTitle(), + ]; + } + return $choices; + } + + public static function toCollection(): Collection + { + return collect(self::toArray()); + } +} diff --git a/app/application/app/Enums/CacheTag.php b/app/application/app/Enums/CacheTag.php new file mode 100644 index 0000000..baf4e51 --- /dev/null +++ b/app/application/app/Enums/CacheTag.php @@ -0,0 +1,16 @@ +value); + } +} diff --git a/app/application/app/Enums/Morph.php b/app/application/app/Enums/Morph.php new file mode 100644 index 0000000..dcbc5a4 --- /dev/null +++ b/app/application/app/Enums/Morph.php @@ -0,0 +1,31 @@ + User::class, + }; + } + + public function getFolderName(): string + { + return $this->name; + } + + public static function map(): array + { + $map = []; + foreach (self::cases() as $item) { + $map[$item->value] = $item->getPathModel(); + } + return $map; + } +} diff --git a/app/application/app/Exceptions/Jobs/Registry/V2/JobException.php b/app/application/app/Exceptions/Jobs/Registry/V2/JobException.php new file mode 100644 index 0000000..f27676f --- /dev/null +++ b/app/application/app/Exceptions/Jobs/Registry/V2/JobException.php @@ -0,0 +1,8 @@ +rtrim('.') ->value(); } + + public static function shortDigest(string $digest): string + { + $digest = Str::of($digest)->explode(':', 2)->last(); + return Str::of($digest)->take(12)->toString(); + } } diff --git a/app/application/app/Helpers/Number.php b/app/application/app/Helpers/Number.php new file mode 100644 index 0000000..51c0e81 --- /dev/null +++ b/app/application/app/Helpers/Number.php @@ -0,0 +1,74 @@ + 'K', + 6 => 'M', + 9 => 'B', + 12 => 'T', + 15 => 'Q', + ]; + + /** + * Convert the number to its human-readable equivalent. + * + * @param int|float $number + * @return string + */ + public static function abbreviate(int|float $number): string + { + if ($number < 0) { + throw new \InvalidArgumentException('Number cannot be negative'); + } + + return self::summarize($number); + } + + /** + * $size - Size in bytes + * @param int $size + * + * @return string + */ + public static function sizeForHumans(int $size): string + { + return LaravelNumber::fileSize($size); + } + + private static function summarize(int|float $number, bool $hidePlusSymbol = false): string + { + $symbol = '+'; + if ($hidePlusSymbol) { + $symbol = ''; + } + + if ($number < 1000) { + return (string) $number; + } + + if ($number >= 1e15) { + $units = self::ABBREVIATE_UNITS; + return sprintf('%s' . end($units), self::summarize($number / 1e15, hidePlusSymbol: true)) . $symbol; + } + + $numberExponent = \floor(\log10($number)); + $displayExponent = $numberExponent - ($numberExponent % 3); + $number /= \pow(10, $displayExponent); + $number = \explode('.', (string) $number, 2); + + $remainder = $number[1] ?? 0; + $number = $number[0] ?? 0; + + if ($remainder === 0) { + $symbol = ''; + } + $abbreviated = self::ABBREVIATE_UNITS[$displayExponent] ?? ''; + + return (string) $number . $abbreviated . $symbol; + } +} diff --git a/app/application/app/Http/Controllers/Controller.php b/app/application/app/Http/Controllers/Controller.php index fecb479..e7434ae 100644 --- a/app/application/app/Http/Controllers/Controller.php +++ b/app/application/app/Http/Controllers/Controller.php @@ -2,7 +2,17 @@ namespace App\Http\Controllers; +use App\Contracts\ServiceResultError as ServiceResultErrorContract; +use Illuminate\Http\Response; + abstract class Controller { - // + final protected function errors(ServiceResultErrorContract $result): never + { + if ($result->getCode() === Response::HTTP_UNPROCESSABLE_ENTITY) { + redirect()->back()->withInput()->withErrors($result->getErrors()); + exit; + } + abort($result->getCode(), $result->getMessage()); + } } diff --git a/app/application/app/Http/Controllers/Registry/Controller.php b/app/application/app/Http/Controllers/Registry/Controller.php new file mode 100644 index 0000000..bab6779 --- /dev/null +++ b/app/application/app/Http/Controllers/Registry/Controller.php @@ -0,0 +1,10 @@ +getDto(); + $result = $this->authorizationService->authorization($data); + if (!$result->isSuccess()) { + return response()->json($result->getData())->setStatusCode($result->getCode()); + } + + return \response()->json($result->getData()); + } + + public function event(EventRequest $request): JsonResponse + { + $data = $request->getDto(); + $result = $this->eventService->dataProcessing($data); + if (!$result->isSuccess()) { + return response()->json($result->getData())->setStatusCode($result->getCode()); + } + + return \response()->json(['message' => $result->getMessage()]); + } +} diff --git a/app/application/app/Http/Controllers/Site/AccessTokensController.php b/app/application/app/Http/Controllers/Site/AccessTokensController.php new file mode 100644 index 0000000..0db20bd --- /dev/null +++ b/app/application/app/Http/Controllers/Site/AccessTokensController.php @@ -0,0 +1,94 @@ +user(); + $data = $request->getDto(); + $querySettingsDto = new QuerySettingsDto( + limit: 20, + page: $data->getPage(), + queryWith: [] + ); + + $result = $this->accessTokenService->index($querySettingsDto, $user); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.access-tokens.index', $result->getData()); + } + + public function create(Request $request): View + { + $user = $request->user(); + $result = $this->accessTokenService->create($user); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.access-tokens.create', $result->getData()); + } + + public function edit(int $id, Request $request): View + { + $user = $request->user(); + $result = $this->accessTokenService->edit($id, $user); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.access-tokens.edit', $result->getData()); + } + + public function store(StoreUpdateRequest $request): JsonResponse + { + $data = $request->getDto(); + $user = $request->user(); + $result = $this->accessTokenService->store($data, $user); + if ($result->isError()) { + return response()->json($result->getData())->setStatusCode($result->getCode()); + } + return response()->json(new Store($result)); + } + + public function update(int $id, StoreUpdateRequest $request): RedirectResponse + { + $data = $request->getDto(); + $user = $request->user(); + $result = $this->accessTokenService->update($id, $data, $user); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('profile.access-tokens.edit', $result->getModel())->withSuccess($result->getMessage()); + } + + public function destroy(int $id, Request $request): RedirectResponse + { + $user = $request->user(); + $result = $this->accessTokenService->destroy($id, $user); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('profile.access-tokens.index')->withSuccess($result->getMessage()); + } +} diff --git a/app/application/app/Http/Controllers/Site/Controller.php b/app/application/app/Http/Controllers/Site/Controller.php index 2cc8d8d..539dd1f 100644 --- a/app/application/app/Http/Controllers/Site/Controller.php +++ b/app/application/app/Http/Controllers/Site/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers\Site; -abstract class Controller +use App\Http\Controllers\Controller as BaseController; + +abstract class Controller extends BaseController { - // + } diff --git a/app/application/app/Http/Controllers/Site/HomeController.php b/app/application/app/Http/Controllers/Site/HomeController.php deleted file mode 100644 index c1b3cf4..0000000 --- a/app/application/app/Http/Controllers/Site/HomeController.php +++ /dev/null @@ -1,13 +0,0 @@ -getDto()->getPage(), + queryWith: ['user'] + ); + $result = $this->repositoryService->repositories($querySettingsDto); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.repositories.repositories', $result->getData()); + } + + public function repository(string $username, string $repositoryName, Request $request): View + { + $result = $this->repositoryService->repository($username, $repositoryName, $request->user()); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.repositories.repository', $result->getData()); + } + + public function create(string $username, Request $request): View + { + $result = $this->repositoryService->create($username, $request->user()); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.repositories.create', $result->getData()); + } + + public function edit(string $username, string $repositoryName, Request $request) + { + $result = $this->repositoryService->edit($username, $repositoryName, $request->user()); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.repositories.edit', $result->getData()); + } + + public function store(string $username, StoreUpdateRequest $request): RedirectResponse + { + $data = $request->getDto(); + $result = $this->repositoryService->store($username, $data, $request->user()); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('repository.edit', ['repository_name' => $result->getModel()->name, 'username' => $username])->withSuccess($result->getMessage()); + } + + public function update(string $username, string $repositoryName, StoreUpdateRequest $request): RedirectResponse + { + $data = $request->getDto(); + $result = $this->repositoryService->update($username, $repositoryName, $data, $request->user()); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('repository.edit', ['repository_name' => $result->getModel()->name, 'username' => $username])->withSuccess($result->getMessage()); + } + + public function destroy(string $username, string $repositoryName, Request $request): RedirectResponse + { + $user = $request->user(); + $result = $this->repositoryService->destroy($username, $repositoryName, $user); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('user.repositories', ['username' => $username])->withSuccess($result->getMessage()); + } +} diff --git a/app/application/app/Http/Controllers/Site/TagRepositoriesController.php b/app/application/app/Http/Controllers/Site/TagRepositoriesController.php new file mode 100644 index 0000000..18cf375 --- /dev/null +++ b/app/application/app/Http/Controllers/Site/TagRepositoriesController.php @@ -0,0 +1,42 @@ +getDto()->getPage(), + queryWith: ['repository', 'architecture'] + ); + $result = $this->tagRepositoryService->tags($username, $repositoryName, $querySettingsDto, $request->user()); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.repositories.tags', $result->getData()); + } + + public function destroy(string $username, string $repositoryName, int $tagId, TagsRequest $request): RedirectResponse + { + $user = $request->user(); + $result = $this->tagRepositoryService->destroy($username, $repositoryName, $tagId, $user); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('repository.tags', ['username' => $username, 'repository_name' => $repositoryName])->withSuccess($result->getMessage()); + } +} diff --git a/app/application/app/Http/Controllers/Site/UserController.php b/app/application/app/Http/Controllers/Site/UserController.php new file mode 100644 index 0000000..86efd8c --- /dev/null +++ b/app/application/app/Http/Controllers/Site/UserController.php @@ -0,0 +1,30 @@ +getDto()->getPage(), + queryWith: ['user'] + ); + $result = $this->userService->repositories($username, $querySettingsDto, $request->user()); + if ($result->isError()) { + $this->errors($result); + } + + return view('site.user.user', $result->getData()); + } +} diff --git a/app/application/app/Http/Middleware/RegistryAuth.php b/app/application/app/Http/Middleware/RegistryAuth.php new file mode 100644 index 0000000..ee9784a --- /dev/null +++ b/app/application/app/Http/Middleware/RegistryAuth.php @@ -0,0 +1,69 @@ +getClientIp(), maxAttempts: 15)) { + $message = __('http-statuses.' . Response::HTTP_TOO_MANY_REQUESTS, [], 'en'); + \abort( + new Response($message, Response::HTTP_TOO_MANY_REQUESTS) + ); + } + + if ($request->hasHeader('authorization') && $this->authenticate($request) === false) { + RateLimiter::increment(key: 'registry-auth:' . $request->getClientIp(), decaySeconds: 3600); + throw new \Illuminate\Auth\AuthenticationException(); + } + + return $next($request); + } + + private function authenticate(Request $request): bool + { + $basicHeader = $this->getHeaderBasic($request); + $authenticated = base64_decode($basicHeader); + if ($authenticated === false) { + return false; + } + + $authenticated = explode(':', $authenticated, 2); + if (count($authenticated) !== 2) { + return false; + } + [$username, $token] = $authenticated; + + $request->headers->set('authorization', 'Bearer ' . $token); + Config::set('auth.defaults.guard', 'sanctum'); + if ($request->user()?->username === Str::lower($username)) { + return true; + } + + return false; + } + + private function getHeaderBasic(Request $request): string + { + $basicHeader = $request->header('authorization'); + $position = strrpos($basicHeader, 'Basic '); + + if ($position !== false) { + return substr($basicHeader, $position + 6); + } + + return $basicHeader; + } +} diff --git a/app/application/app/Http/Middleware/RegistryAuthNotification.php b/app/application/app/Http/Middleware/RegistryAuthNotification.php new file mode 100644 index 0000000..3808e56 --- /dev/null +++ b/app/application/app/Http/Middleware/RegistryAuthNotification.php @@ -0,0 +1,34 @@ +getClientIp(), maxAttempts: 3)) { + $message = __('http-statuses.' . Response::HTTP_TOO_MANY_REQUESTS, [], 'en'); + \abort( + new Response($message, Response::HTTP_TOO_MANY_REQUESTS) + ); + } + + if ($request->header('authorization') !== config('registry.token_for_notifications')) { + RateLimiter::increment(key: 'registry-auth-notification:' . $request->getClientIp(), decaySeconds: 600); + throw new \Illuminate\Auth\AuthenticationException(); + } + + return $next($request); + } +} diff --git a/app/application/app/Http/Requests/Admin/Users/StoreUpdateRequest.php b/app/application/app/Http/Requests/Admin/Users/StoreUpdateRequest.php index ccfaefe..cf6a123 100644 --- a/app/application/app/Http/Requests/Admin/Users/StoreUpdateRequest.php +++ b/app/application/app/Http/Requests/Admin/Users/StoreUpdateRequest.php @@ -20,13 +20,13 @@ public function rules(): array $rules = [ 'name' => ['required', 'max:255'], 'email' => ['required', 'email', 'max:255'], - 'username' => ['required', 'string', new Username()], 'is_active' => ['required', 'boolean'], 'roles' => ['array', Rule::exists('roles', 'id')], ]; if ($this->getMethod() === 'POST') { $rules['password'] = ['required', Password::default()]; + $rules['username'] = ['required', 'string', new Username()]; } return $rules; @@ -38,9 +38,9 @@ public function getDto(): StoreUpdate return new StoreUpdate( name: $this->input('name'), email: $this->input('email'), - username: $this->input('username'), isActive: (bool) $this->input('is_active', false), roles: new ManyRoleDto($this->input('roles', [])), + username: $this->input('username', null), password: $this->input('password', null), ); } diff --git a/app/application/app/Http/Requests/Registry/Version2/AuthRequest.php b/app/application/app/Http/Requests/Registry/Version2/AuthRequest.php new file mode 100644 index 0000000..9b94cf6 --- /dev/null +++ b/app/application/app/Http/Requests/Registry/Version2/AuthRequest.php @@ -0,0 +1,36 @@ + ['required', 'string'], + 'scope' => ['nullable', 'string', 'regex:/^repository\:[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*\:(push,pull|pull,push|pull)$/'], + ]; + } + + public function getDto(): Authorization + { + $scope = $this->input('scope', null); + if (! \is_null($scope)) { + $scope = Scope::parse($scope); + } + + return new Authorization( + service: $this->input('service'), + scope: $scope, + user: $this->user(), + ); + } +} diff --git a/app/application/app/Http/Requests/Registry/Version2/EventRequest.php b/app/application/app/Http/Requests/Registry/Version2/EventRequest.php new file mode 100644 index 0000000..546cfe1 --- /dev/null +++ b/app/application/app/Http/Requests/Registry/Version2/EventRequest.php @@ -0,0 +1,42 @@ + 'required|array', + 'events.*.timestamp' => 'required|string', + 'events.*.action' => 'required|string', + 'events.*.target' => 'required|array', + ]; + } + + public function getDto(): DataProcessing + { + $events = new Events(); + + foreach ($this->input('events', []) as $event) { + $events->addEvent( + new Event( + timestamp: $event['timestamp'] ?? '', + action: $event['action'] ?? '', + target: $event['target'] ?? [], + ) + ); + } + + return new DataProcessing($events); + } +} diff --git a/app/application/app/Http/Requests/Site/AccessTokens/IndexRequest.php b/app/application/app/Http/Requests/Site/AccessTokens/IndexRequest.php new file mode 100644 index 0000000..47d2538 --- /dev/null +++ b/app/application/app/Http/Requests/Site/AccessTokens/IndexRequest.php @@ -0,0 +1,28 @@ +redirect = route('profile.access-tokens.index'); + return [ + 'page' => ['nullable', 'numeric', 'min:1'] + ]; + } + + public function getDto(): Index + { + return new Index( + page: (int) $this->input('page', 1) + ); + } +} diff --git a/app/application/app/Http/Requests/Site/AccessTokens/StoreUpdateRequest.php b/app/application/app/Http/Requests/Site/AccessTokens/StoreUpdateRequest.php new file mode 100644 index 0000000..50cb69e --- /dev/null +++ b/app/application/app/Http/Requests/Site/AccessTokens/StoreUpdateRequest.php @@ -0,0 +1,34 @@ + ['required', 'string', 'max:255'], + 'permissions' => ['required', 'string', new Enum(AccessTokenPermission::class)], + ]; + } + + + public function getDto(): StoreUpdate + { + return new StoreUpdate( + name: $this->input('name'), + permission: AccessTokenPermission::from( + $this->input('permissions') + ), + ); + } +} diff --git a/app/application/app/Http/Requests/Site/Repositories/RepositoriesRequest.php b/app/application/app/Http/Requests/Site/Repositories/RepositoriesRequest.php new file mode 100644 index 0000000..2c118bc --- /dev/null +++ b/app/application/app/Http/Requests/Site/Repositories/RepositoriesRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'numeric', 'min:1'] + ]; + } + + public function getDto(): Repositories + { + return new Repositories( + page: (int) $this->input('page', 1) + ); + } +} diff --git a/app/application/app/Http/Requests/Site/Repositories/StoreUpdateRequest.php b/app/application/app/Http/Requests/Site/Repositories/StoreUpdateRequest.php new file mode 100644 index 0000000..ffc996e --- /dev/null +++ b/app/application/app/Http/Requests/Site/Repositories/StoreUpdateRequest.php @@ -0,0 +1,40 @@ + ['nullable', 'string', 'max:300'], + 'overview' => ['nullable', 'string'], + 'is_public' => ['required', 'boolean'], + ]; + + if ($this->getMethod() === 'POST') { + $rules['name'] = ['required', 'string', new Repository()]; + } + + return $rules; + } + + + public function getDto(): StoreUpdate + { + return new StoreUpdate( + isPublic: (bool) $this->input('is_public', false), + description: $this->input('description'), + overview: $this->input('overview'), + name: $this->input('name'), + ); + } +} diff --git a/app/application/app/Http/Requests/Site/TagRepositories/TagsRequest.php b/app/application/app/Http/Requests/Site/TagRepositories/TagsRequest.php new file mode 100644 index 0000000..5d0fcf6 --- /dev/null +++ b/app/application/app/Http/Requests/Site/TagRepositories/TagsRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'numeric', 'min:1'] + ]; + } + + public function getDto(): Tags + { + return new Tags( + page: (int) $this->input('page', 1) + ); + } +} diff --git a/app/application/app/Http/Requests/Site/User/UserRequest.php b/app/application/app/Http/Requests/Site/User/UserRequest.php new file mode 100644 index 0000000..acf4d2e --- /dev/null +++ b/app/application/app/Http/Requests/Site/User/UserRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'numeric', 'min:1'] + ]; + } + + public function getDto(): User + { + return new User( + page: (int) $this->input('page', 1) + ); + } +} diff --git a/app/application/app/Http/Resources/Site/AccessTokens/Store.php b/app/application/app/Http/Resources/Site/AccessTokens/Store.php new file mode 100644 index 0000000..0b94d19 --- /dev/null +++ b/app/application/app/Http/Resources/Site/AccessTokens/Store.php @@ -0,0 +1,24 @@ + $this->resource->getName(), + 'permission' => $this->resource->getPermission()->getTitle(), + 'token' => $this->resource->getToken(), + ]; + } +} diff --git a/app/application/app/Jobs/Registry/V2/ProcessEventPull.php b/app/application/app/Jobs/Registry/V2/ProcessEventPull.php new file mode 100644 index 0000000..993b320 --- /dev/null +++ b/app/application/app/Jobs/Registry/V2/ProcessEventPull.php @@ -0,0 +1,60 @@ +target['tag'])) { + return; + } + + [$username, $repositoryName] = \explode('/', $this->target['repository'], 2); + + $data = new ImageDataPull( + username: $username, + repositoryName: $repositoryName, + tag: $this->target['tag'], + digest: $this->target['digest'], + ); + $result = $eventService->pull($data); + if ($result->isError()) { + throw new JobException($result->getMessage()); + } + } + + /** + * Calculate the number of seconds to wait before retrying the job. + * + * @return array + */ + public function backoff(): array + { + return [10, 60, 600, 1800, 3600]; + } +} diff --git a/app/application/app/Jobs/Registry/V2/ProcessEventPush.php b/app/application/app/Jobs/Registry/V2/ProcessEventPush.php new file mode 100644 index 0000000..50c99f2 --- /dev/null +++ b/app/application/app/Jobs/Registry/V2/ProcessEventPush.php @@ -0,0 +1,70 @@ + + */ + public function backoff(): array + { + return [10, 60, 600, 1800, 3600]; + } + + /** + * Execute the job. + */ + public function handle(EventService $eventService): void + { + [$username, $repositoryName] = \explode('/', $this->target['repository'], 2); + + $size = (int) $this->target['size'] ?? 0; + $references = $this->target['references'] ?? []; + foreach ($references as $reference) { + $size += (int) $reference['size'] ?? 0; + } + + $data = new ImageDataPush( + username: $username, + repositoryName: $repositoryName, + tag: $this->target['tag'], + digest: $this->target['digest'], + size: $size, + ); + $result = $eventService->push($data); + if ($result->isError()) { + throw new JobException($result->getMessage()); + } + } +} diff --git a/app/application/app/Models/Architecture.php b/app/application/app/Models/Architecture.php new file mode 100644 index 0000000..6760423 --- /dev/null +++ b/app/application/app/Models/Architecture.php @@ -0,0 +1,28 @@ + + */ + protected $fillable = [ + 'name', + ]; + + public function os(): BelongsTo + { + return $this->belongsTo(OperatingSystem::class, 'operating_system_id'); + } +} diff --git a/app/application/app/Models/OperatingSystem.php b/app/application/app/Models/OperatingSystem.php new file mode 100644 index 0000000..90139b5 --- /dev/null +++ b/app/application/app/Models/OperatingSystem.php @@ -0,0 +1,28 @@ + + */ + protected $fillable = [ + 'name', + ]; + + public function architectures(): HasMany + { + return $this->hasMany(Architecture::class, 'operating_system_id'); + } +} diff --git a/app/application/app/Models/Repository.php b/app/application/app/Models/Repository.php new file mode 100644 index 0000000..71f1075 --- /dev/null +++ b/app/application/app/Models/Repository.php @@ -0,0 +1,71 @@ + true, + ]; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'name', + 'is_public', + 'description', + 'overview', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'is_public' => 'boolean', + ]; + } + + protected function repository(): Attribute + { + return Attribute::make( + get: fn () => $this->user->username . '/' . $this->name, + )->shouldCache(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function tags(): HasMany + { + return $this->hasMany(TagRepository::class, 'repository_id'); + } + + public function tag(): HasOne + { + return $this->hasOne(TagRepository::class, 'repository_id')->orderByDesc('updated_at'); + } +} diff --git a/app/application/app/Models/TagRepository.php b/app/application/app/Models/TagRepository.php new file mode 100644 index 0000000..352ee28 --- /dev/null +++ b/app/application/app/Models/TagRepository.php @@ -0,0 +1,53 @@ + 0, + ]; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'name', + 'digest', + 'size', + ]; + + public function repository(): BelongsTo + { + return $this->belongsTo(Repository::class); + } + + public function architecture(): BelongsTo + { + return $this->belongsTo(Architecture::class)->with('os'); + } +} diff --git a/app/application/app/Models/User.php b/app/application/app/Models/User.php index b602994..29dd85a 100644 --- a/app/application/app/Models/User.php +++ b/app/application/app/Models/User.php @@ -8,13 +8,15 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; final class User extends Authenticatable { - use HasFactory, Notifiable, SoftDeletes; + use HasApiTokens, HasFactory, Notifiable, SoftDeletes; /** * The model's default values for attributes. @@ -65,6 +67,11 @@ protected function casts(): array ]; } + public function repositories(): HasMany + { + return $this->hasMany(Repository::class, 'user_id', 'id'); + } + /** * Return the user's roles */ diff --git a/app/application/app/Policies/Policy.php b/app/application/app/Policies/Policy.php index 3b597ed..dba58ea 100644 --- a/app/application/app/Policies/Policy.php +++ b/app/application/app/Policies/Policy.php @@ -2,6 +2,8 @@ namespace App\Policies; +use App\Models\Repository; +use App\Models\TagRepository; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; @@ -9,13 +11,23 @@ { use HandlesAuthorization; - final public function before(User $user): ?bool + final public function before(User $user, string $ability = '', string|object $model = ''): ?bool { if ($user->is_active !== true) { return false; } - if ($user->is_admin) { + if ( + is_string($model) && $model === Repository::class + || is_object($model) && $model::class === Repository::class + + || is_string($model) && $model === TagRepository::class + || is_object($model) && $model::class === TagRepository::class + ) { + return null; + } + + if ($user->is_admin && $user->currentAccessToken() === null) { return true; } diff --git a/app/application/app/Policies/RepositoryPolicy.php b/app/application/app/Policies/RepositoryPolicy.php new file mode 100644 index 0000000..4cda0f0 --- /dev/null +++ b/app/application/app/Policies/RepositoryPolicy.php @@ -0,0 +1,89 @@ +id === $repositoryUser->id; + } + + public function view(User $user, Repository $repository): bool + { + if ($repository->is_public) { + return true; + } + + return $user->id === $repository->user_id; + } + + public function update(User $user, Repository $repository): bool + { + return $user->id === $repository->user_id; + } + + public function delete(User $user, Repository $repository): bool + { + return $user->id === $repository->user_id; + } + + public function pull(User $user, Repository $repository): bool + { + if ($repository->is_public) { + return true; + } + + if ($repository->user_id !== $user->id) { + return false; + } + + if ($user->currentAccessToken()) { + return $this->tokenCan( + [ + AccessTokenPermission::RepoReadWriteDelete->value, + AccessTokenPermission::RepoReadWrite->value, + AccessTokenPermission::RepoRead->value, + ], + $user->currentAccessToken() + ); + } + + return true; + } + + public function push(User $user, Repository $repository): bool + { + if ($repository->user_id !== $user->id) { + return false; + } + + if ($user->currentAccessToken()) { + return $this->tokenCan( + [ + AccessTokenPermission::RepoReadWriteDelete->value, + AccessTokenPermission::RepoReadWrite->value, + ], + $user->currentAccessToken() + ); + } + + return true; + } + + private function tokenCan(array $permissions, HasAbilities $abilities): bool + { + foreach ($permissions as $permission) { + if ($abilities->can($permission)) { + return true; + } + } + + return false; + } +} diff --git a/app/application/app/Policies/TagRepositoryPolicy.php b/app/application/app/Policies/TagRepositoryPolicy.php new file mode 100644 index 0000000..448587e --- /dev/null +++ b/app/application/app/Policies/TagRepositoryPolicy.php @@ -0,0 +1,15 @@ +id === $tag->repository?->user_id; + } +} diff --git a/app/application/app/Providers/AppServiceProvider.php b/app/application/app/Providers/AppServiceProvider.php index f49138a..ab148c9 100644 --- a/app/application/app/Providers/AppServiceProvider.php +++ b/app/application/app/Providers/AppServiceProvider.php @@ -2,17 +2,22 @@ namespace App\Providers; +use App\Enums\Morph; use App\Services\Search\CreateSearchInstanceCommand; use App\Services\Search\Search; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Sanctum\PersonalAccessToken; +use Laravel\Sanctum\Sanctum; -class AppServiceProvider extends ServiceProvider +final class AppServiceProvider extends ServiceProvider { /** * Register any application services. @@ -35,8 +40,25 @@ public function boot(): void $this->passwordDefaults(); + Relation::enforceMorphMap(Morph::map()); + + Route::pattern('username', '[a-z0-9]+(?:[._-][a-z0-9]+)*'); + Route::pattern('repository_name', '[a-z0-9]+(?:[._-][a-z0-9]+)*'); + Route::pattern('tag_name', '[a-zA-Z0-9]+(?:[._-][a-zA-Z0-9]+)*'); + Route::pattern('tag_id', '[0-9]+'); + $this->configureRateLimiting(); Gate::define('AdminPanel', [\App\Policies\AdminPanel::class, 'view']); + + Sanctum::authenticateAccessTokensUsing( + static function (PersonalAccessToken $accessToken, bool $is_valid) { + if ($accessToken->tokenable?->is_active === true) { + return $is_valid; + } + + return false; + } + ); } /** diff --git a/app/application/app/Providers/BladeProvider.php b/app/application/app/Providers/BladeProvider.php new file mode 100644 index 0000000..cf2527d --- /dev/null +++ b/app/application/app/Providers/BladeProvider.php @@ -0,0 +1,35 @@ +', $expression); + }); + + Blade::directive('short_digest', function ($expression) { + return sprintf('', $expression); + }); + + Blade::directive('size_for_humans', function ($expression) { + return sprintf('', $expression); + }); + } +} diff --git a/app/application/app/Providers/RegistryServiceProvider.php b/app/application/app/Providers/RegistryServiceProvider.php new file mode 100644 index 0000000..fed4dfb --- /dev/null +++ b/app/application/app/Providers/RegistryServiceProvider.php @@ -0,0 +1,45 @@ +app->bind(Base32::class, Base32CommandHandler::class); + + $this->app->bind(AuthorizationConfig::class, function () { + return new AuthorizationConfig( + serviceName: \config('registry.service_name' , ''), + privateKeyName: \config('registry.private_key_name' , ''), + issuer: \config('registry.issuer' , ''), + algorithm: \config('registry.algorithm' , 'RS256'), + expiresInSeconds: (int) \config('registry.expires_in_seconds' , 600), + ); + }); + + $this->app->bind(RequestCommandHandler::class, function (Application $app) { + return new RequestCommandHandler( + baseUrl: \config('registry.service_http') . '/v2' + ); + }); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + + } +} diff --git a/app/application/app/Repositories/AccessTokenRepository.php b/app/application/app/Repositories/AccessTokenRepository.php new file mode 100644 index 0000000..61cb91c --- /dev/null +++ b/app/application/app/Repositories/AccessTokenRepository.php @@ -0,0 +1,27 @@ +tokens()->with($with); + + return $this->createSearchInstanceCommand->execute($query); + } + + public function getTokenById(User $user, int $id): ?PersonalAccessToken + { + return $user->tokens()->where('id', $id)->first(); + } +} diff --git a/app/application/app/Repositories/RepositoryRepository.php b/app/application/app/Repositories/RepositoryRepository.php new file mode 100644 index 0000000..3d31ba4 --- /dev/null +++ b/app/application/app/Repositories/RepositoryRepository.php @@ -0,0 +1,61 @@ +repositories(); + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->where('name', $name)->first(); + } + + public function getRepositories(RepositoryBuilderDto $builderDto, array $with = []): Search + { + $query = $this->builderCommand->execute( + query: Repository::query()->with($with)->orderByDesc('updated_at'), + builderDto: $builderDto + ); + + return $this->createSearchInstanceCommand->execute($query); + } + + public function getUserRepositories(User $user, RepositoryBuilderDto $builderDto, array $with = []): Search + { + $query = $this->builderCommand->execute( + query: $user->repositories()->with($with)->orderByDesc('updated_at'), + builderDto: $builderDto + ); + + return $this->createSearchInstanceCommand->execute($query); + } + + public function isExistsName(User $user, string $name, ?int $exceptId = null): bool + { + return $user->repositories() + ->where('name', Str::lower($name)) + ->when($exceptId, function (Builder $query, int $exceptId) { + $query->where('id', '!=', $exceptId); + }) + ->withTrashed() + ->exists(); + } +} diff --git a/app/application/app/Repositories/TagRepositoryRepository.php b/app/application/app/Repositories/TagRepositoryRepository.php new file mode 100644 index 0000000..b547d71 --- /dev/null +++ b/app/application/app/Repositories/TagRepositoryRepository.php @@ -0,0 +1,27 @@ +where("id", $id)->first(); + } + + public function getTagsRepository(Repository $repository, array $with = []): Search + { + $query = $repository->tags()->with($with)->orderByDesc('updated_at'); + + return $this->createSearchInstanceCommand->execute($query); + } +} diff --git a/app/application/app/Repositories/UserRepository.php b/app/application/app/Repositories/UserRepository.php index 4023a1c..b1189b2 100644 --- a/app/application/app/Repositories/UserRepository.php +++ b/app/application/app/Repositories/UserRepository.php @@ -27,6 +27,16 @@ public function getUserByEmail(string $email): ?User return User::query()->where('email', Str::lower($email))->first(); } + public function getUserByUsername(string $username, bool $withTrashed = false): ?User + { + $query = User::query(); + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->where('username', $username)->first(); + } + public function getUsers(UserBuilderDto $userBuilderDto, array $with = []): Search { $query = $this->builderCommand->execute( diff --git a/app/application/app/Rules/Repository.php b/app/application/app/Rules/Repository.php index 122bc07..5758d19 100644 --- a/app/application/app/Rules/Repository.php +++ b/app/application/app/Rules/Repository.php @@ -21,9 +21,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $validator = Validator::make([ - 'slug' => $value, + 'name' => $value, ], [ - 'slug' => 'min:1|max:150|regex:/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/', + 'name' => 'min:1|max:150|regex:/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/', ]); if ($validator->fails()) { foreach ($validator->errors()->all() as $error) { diff --git a/app/application/app/ServiceResults/Registry/V2/ArchitectureResult.php b/app/application/app/ServiceResults/Registry/V2/ArchitectureResult.php new file mode 100644 index 0000000..16eddbd --- /dev/null +++ b/app/application/app/ServiceResults/Registry/V2/ArchitectureResult.php @@ -0,0 +1,18 @@ +architecture; + } +} diff --git a/app/application/app/ServiceResults/Site/AccessTokenService/StoreTokenResult.php b/app/application/app/ServiceResults/Site/AccessTokenService/StoreTokenResult.php new file mode 100644 index 0000000..1a3f5a3 --- /dev/null +++ b/app/application/app/ServiceResults/Site/AccessTokenService/StoreTokenResult.php @@ -0,0 +1,30 @@ +name; + } + + public function getPermission(): AccessTokenPermission + { + return $this->permission; + } + + public function getToken(): string + { + return $this->token; + } +} diff --git a/app/application/app/ServiceResults/TagRepository/IncrementPullResult.php b/app/application/app/ServiceResults/TagRepository/IncrementPullResult.php new file mode 100644 index 0000000..0806ce7 --- /dev/null +++ b/app/application/app/ServiceResults/TagRepository/IncrementPullResult.php @@ -0,0 +1,15 @@ +userRepository->getUserById($id); - if (is_null($modelUser)) { + if (\is_null($modelUser)) { return $this->errNotFound(__('Not Found')); } @@ -101,14 +101,14 @@ public function store(StoreUpdate $data, User $user): ServiceResultError | Store try { $modelUser = DB::transaction(function () use ($data) { $dataUser = $this->getDataUser($data); - + $dataUser['username'] = $data->getUsername(); $modelUser = $this->userCommandHandler->handleStore($dataUser, $data->getPassword()); $this->userCommandHandler->handleSyncRoles($modelUser, $data->getRoles()); return $modelUser; }); } catch (\Throwable $e) { - report($e); + \report($e); return $this->errService(__('Server Error')); } @@ -119,7 +119,7 @@ public function update(int $id, StoreUpdate $data, User $user): ServiceResultErr { $modelUser = $this->userRepository->getUserById($id); - if (is_null($modelUser)) { + if (\is_null($modelUser)) { return $this->errNotFound(__('Not Found')); } @@ -134,13 +134,6 @@ public function update(int $id, StoreUpdate $data, User $user): ServiceResultErr ); } - if ($this->userRepository->isExistsUsername($data->getUsername(), $modelUser->id)) { - return $this->errValidate( - __('validation.unique', ['attribute' => __('validation.attributes.username')]), - ['code' => __('validation.unique', ['attribute' => __('validation.attributes.username')])] - ); - } - try { $modelUser = DB::transaction(function () use ($data, $modelUser) { $dataUser = $this->getDataUser($data); @@ -151,7 +144,7 @@ public function update(int $id, StoreUpdate $data, User $user): ServiceResultErr return $modelUser; }); } catch (\Throwable $e) { - report($e); + \report($e); return $this->errService(__('Server Error')); } @@ -162,7 +155,7 @@ public function updatePassword(int $id, UpdatePassword $data, User $user): Servi { $modelUser = $this->userRepository->getUserById($id); - if (is_null($modelUser)) { + if (\is_null($modelUser)) { return $this->errNotFound(__('Not Found')); } @@ -173,7 +166,7 @@ public function updatePassword(int $id, UpdatePassword $data, User $user): Servi try { $this->userCommandHandler->handleUpdatePassword($modelUser, $data->getPassword()); } catch (\Throwable $e) { - report($e->getMessage()); + \report($e->getMessage()); return $this->errService($e->getMessage()); } @@ -184,7 +177,7 @@ public function destroy(int $id, User $user): ServiceResultError | ServiceResult { $modelUser = $this->userRepository->getUserById($id); - if (is_null($modelUser)) { + if (\is_null($modelUser)) { return $this->errNotFound(__('Not Found')); } @@ -197,7 +190,7 @@ public function destroy(int $id, User $user): ServiceResultError | ServiceResult $this->userCommandHandler->handleDestroy($modelUser); }); } catch (\Throwable $e) { - report($e); + \report($e); return $this->errService(__('Server Error')); } @@ -209,7 +202,6 @@ private function getDataUser(StoreUpdate $data): array return [ 'name' => $data->getName(), 'email' => $data->getEmail(), - 'username' => $data->getUsername(), 'is_active' => $data->isActive(), ]; } diff --git a/app/application/app/Services/Registry/V2/AccessCommand.php b/app/application/app/Services/Registry/V2/AccessCommand.php new file mode 100644 index 0000000..c05b21d --- /dev/null +++ b/app/application/app/Services/Registry/V2/AccessCommand.php @@ -0,0 +1,70 @@ +userRepository->getUserByUsername($scope->getUsername()); + if (!$userScope) { + return []; + } + + $repository = $this->repositoryRepository->getRepositoryByName($userScope, $scope->getRepositoryName()); + if (!$repository) { + return []; + } + + return [ + [ + 'type' => 'repository', + 'name' => $scope->getUsername() . '/' . $scope->getRepositoryName(), + 'actions' => $this->actions($userScope, $repository, $scope, $user), + ] + ]; + } + + private function actions(User $userScope, Repository $repository, Scope $scope, ?User $user): array + { + $actions = []; + if ($repository->is_public) { + $actions[] = 'pull'; + } + + if (\is_null($user)) { + return $actions; + } + + if ($userScope->id !== $user->id) { + return $actions; + } + + foreach ($scope->getActions() as $action) { + if ($action === 'pull' && $repository->is_public) { + continue; + } + + if ($user->can($action, $repository)) { + $actions[] = $action; + } + } + + return $actions; + } +} diff --git a/app/application/app/Services/Registry/V2/Api/ApiService.php b/app/application/app/Services/Registry/V2/Api/ApiService.php new file mode 100644 index 0000000..2f05bc9 --- /dev/null +++ b/app/application/app/Services/Registry/V2/Api/ApiService.php @@ -0,0 +1,67 @@ +getAccessRepository($repositoryRegistry, ['pull']), + ]; + $username = ''; + + try { + $token = $this->generateJwtTokenCommand->execute($access, $username); + + $path = $repositoryRegistry . '/manifests/' . $reference; + $result = $this->requestCommandHandler->handleGet($path, $token); + Log::info($result); + } catch (\Throwable $e) { + report($e); + return $this->errService($e->getMessage()); + } + + return $this->result($result); + } + + public function getBlob(string $repositoryRegistry, string $digest): ServiceResultError | ServiceResultArray + { + $access = [ + $this->getAccessRepository($repositoryRegistry, ['pull']), + ]; + $username = ''; + + try { + $token = $this->generateJwtTokenCommand->execute($access, $username); + + $path = $repositoryRegistry . '/blobs/' . $digest; + $result = $this->requestCommandHandler->handleGet($path, $token); + Log::info($result); + } catch (\Throwable $e) { + report($e); + return $this->errService($e->getMessage()); + } + + return $this->result($result); + } + + private function getAccessRepository(string $repository, array $actions): array + { + return [ + 'type' => 'repository', + 'name' => $repository, + 'actions' => $actions, + ]; + } +} diff --git a/app/application/app/Services/Registry/V2/Api/GenerateJwtTokenCommand.php b/app/application/app/Services/Registry/V2/Api/GenerateJwtTokenCommand.php new file mode 100644 index 0000000..a17dcf1 --- /dev/null +++ b/app/application/app/Services/Registry/V2/Api/GenerateJwtTokenCommand.php @@ -0,0 +1,22 @@ +payloadCommand->execute($this->authorizationConfig, $access, $username); + return $this->jwtCommand->execute($this->authorizationConfig, $payload); + } +} diff --git a/app/application/app/Services/Registry/V2/Api/RequestCommandHandler.php b/app/application/app/Services/Registry/V2/Api/RequestCommandHandler.php new file mode 100644 index 0000000..cbd8694 --- /dev/null +++ b/app/application/app/Services/Registry/V2/Api/RequestCommandHandler.php @@ -0,0 +1,31 @@ +beforeRequest($token)->get($path); + + return $response->json(); + } + + private function beforeRequest(string $token): PendingRequest + { + return Http::withHeaders([ + 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json', + 'Authorization' => 'Bearer ' . $token + ]) + ->timeout(15) + ->acceptJson() + ->baseUrl($this->baseUrl); + } +} diff --git a/app/application/app/Services/Registry/V2/ArchitectureCommand.php b/app/application/app/Services/Registry/V2/ArchitectureCommand.php new file mode 100644 index 0000000..29a1da9 --- /dev/null +++ b/app/application/app/Services/Registry/V2/ArchitectureCommand.php @@ -0,0 +1,62 @@ +apiService->getManifest($repositoryRegistry, $digest); + $manifest = $manifest->getData(); + if (!isset($manifest['config']) || !isset($manifest['config']['digest'])) { + throw new ManifestException('Got an empty result'); + } + + $manifestDigest = $manifest['config']['digest']; + unset($manifest); + $data = $this->apiService->getBlob($repositoryRegistry, $manifestDigest); + $data = $data->getData(); + + if (!isset($data['architecture']) || !isset($data['os'])) { + throw new BlobException('Didn\'t receive information about architecture and OS'); + } + + $architecture = $data['architecture']; + $os = $data['os']; + unset($data); + + /** + * @var OperatingSystem $osModel + */ + $osModel = OperatingSystem::firstOrCreate([ + 'name' => $os, + ]); + + /** + * @var Architecture $architectureModel + */ + $architectureModel = $osModel->architectures()->firstOrCreate([ + 'name' => $architecture, + ]); + } catch (\Throwable $e) { + report($e); + return $this->errService($e->getMessage()); + } + + return new ArchitectureResult($architectureModel); + } +} diff --git a/app/application/app/Services/Registry/V2/AuthorizationService.php b/app/application/app/Services/Registry/V2/AuthorizationService.php new file mode 100644 index 0000000..8e2f058 --- /dev/null +++ b/app/application/app/Services/Registry/V2/AuthorizationService.php @@ -0,0 +1,41 @@ +getService() !== $this->authorizationConfig->getServiceName()) { + return $this->errUnauthorized(__('Service name does not match')); + } + + try { + $access = $this->accessCommand->execute($authorization->getScope(), $authorization->getUser()); + $username = $authorization->getUser()?->username ?? ''; + $payload = $this->payloadCommand->execute($this->authorizationConfig, $access, $username); + + $jwt = $this->jwtCommand->execute($this->authorizationConfig, $payload); + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return $this->result([ + 'token' => $jwt + ]); + } +} diff --git a/app/application/app/Services/Registry/V2/Base32CommandHandler.php b/app/application/app/Services/Registry/V2/Base32CommandHandler.php new file mode 100644 index 0000000..9c11225 --- /dev/null +++ b/app/application/app/Services/Registry/V2/Base32CommandHandler.php @@ -0,0 +1,239 @@ + + */ + private $map = [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', // 7 + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', // 15 + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', // 23 + 'Y', + 'Z', + '2', + '3', + '4', + '5', + '6', + '7', // 31 + '=', // padding char + ]; + + /** + * @var array + */ + private $flippedMap = [ + 'A' => '0', + 'B' => '1', + 'C' => '2', + 'D' => '3', + 'E' => '4', + 'F' => '5', + 'G' => '6', + 'H' => '7', + 'I' => '8', + 'J' => '9', + 'K' => '10', + 'L' => '11', + 'M' => '12', + 'N' => '13', + 'O' => '14', + 'P' => '15', + 'Q' => '16', + 'R' => '17', + 'S' => '18', + 'T' => '19', + 'U' => '20', + 'V' => '21', + 'W' => '22', + 'X' => '23', + 'Y' => '24', + 'Z' => '25', + '2' => '26', + '3' => '27', + '4' => '28', + '5' => '29', + '6' => '30', + '7' => '31', + ]; + + /** + * Encodes data with base32. + * + * @param string $input The original data, as a string + * @param bool $padding Use padding false when encoding for urls + * + * @return string The Base32 encoded string + */ + public function encode(string $input, bool $padding = true): string + { + if ($input === '') { + return ''; + } + + $input = str_split($input); + $binaryString = ''; + + $inputCount = count($input); + for ($i = 0; $i < $inputCount; $i++) { + $binaryString .= str_pad(base_convert((string)ord($input[$i]), 10, 2), 8, '0', STR_PAD_LEFT); + } + + $fiveBitBinaryArray = str_split($binaryString, 5); + $base32 = ''; + $i = 0; + $fiveCount = count($fiveBitBinaryArray); + + while ($i < $fiveCount) { + $base32 .= $this->map[base_convert(str_pad($fiveBitBinaryArray[$i], 5, '0'), 2, 10)]; + $i++; + } + + $x = strlen($binaryString) % 40; + if ($padding && $x !== 0) { + if ($x === 8) { + return $base32 . str_repeat($this->map[32], 6); + } + if ($x === 16) { + return $base32 . str_repeat($this->map[32], 4); + } + if ($x === 24) { + return $base32 . str_repeat($this->map[32], 3); + } + if ($x === 32) { + return $base32 . $this->map[32]; + } + } + + return $base32; + } + + /** + * Decodes data encoded with base32. + * + * @param string $input The encoded data + * + * @throws InvalidArgumentException + * + * @return string The original data or false on failure + */ + public function decode(string $input): string + { + if ($input === '') { + return ''; + } + + $input = strtoupper($input); + $paddingCharCount = substr_count($input, $this->map[32]); + $allowedValues = [6, 4, 3, 1, 0]; + + if (!in_array($paddingCharCount, $allowedValues)) { + throw new InvalidArgumentException('Invalid base32 data'); + } + + for ($i = 0; $i < 4; $i++) { + if ( + $paddingCharCount === $allowedValues[$i] + && substr($input, -$allowedValues[$i]) !== str_repeat($this->map[32], $allowedValues[$i]) + ) { + throw new InvalidArgumentException('Invalid base32 data'); + } + } + + $input = str_replace('=', '', $input); + $input = str_split($input); + $binaryString = ''; + $count = count($input); + + for ($i = 0; $i < $count; $i += 8) { + $x = ''; + + if (!in_array($input[$i], $this->map)) { + throw new InvalidArgumentException('Invalid base32 data'); + } + + $x .= $this->decodeFlippedMap($i, $input); + + $eightBits = str_split($x, 8); + $bitCount = count($eightBits); + + $binaryString .= $this->decodeEightBits($bitCount, $eightBits); + } + + // Converting a binary (\0 terminated) string to a PHP string + return rtrim($binaryString, "\0"); + } + + /** + * Decode data with flipped map. + * + * @param int $i The encoded data index + * @param array $input The encoded data array + * + * @return string parted decoded string + */ + private function decodeFlippedMap(int $i, array $input) + { + $x = ''; + + for ($j = 0; $j < 8; $j++) { + if (!isset($input[$i + $j])) { + continue; + } + $x .= str_pad(base_convert($this->flippedMap[$input[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT); + } + + return $x; + } + + /** + * Decode data with eight bits. + * + * @param int $bitCount The eight bits count + * @param array $eightBits The eight bits + * + * @return string + */ + private function decodeEightBits(int $bitCount, array $eightBits) + { + $binaryString = ''; + + for ($z = 0; $z < $bitCount; $z++) { + $binaryString .= (($y = chr((int)base_convert($eightBits[$z], 2, 10))) || ord($y) === 48) ? $y : ''; + } + + return $binaryString; + } +} diff --git a/app/application/app/Services/Registry/V2/ContentPrivateKeyCommand.php b/app/application/app/Services/Registry/V2/ContentPrivateKeyCommand.php new file mode 100644 index 0000000..a09365b --- /dev/null +++ b/app/application/app/Services/Registry/V2/ContentPrivateKeyCommand.php @@ -0,0 +1,18 @@ +getCache() + ->remember(self::class . $privateKeyName, $seconds, function () use ($privateKeyName) { + return \file_get_contents( \resource_path('certs/' . $privateKeyName) ); + }); + } +} diff --git a/app/application/app/Services/Registry/V2/EventService.php b/app/application/app/Services/Registry/V2/EventService.php new file mode 100644 index 0000000..e67f47e --- /dev/null +++ b/app/application/app/Services/Registry/V2/EventService.php @@ -0,0 +1,120 @@ +getEvents()->getEvents(); + + foreach ($events as $event) { + /** + * @var Event $event + */ + + if ($event->getAction() === 'pull') { + ProcessEventPull::dispatch($event->getTarget())->onQueue('normal'); + continue; + } + + if ($event->getAction() === 'push') { + ProcessEventPush::dispatch($event->getTarget())->onQueue('high'); + continue; + } + + $exception = new DataProcessingException('Unknown action: ' . $event->getAction() . ' target: ' . print_r($event->getTarget(), true)); + \report($exception); + } + + return $this->ok('OK!'); + } + + public function push(ImageDataPush $data): ServiceResultError | ServiceResultSuccess + { + $user = $this->userRepository->getUserByUsername($data->getUsername(), withTrashed: true); + if (\is_null($user)) { + return $this->errNotFound(__('Not Found') . ' User: ' . $data->getUsername()); + } + + $repository = $this->repositoryRepository->getRepositoryByName($user, $data->getRepositoryName(), withTrashed: true); + if (\is_null($repository)) { + return $this->errNotFound(__('Not Found') . ' Repository: ' . $data->getRepositoryName()); + } + unset($user); + + $repositoryRegistry = $data->getUsername() . '/' . $data->getRepositoryName(); + $architecture = $this->architectureCommand->execute($repositoryRegistry, $data->getDigest()); + if ($architecture->isError()) { + return $architecture; + } + $architecture = $architecture->getArchitecture(); + + try { + DB::transaction(function () use ($repository, $architecture, $data) { + $dataTag = [ + 'digest' => $data->getDigest(), + 'size' => $data->getSize(), + ]; + $this->updateOrCreateCommand->execute($repository, $architecture, $data->getTag(), $dataTag); + }); + } catch (\Throwable $e) { + report($e); + return $this->errService($e->getMessage()); + } + + return $this->ok('OK!'); + } + + public function pull(ImageDataPull $data): ServiceResultError | ServiceResultSuccess + { + $user = $this->userRepository->getUserByUsername($data->getUsername(), withTrashed: true); + if (\is_null($user)) { + return $this->errNotFound(__('Not Found') . ' User: ' . $data->getUsername()); + } + + $repository = $this->repositoryRepository->getRepositoryByName($user, $data->getRepositoryName(), withTrashed: true); + if (\is_null($repository)) { + return $this->errNotFound(__('Not Found') . ' Repository: ' . $data->getRepositoryName()); + } + unset($user); + + try { + $result = DB::transaction(function () use ($repository, $data) { + return $this->incrementPullCommand->execute($repository, $data->getTag(), $data->getDigest()); + }); + if ($result->isError()) { + return $result; + } + } catch (\Throwable $e) { + report($e); + return $this->errService($e->getMessage()); + } + + return $this->ok('OK!'); + } +} diff --git a/app/application/app/Services/Registry/V2/JwtCommand.php b/app/application/app/Services/Registry/V2/JwtCommand.php new file mode 100644 index 0000000..8519ba8 --- /dev/null +++ b/app/application/app/Services/Registry/V2/JwtCommand.php @@ -0,0 +1,26 @@ +contentPrivateKeyCommand->execute($authorizationConfig->getPrivateKeyName()); + + return JWT::encode( + $payload, + $contentPrivateKey, + $authorizationConfig->getAlgorithm(), + $this->kidCommand->execute($authorizationConfig->getPrivateKeyName(), $contentPrivateKey) + ); + } +} diff --git a/app/application/app/Services/Registry/V2/KidCommand.php b/app/application/app/Services/Registry/V2/KidCommand.php new file mode 100644 index 0000000..350088e --- /dev/null +++ b/app/application/app/Services/Registry/V2/KidCommand.php @@ -0,0 +1,56 @@ +getCache() + ->remember(self::class . $privateKeyName, $seconds, function () use ($privateKeyContent) { + return $this->generate($privateKeyContent); + }); + } + + private function generate(string $privateKeyContent): string + { + // Extract public key from private key + $privateKey = openssl_pkey_get_private($privateKeyContent); + $publicKeyContent = openssl_pkey_get_details($privateKey)['key']; + + // Clean pem header and footer + $pattern = '/-----BEGIN [^-]*-----\r?\n?|-----END [^-]*-----\r?\n?/'; + $cleanedPem = trim(preg_replace($pattern, '', $publicKeyContent)); + + // Convert to der + $der = base64_decode(preg_replace('/\s+/', '', $cleanedPem)); + + // Calculate digest + $algorithm = hash_init('sha256'); + hash_update($algorithm, $der); + $digest = hash_final($algorithm, true); + + // Shorten digest to 30 bytes + $digest = substr($digest, 0, 30); + + $source = $this->base32CommandHandler->encode($digest); + $source = str_replace('=', '', $source); + + // Format with : + $result = []; + for ($i = 0; $i < strlen($source); $i += 4) { + $result[] = substr($source, $i, 4); + } + + return implode(':', $result); + } +} diff --git a/app/application/app/Services/Registry/V2/PayloadCommand.php b/app/application/app/Services/Registry/V2/PayloadCommand.php new file mode 100644 index 0000000..9d51637 --- /dev/null +++ b/app/application/app/Services/Registry/V2/PayloadCommand.php @@ -0,0 +1,22 @@ + $authorizationConfig->getIssuer(), + 'aud' => $authorizationConfig->getServiceName(), + 'iat' => \now()->getTimestamp(), + 'nbf' => \now()->getTimestamp(), + 'exp' => \now()->addSeconds($authorizationConfig->getExpiresInSeconds())->getTimestamp(), + 'sub' => $username, + 'jti' => \strtoupper(\bin2hex(\openssl_random_pseudo_bytes(16))), + 'access' => $access, + ]; + } +} diff --git a/app/application/app/Services/Repository/BuilderCommand.php b/app/application/app/Services/Repository/BuilderCommand.php new file mode 100644 index 0000000..0537a24 --- /dev/null +++ b/app/application/app/Services/Repository/BuilderCommand.php @@ -0,0 +1,17 @@ +when($builderDto->getIsPublic(), function (Builder $query) use ($builderDto) { + $query->where('is_public', $builderDto->getIsPublic()); + }); + } +} diff --git a/app/application/app/Services/Repository/RepositoryCommandHandler.php b/app/application/app/Services/Repository/RepositoryCommandHandler.php new file mode 100644 index 0000000..b380ed8 --- /dev/null +++ b/app/application/app/Services/Repository/RepositoryCommandHandler.php @@ -0,0 +1,34 @@ +repositories()->create($data); + + return $repository; + } + + public function handleUpdate(Repository $repository, array $data): Repository + { + if (isset($data['name'])) { + // It is not yet clear how to rename repositories in the registry service. + unset($data['name']); + } + + $repository->update($data); + return $repository; + } + + public function handleDestroy(Repository $repository): void + { + $repository->delete(); + } +} diff --git a/app/application/app/Services/Site/AccessTokenService.php b/app/application/app/Services/Site/AccessTokenService.php new file mode 100644 index 0000000..c901b2d --- /dev/null +++ b/app/application/app/Services/Site/AccessTokenService.php @@ -0,0 +1,113 @@ +accessTokenRepository->getUserTokens( + $user, + $querySettingsDto->getQueryWith() + )->pagination( + $querySettingsDto->getLimit(), + $querySettingsDto->getPage() + ); + + return $this->result([ + 'tokens' => $tokens + ]); + } + + public function create(User $user): ServiceResultError | ServiceResultArray + { + return $this->result([ + 'user' => $user, + 'serviceAddress' => config('registry.service_http'), + 'permissions' => AccessTokenPermission::toCollection()->pluck('title', 'value')->toArray(), + ]); + } + + public function edit(int $id, User $user): ServiceResultError | ServiceResultArray + { + $token = $this->accessTokenRepository->getTokenById($user, $id); + if (\is_null($token)) { + return $this->errNotFound(__('Not Found')); + } + + return $this->result([ + 'token' => $token, + 'user' => $user, + 'permissions' => AccessTokenPermission::toCollection()->pluck('title', 'value')->toArray(), + ]); + } + + public function store(StoreUpdate $data, User $user): ServiceResultError | StoreTokenResult + { + try { + $token = $user->createToken($data->getName(), [$data->getPermission()->value])->plainTextToken; + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return new StoreTokenResult( + name: $data->getName(), + permission: $data->getPermission(), + token: $token, + ); + } + + public function update(int $id, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult + { + $token = $this->accessTokenRepository->getTokenById($user, $id); + if (\is_null($token)) { + return $this->errNotFound(__('Not Found')); + } + + try { + $token->update([ + 'name' => $data->getName(), + 'abilities' => [$data->getPermission()->value], + ]); + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return $this->resultStoreUpdateModel($token, __('site.Token updated successfully')); + } + + public function destroy(int $id, User $user): ServiceResultError|ServiceResultSuccess + { + $token = $this->accessTokenRepository->getTokenById($user, $id); + if (\is_null($token)) { + return $this->errNotFound(__('Not Found')); + } + + try { + $token->delete(); + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return $this->ok(__('site.The token has been deleted')); + } +} diff --git a/app/application/app/Services/Site/RepositoryService.php b/app/application/app/Services/Site/RepositoryService.php new file mode 100644 index 0000000..0132af0 --- /dev/null +++ b/app/application/app/Services/Site/RepositoryService.php @@ -0,0 +1,209 @@ +repositoryRepository->getRepositories( + new RepositoryBuilder(isPublic: true), + $querySettingsDto->getQueryWith() + )->pagination( + $querySettingsDto->getLimit(), + $querySettingsDto->getPage() + ); + + if ($querySettingsDto->getPage() > 1 && $repositories->count() === 0) { + return $this->errNotFound(__('Not Found')); + } + + return $this->result([ + 'repositories' => $repositories, + ]); + } + + public function repository(string $username, string $repositoryName, ?User $user = null): ServiceResultArray | ServiceResultError + { + $pageUser = $this->userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + $repository = $this->repositoryRepository->getRepositoryByName($pageUser, $repositoryName); + if (!$repository || $user === null && $repository->is_public === false) { + return $this->errNotFound(__('Not Found')); + } + + if ($user?->cannot('view', $repository)) { + return $this->errFobidden(__('Access is denied')); + } + + return $this->result([ + 'repository' => $repository, + 'pageUser' => $pageUser, + 'serviceAddress' => config('registry.service_http'), + ]); + } + + public function create(string $username, User $user): ServiceResultArray | ServiceResultError + { + $pageUser = $this->userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('create', [Repository::class, $pageUser])) { + return $this->errFobidden(__('Access is denied')); + } + + return $this->result([ + 'user' => $user, + 'repository' => new Repository(), + 'pageUser' => $pageUser, + ]); + } + + public function edit(string $username, string $name, User $user): ServiceResultArray | ServiceResultError + { + $pageUser = $this->userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + $repository = $this->repositoryRepository->getRepositoryByName($pageUser, $name); + if (!$repository) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('update', $repository)) { + return $this->errFobidden(__('Access is denied')); + } + + return $this->result([ + 'user' => $user, + 'repository' => $repository, + 'pageUser' => $pageUser, + ]); + } + + public function store(string $username, StoreUpdate $data, User $user): StoreUpdateResult | ServiceResultError + { + $pageUser = $this->userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('create', [Repository::class, $pageUser])) { + return $this->errFobidden(__('Access is denied')); + } + + if ($this->repositoryRepository->isExistsName($pageUser, $data->getName())) { + return $this->errValidate( + __('validation.unique', ['attribute' => __('validation.attributes.name')]), + ['name' => __('validation.unique', ['attribute' => __('validation.attributes.name')])] + ); + } + + try { + $modelRepository = DB::transaction(function () use ($data, $pageUser) { + $dataRepository = $this->getDataRepository($data); + return $this->repositoryCommandHandler->handleStore($pageUser, $dataRepository, $data->getName()); + }); + } catch (\Throwable $e) { + \report($e); + return $this->errService(__('Server Error')); + } + + return $this->resultStoreUpdateModel($modelRepository, __('site.The repository has been successfully created')); + } + + public function update(string $username, string $name, StoreUpdate $data, User $user): StoreUpdateResult | ServiceResultError + { + $pageUser = $this->userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + $repository = $this->repositoryRepository->getRepositoryByName($pageUser, $name); + if (!$repository) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('update', $repository)) { + return $this->errFobidden(__('Access is denied')); + } + + try { + $modelRepository = DB::transaction(function () use ($data, $repository) { + $dataRepository = $this->getDataRepository($data); + return $this->repositoryCommandHandler->handleUpdate($repository, $dataRepository); + }); + } catch (\Throwable $e) { + \report($e); + return $this->errService(__('Server Error')); + } + + return $this->resultStoreUpdateModel($modelRepository, __('site.The repository has been successfully updated')); + } + + public function destroy(string $username, string $name, User $user): ServiceResultError | ServiceResultSuccess + { + $pageUser = $this->userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + $repository = $this->repositoryRepository->getRepositoryByName($pageUser, $name); + if (!$repository) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('delete', $repository)) { + return $this->errFobidden(__('Access is denied')); + } + + try { + DB::transaction(function () use ($repository) { + $this->repositoryCommandHandler->handleDestroy($repository); + }); + } catch (\Throwable $e) { + \report($e); + return $this->errService(__('Server Error')); + } + + return $this->ok(__('site.Repository deleted successfully')); + } + + private function getDataRepository(StoreUpdate $data): array + { + return [ + 'is_public' => $data->isPublic(), + 'description' => $data->getDescription(), + 'overview' => $data->getOverview(), + ]; + } +} diff --git a/app/application/app/Services/Site/TagRepositoryService.php b/app/application/app/Services/Site/TagRepositoryService.php new file mode 100644 index 0000000..228a799 --- /dev/null +++ b/app/application/app/Services/Site/TagRepositoryService.php @@ -0,0 +1,94 @@ +userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + $repository = $this->repositoryRepository->getRepositoryByName($pageUser, $repositoryName); + if (!$repository || $user === null && $repository->is_public === false) { + return $this->errNotFound(__('Not Found')); + } + + if ($user?->cannot('view', $repository)) { + return $this->errFobidden(__('Access is denied')); + } + + $tags = $this->tagRepositoryRepository->getTagsRepository( + $repository, + $querySettingsDto->getQueryWith() + )->pagination( + $querySettingsDto->getLimit(), + $querySettingsDto->getPage() + ); + + if ($querySettingsDto->getPage() > 1 && $tags->count() === 0) { + return $this->errNotFound(__('Not Found')); + } + + return $this->result([ + 'tags' => $tags, + 'repository' => $repository, + 'pageUser' => $pageUser, + 'serviceAddress' => config('registry.service_http'), + ]); + } + + public function destroy(string $username, string $repositoryName, int $tagId, User $user): ServiceResultError | ServiceResultSuccess + { + $pageUser = $this->userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + $repository = $this->repositoryRepository->getRepositoryByName($pageUser, $repositoryName); + if (!$repository) { + return $this->errNotFound(__('Not Found')); + } + + $tag = $this->tagRepositoryRepository->getTagById($tagId); + if (!$tag || $tag->repository_id !== $repository->id) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('delete', $tag)) { + return $this->errFobidden(__('Access is denied')); + } + + try { + DB::transaction(function () use ($tag) { + $this->tagRepositoryCommandHandler->handleDestroy($tag); + }); + } catch (\Throwable $e) { + \report($e); + return $this->errService(__('Server Error')); + } + + return $this->ok(__('site.Tag removed successfully')); + } +} diff --git a/app/application/app/Services/Site/UserService.php b/app/application/app/Services/Site/UserService.php new file mode 100644 index 0000000..95dd3ca --- /dev/null +++ b/app/application/app/Services/Site/UserService.php @@ -0,0 +1,51 @@ +userRepository->getUserByUsername($username); + if ($pageUser === null) { + return $this->errNotFound(__('Not Found')); + } + + $isPublic = true; + if ($user?->id === $pageUser->id) { + $isPublic = null; + } + + $repositories = $this->repositoryRepository->getUserRepositories( + $pageUser, + new RepositoryBuilder(isPublic: $isPublic), + $querySettingsDto->getQueryWith() + )->pagination( + $querySettingsDto->getLimit(), + $querySettingsDto->getPage() + ); + + if ($querySettingsDto->getPage() > 1 && $repositories->count() === 0) { + return $this->errNotFound(__('Not Found')); + } + + return $this->result([ + 'pageUser' => $pageUser, + 'repositories' => $repositories, + ]); + } +} diff --git a/app/application/app/Services/TagRepository/IncrementPullCommand.php b/app/application/app/Services/TagRepository/IncrementPullCommand.php new file mode 100644 index 0000000..4522957 --- /dev/null +++ b/app/application/app/Services/TagRepository/IncrementPullCommand.php @@ -0,0 +1,37 @@ +tags() + ->withTrashed() + ->where('name', $tagName) + ->where('digest', $digest) + ->first(); + + if (null === $tag) { + return $this->errNotFound(__('Not Found') . ' Tag: ' . $tagName . ':' . $digest); + } + + Repository::withTrashed()->where('id', $repository->id) + // So that the updated_at field is not updated + ->where(fn($q) => $q->getModel()->timestamps = false) + ->increment('quantity_pulls'); + + TagRepository::withTrashed()->where('id', $tag->id) + // So that the updated_at field is not updated + ->where(fn($q) => $q->getModel()->timestamps = false) + ->increment('quantity_pulls'); + + return new IncrementPullResult($repository, $tag); + } +} diff --git a/app/application/app/Services/TagRepository/TagRepositoryCommandHandler.php b/app/application/app/Services/TagRepository/TagRepositoryCommandHandler.php new file mode 100644 index 0000000..38f0000 --- /dev/null +++ b/app/application/app/Services/TagRepository/TagRepositoryCommandHandler.php @@ -0,0 +1,34 @@ +repository_id = $repository->id; + $tag->architecture_id = $architecture->id; + $tag->name = $name; + + $tag->fill($data); + $tag->save(); + + return $tag; + } + + public function handleUpdate(TagRepository $tag, array $data): TagRepository + { + $tag->update($data); + return $tag; + } + + public function handleDestroy(TagRepository $tag): void + { + $tag->delete(); + } +} diff --git a/app/application/app/Services/TagRepository/UpdateOrCreateCommand.php b/app/application/app/Services/TagRepository/UpdateOrCreateCommand.php new file mode 100644 index 0000000..b0b44f8 --- /dev/null +++ b/app/application/app/Services/TagRepository/UpdateOrCreateCommand.php @@ -0,0 +1,29 @@ +where('repository_id', $repository->id) + ->where('architecture_id', $architecture->id) + ->where('name', $name) + ->first(); + + if (null === $tag) { + return $this->tagRepositoryCommandHandler->handleStore($repository, $architecture, $name, $data); + } + + return $this->tagRepositoryCommandHandler->handleUpdate($tag, $data); + } +} diff --git a/app/application/app/Services/User/UserCommandHandler.php b/app/application/app/Services/User/UserCommandHandler.php index 81ff576..03aa416 100644 --- a/app/application/app/Services/User/UserCommandHandler.php +++ b/app/application/app/Services/User/UserCommandHandler.php @@ -28,7 +28,8 @@ public function handleUpdate(User $user, array $data): User } if (isset($data['username'])) { - $data['username'] = Str::lower($data['username']); + // It is not yet clear how to rename repositories in the registry service. + unset($data['username']); } if (isset($data['password'])) { diff --git a/app/application/app/View/Components/Layout/Site.php b/app/application/app/View/Components/Layout/Site.php index 23e9ce0..3abef1f 100644 --- a/app/application/app/View/Components/Layout/Site.php +++ b/app/application/app/View/Components/Layout/Site.php @@ -2,6 +2,7 @@ namespace App\View\Components\Layout; +use Illuminate\Pagination\Paginator; use Illuminate\View\Component; use Illuminate\View\View; @@ -13,6 +14,8 @@ final class Site extends Component */ public function render(): View { + Paginator::defaultView('pagination.site'); + return view('layout.site'); } } diff --git a/app/application/app/View/Components/Site/Forms/Input.php b/app/application/app/View/Components/Site/Forms/Input.php index 4b078f2..acaee8c 100644 --- a/app/application/app/View/Components/Site/Forms/Input.php +++ b/app/application/app/View/Components/Site/Forms/Input.php @@ -12,6 +12,8 @@ public function __construct( private readonly string $name, private readonly string $type = 'text', private readonly ?string $value = '', + private readonly ?string $example = null, + private readonly ?string $allowedCharacters = null, ) { } protected function getName(): string @@ -34,6 +36,16 @@ private function getValue(): string return (string) old($this->getRequestName(), $this->value); } + private function getExample(): ?string + { + return $this->example; + } + + public function getAllowedCharacters(): ?string + { + return $this->allowedCharacters; + } + /** * @inheritDoc */ @@ -45,6 +57,8 @@ public function render(): View 'requestName' => $this->getRequestName(), 'type' => $this->getType(), 'value' => $this->getValue(), + 'example' => $this->getExample(), + 'allowedCharacters' => $this->getAllowedCharacters(), ]); } } diff --git a/app/application/app/View/Components/Site/Forms/Textarea.php b/app/application/app/View/Components/Site/Forms/Textarea.php new file mode 100644 index 0000000..87e2384 --- /dev/null +++ b/app/application/app/View/Components/Site/Forms/Textarea.php @@ -0,0 +1,44 @@ +name; + } + + private function getTitle(): string + { + return Str::ucfirst($this->title); + } + + private function getValue(): string + { + return (string) old($this->getRequestName(), $this->value); + } + + /** + * @inheritDoc + */ + public function render(): View + { + return view('components.site.forms.textarea', [ + 'title' => $this->getTitle(), + 'name' => $this->getName(), + 'requestName' => $this->getRequestName(), + 'value' => $this->getValue(), + ]); + } +} diff --git a/app/application/bootstrap/app.php b/app/application/bootstrap/app.php index 6164d18..b66861f 100644 --- a/app/application/bootstrap/app.php +++ b/app/application/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Support\Facades\Route; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -16,11 +17,38 @@ ->prefix('admin') ->as('admin.') ->group(base_path('routes/admin.php')); + + Route::middleware(['api']) + ->prefix('registry') + ->name('registry.') + ->group(base_path('routes/registry.php')); }, ) ->withMiddleware(function (Middleware $middleware) { - // + $middleware->validateCsrfTokens(except: [ + '/registry/v2/event', + '/registry/v2/event/*', + ]); }) ->withExceptions(function (Exceptions $exceptions) { - // + + $exceptions->renderable(function (\Illuminate\Auth\AuthenticationException $e, Request $request) { + if ($request->is('registry/*')) { + return \response()->json([ + 'message' => $e->getMessage(), + ], \Illuminate\Http\Response::HTTP_UNAUTHORIZED); + } + + return false; + }); + + $exceptions->renderable(function (\Illuminate\Http\Exceptions\HttpResponseException $e, Request $request) { + if ($request->is('registry/*')) { + return \response()->json([ + 'message' => $e->getResponse()->getContent(), + ], $e->getResponse()->getStatusCode()); + } + + return false; + }); })->create(); diff --git a/app/application/bootstrap/providers.php b/app/application/bootstrap/providers.php index 38b258d..d6c57ba 100644 --- a/app/application/bootstrap/providers.php +++ b/app/application/bootstrap/providers.php @@ -2,4 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\RegistryServiceProvider::class, + App\Providers\BladeProvider::class, ]; diff --git a/app/application/composer.json b/app/application/composer.json index 1353e30..874ffea 100644 --- a/app/application/composer.json +++ b/app/application/composer.json @@ -6,9 +6,12 @@ "license": "MIT", "require": { "php": "^8.3", + "firebase/php-jwt": "^6.10", "kor-elf/captcha-rule-for-laravel": "^1.0", "laravel/framework": "^11.9", - "laravel/tinker": "^2.9" + "laravel/sanctum": "^4.0", + "laravel/tinker": "^2.9", + "ext-openssl": "*" }, "require-dev": { "barryvdh/laravel-debugbar": "^3.13", diff --git a/app/application/composer.lock b/app/application/composer.lock index b6a1076..10c09e8 100644 --- a/app/application/composer.lock +++ b/app/application/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5768ab30453b30f69da2a2d7ef5c21c5", + "content-hash": "f10d849a6239c9c56993d1c9ab78c027", "packages": [ { "name": "brick/math", @@ -506,6 +506,69 @@ ], "time": "2023-10-06T06:47:41+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.10.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "500501c2ce893c824c801da135d02661199f60c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5", + "reference": "500501c2ce893c824c801da135d02661199f60c5", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.10.1" + }, + "time": "2024-05-18T18:05:11+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -1363,6 +1426,70 @@ }, "time": "2024-05-27T13:53:20+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/9cfc0ce80cabad5334efff73ec856339e8ec1ac1", + "reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/database": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2024-04-10T19:39:58+00:00" + }, { "name": "laravel/serializable-closure", "version": "v1.3.3", diff --git a/app/application/config/registry.php b/app/application/config/registry.php new file mode 100644 index 0000000..2fe159f --- /dev/null +++ b/app/application/config/registry.php @@ -0,0 +1,103 @@ + env('REGISTRY_SERVICE_NAME'), + + /* + |-------------------------------------------------------------------------- + | service_http + |-------------------------------------------------------------------------- + | + | Register of real addresses + | + */ + 'service_http' => env('REGISTRY_SERVICE_HTTP'), + + /* + |-------------------------------------------------------------------------- + | private_key_name + |-------------------------------------------------------------------------- + | + | Executing commands: + | openssl req -nodes -newkey rsa:4096 -keyout registry-auth1.key -out registry-auth1.csr -subj "/CN=token_issuer" + | openssl x509 -in registry-auth1.csr -out registry-auth1.crt -req -signkey registry-auth1.key -days 3650 + | + | The file name corresponds to the name registry-auth1.key. + | + */ + 'private_key_name' => env('REGISTRY_PRIVATE_KEY_NAME'), + + /* + |-------------------------------------------------------------------------- + | issuer + |-------------------------------------------------------------------------- + | + | The parameter must match the parameter from the registry service + | auth: + | token: + | issuer: + | + | Executing commands: + | openssl req -nodes -newkey rsa:4096 -keyout registry-auth1.key -out registry-auth1.csr -subj "/CN=token_issuer" + | openssl x509 -in registry-auth1.csr -out registry-auth1.crt -req -signkey registry-auth1.key -days 3650 + | + | auth: + | token: + | issuer: token_issuer + | + | Where it is better to name token_issuer in your own way. + | + */ + 'issuer' => env('REGISTRY_ISSUER'), + + /* + |-------------------------------------------------------------------------- + | algorithm + |-------------------------------------------------------------------------- + | + | Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', 'HS384', + | 'HS512', 'RS256', 'RS384', and 'RS512'. Testing only RS256. + | + | openssl req -nodes -newkey rsa:4096 -keyout registry-auth1.key -out registry-auth1.csr -subj "/CN=token_issuer" + | openssl x509 -in registry-auth1.csr -out registry-auth1.crt -req -signkey registry-auth1.key -days 3650 + | + | If you created a key using this command, then the algorithm will be RS256. + | + */ + 'algorithm' => env('REGISTRY_ALGORITHM', 'RS256'), + + /* + |-------------------------------------------------------------------------- + | expires_in_seconds + |-------------------------------------------------------------------------- + | + | For how many seconds to issue an authorization token. + | + */ + 'expires_in_seconds' => (int) env('REGISTRY_EXPIRES_IN_SECONDS', 600), + + /* + |-------------------------------------------------------------------------- + | token_for_notifications + |-------------------------------------------------------------------------- + | + | The parameter must match the parameter from the registry service + | notifications: + | endpoints: + | headers: + | Authorization: [REGISTRY_TOKEN_FOR_NOTIFICATIONS] + | + */ + 'token_for_notifications' => env('REGISTRY_TOKEN_FOR_NOTIFICATIONS'), +]; diff --git a/app/application/config/sanctum.php b/app/application/config/sanctum.php new file mode 100644 index 0000000..764a82f --- /dev/null +++ b/app/application/config/sanctum.php @@ -0,0 +1,83 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort() + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/app/application/database/migrations/2024_06_18_195947_create_personal_access_tokens_table.php b/app/application/database/migrations/2024_06_18_195947_create_personal_access_tokens_table.php new file mode 100644 index 0000000..e828ad8 --- /dev/null +++ b/app/application/database/migrations/2024_06_18_195947_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/app/application/database/migrations/2024_06_23_170638_create_repositories.php b/app/application/database/migrations/2024_06_23_170638_create_repositories.php new file mode 100644 index 0000000..c0567ed --- /dev/null +++ b/app/application/database/migrations/2024_06_23_170638_create_repositories.php @@ -0,0 +1,74 @@ +id(); + $table->string('name', 255)->unique(); + $table->timestamps(); + }); + + Schema::create('architectures', function (Blueprint $table) { + $table->id(); + $table->string('name', 255)->unique(); + $table->unsignedBigInteger('operating_system_id')->index(); + $table->foreign('operating_system_id')->references('id')->on('operating_systems'); + $table->timestamps(); + }); + + Schema::create('repositories', function (Blueprint $table) { + $table->id(); + $table->string('name', 150); + $table->boolean('is_public')->default(0)->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->foreign('user_id')->references('id')->on('users'); + $table->string('description', 300)->nullable(); + $table->longText('overview')->nullable(); + $table->unsignedBigInteger('quantity_pulls')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['user_id', 'name']); + $table->index(['user_id', 'is_public']); + $table->index('updated_at'); + }); + + Schema::create('tags_repository', function (Blueprint $table) { + $table->id(); + $table->string('name', 128)->collation('ascii_bin'); + $table->unsignedBigInteger('architecture_id')->index(); + $table->foreign('architecture_id')->references('id')->on('architectures'); + $table->unsignedBigInteger('repository_id')->index(); + $table->foreign('repository_id')->references('id')->on('repositories'); + $table->string('digest', 100)->index(); + $table->unsignedBigInteger('size'); + $table->unsignedBigInteger('quantity_pulls')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['repository_id', 'name']); + $table->unique(['repository_id', 'architecture_id', 'name']); + $table->index('updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tags_repository'); + Schema::dropIfExists('repositories'); + Schema::dropIfExists('architectures'); + Schema::dropIfExists('operating_systems'); + } +}; diff --git a/app/application/lang/en.json b/app/application/lang/en.json index 214c9e9..c7fe65a 100644 --- a/app/application/lang/en.json +++ b/app/application/lang/en.json @@ -237,5 +237,23 @@ "example:": "example:", "or": "or", "My Profile": "My Profile", - "Powered by MDHub": "Powered by MDHub" + "Powered by MDHub": "Powered by MDHub", + "Repositories": "Repositories", + "My repositories": "My repositories", + "Access Tokens": "Access Tokens", + "Token created": "Token created", + "Token Name": "Token Name", + "Access Permissions": "Access Permissions", + "To use the access token from your Docker CLI client:": "To use the access token from your Docker CLI client:", + "Run command": "Run command", + "At the password prompt, enter the personal access token.": "At the password prompt, enter the personal access token.", + "WARNING": "WARNING", + "This access token will only be displayed once. It will not be stored and cannot be retrieved. Please be sure to save it now.": "WARNING: This access token will only be displayed once. It will not be stored and cannot be retrieved. Please be sure to save it now.", + "OK, I saved it": "OK, I saved it", + "Error copy": "Error, couldn't copy", + "Copied": "Copied", + "Copy": "Copy", + "Last Used": "Last Used", + "Never": "Never", + "by": "by" } diff --git a/app/application/lang/en/access-token-permission.php b/app/application/lang/en/access-token-permission.php new file mode 100644 index 0000000..62514d7 --- /dev/null +++ b/app/application/lang/en/access-token-permission.php @@ -0,0 +1,7 @@ + 'Read, Write, Delete', + 'RepoReadWrite' => 'Read & Write', + 'RepoRead' => 'Read-only', +]; diff --git a/app/application/lang/en/site.php b/app/application/lang/en/site.php index 0d91043..587390e 100644 --- a/app/application/lang/en/site.php +++ b/app/application/lang/en/site.php @@ -4,4 +4,23 @@ 'logo' => 'MDHub Logo', 'Menu' => 'Menu', 'required field' => 'required field', + 'profile_repositories :username' => 'User Repositories: :username', + 'Create repository' => 'Create repository', + 'Update repository :name' => 'Update repository: :name', + 'The repository has been successfully created' => 'The repository has been successfully created', + 'The repository has been successfully updated' => 'The repository has been successfully updated', + 'Repository deleted successfully' => 'Repository deleted successfully', + 'Tag removed successfully' => 'Tag removed successfully', + 'Create an access token' => 'Create an access token', + 'Editing a token' => 'Editing a token', + 'Token updated successfully' => 'Token updated successfully', + 'The token has been deleted' => 'The token has been deleted', + 'Joined :date' => 'Joined :date', + 'Name' => 'Name', + 'Repositories' => 'Repositories', + 'Updated :date' => 'Updated :date', + 'Repository :repository' => 'Repository :repository', + 'Overview' => 'Overview', + 'Tags' => 'Tags', + 'Docker Pull Command' => 'Docker Pull Command', ]; diff --git a/app/application/lang/en/validation.php b/app/application/lang/en/validation.php index ea0437a..b317682 100644 --- a/app/application/lang/en/validation.php +++ b/app/application/lang/en/validation.php @@ -279,5 +279,7 @@ 'permissions' => 'permissions', 'is_active' => 'is active', 'roles' => 'user group', + 'is_public' => 'public', + 'overview' => 'overview', ], ]; diff --git a/app/application/lang/ru.json b/app/application/lang/ru.json index 4550171..e05197d 100644 --- a/app/application/lang/ru.json +++ b/app/application/lang/ru.json @@ -237,5 +237,23 @@ "example:": "пример:", "or": "или", "My Profile": "Мой профиль", - "Powered by MDHub": "Работает на MDHub" + "Powered by MDHub": "Работает на MDHub", + "Repositories": "Репозитории", + "My repositories": "Мои репозитории", + "Access Tokens": "Токены доступа", + "Token created": "Токен создан", + "Token Name": "Название токена", + "Access Permissions": "Разрешения на доступ", + "To use the access token from your Docker CLI client:": "Чтобы использовать токен доступа из клиента Docker CLI:", + "Run command": "Запустить команду", + "At the password prompt, enter the personal access token.": "В ответ на запрос пароля введите токен личного доступа.", + "WARNING": "ВНИМАНИЕ", + "This access token will only be displayed once. It will not be stored and cannot be retrieved. Please be sure to save it now.": "этот токен доступа будет отображаться только один раз. Он не будет сохранен и не может быть получен. Пожалуйста, обязательно сохраните его сейчас.", + "OK, I saved it": "Хорошо, я сохранил", + "Error copy": "Ошибка, не удалось скопировать", + "Copied": "Скопировано", + "Copy": "Скопировать", + "Last Used": "Время использования", + "Never": "Никогда", + "by": "от" } diff --git a/app/application/lang/ru/access-token-permission.php b/app/application/lang/ru/access-token-permission.php new file mode 100644 index 0000000..a302b48 --- /dev/null +++ b/app/application/lang/ru/access-token-permission.php @@ -0,0 +1,7 @@ + 'Чтение, Запись, Удаление', + 'RepoReadWrite' => 'Чтение и Запись', + 'RepoRead' => 'Только чтение', +]; diff --git a/app/application/lang/ru/site.php b/app/application/lang/ru/site.php index 5d08431..6acd6da 100644 --- a/app/application/lang/ru/site.php +++ b/app/application/lang/ru/site.php @@ -4,4 +4,23 @@ 'logo' => 'Логотип MDHub', 'Menu' => 'Меню', 'required field' => 'обязательное поле', + 'profile_repositories :username' => 'Репозитории пользователя: :username', + 'Create repository' => 'Создать репозиторий', + 'Update repository :name' => 'Обновить репозиторий: :name', + 'The repository has been successfully created' => 'Репозиторий успешно создан', + 'The repository has been successfully updated' => 'Репозиторий успешно обновлен', + 'Repository deleted successfully' => 'Репозиторий успешно удален', + 'Tag removed successfully' => 'Тег успешно удален', + 'Create an access token' => 'Создать токен доступа', + 'Editing a token' => 'Редактирование токена', + 'Token updated successfully' => 'Токен успешно обновлен', + 'The token has been deleted' => 'Токен был удален', + 'Joined :date' => 'Присоединился :date', + 'Name' => 'Имя', + 'Repositories' => 'Репозитории', + 'Updated :date' => 'Обновлено :date', + 'Repository :repository' => 'Репозиторий :repository', + 'Overview' => 'Обзор', + 'Tags' => 'Теги', + 'Docker Pull Command' => 'Команда Pull от Docker\'а', ]; diff --git a/app/application/lang/ru/validation.php b/app/application/lang/ru/validation.php index 90a23bd..0b50950 100644 --- a/app/application/lang/ru/validation.php +++ b/app/application/lang/ru/validation.php @@ -279,5 +279,7 @@ 'permissions' => 'разрешения', 'is_active' => 'активен', 'roles' => 'группа пользователей', + 'is_public' => 'публичный', + 'overview' => 'обзор', ], ]; diff --git a/app/application/resources/certs/.gitignore b/app/application/resources/certs/.gitignore new file mode 100644 index 0000000..7feff33 --- /dev/null +++ b/app/application/resources/certs/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!readme.txt diff --git a/app/application/resources/certs/readme.txt b/app/application/resources/certs/readme.txt new file mode 100644 index 0000000..59fdc79 --- /dev/null +++ b/app/application/resources/certs/readme.txt @@ -0,0 +1,2 @@ +openssl req -nodes -newkey rsa:4096 -keyout registry-auth1.key -out registry-auth1.csr -subj "/CN=token_issuer" +openssl x509 -in registry-auth1.csr -out registry-auth1.crt -req -signkey registry-auth1.key -days 3650 diff --git a/app/application/resources/site/images/icons/download.svg b/app/application/resources/site/images/icons/download.svg new file mode 100644 index 0000000..5bd7bfe --- /dev/null +++ b/app/application/resources/site/images/icons/download.svg @@ -0,0 +1,3 @@ + diff --git a/app/application/resources/site/js/app.js b/app/application/resources/site/js/app.js index 6d15487..179f58f 100644 --- a/app/application/resources/site/js/app.js +++ b/app/application/resources/site/js/app.js @@ -3,3 +3,4 @@ import.meta.glob([ ]); import './bootstrap'; import './_menu.js'; +import './block-copy.js'; diff --git a/app/application/resources/site/js/block-copy.js b/app/application/resources/site/js/block-copy.js new file mode 100644 index 0000000..6f511f9 --- /dev/null +++ b/app/application/resources/site/js/block-copy.js @@ -0,0 +1,21 @@ +[].slice.call(document.querySelectorAll('.block-copy')).map(function(el) { + + let button = el.querySelector('button'); + button.addEventListener('click', () => { + navigator.clipboard.writeText(el.querySelector('.block-copy-text').textContent) + .then(() => { + button.textContent = button.getAttribute('data-copy-success'); + setTimeout(function () { + button.textContent = button.getAttribute('data-default'); + }, 3000); + }) + .catch(err => { + button.textContent = button.getAttribute('data-copy-error'); + setTimeout(function () { + button.textContent = button.getAttribute('data-default'); + }, 3000); + console.log('Something went wrong', err); + }); + }); + +}); diff --git a/app/application/resources/site/scss/app.scss b/app/application/resources/site/scss/app.scss index 11e3a70..6d60b0c 100644 --- a/app/application/resources/site/scss/app.scss +++ b/app/application/resources/site/scss/app.scss @@ -1,6 +1,11 @@ @import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400;1,700&display=swap'); @import "reset"; @import "layout"; +@import "table"; @import "forms"; @import "header"; @import "footer"; +@import "pagination"; +@import "block-profile"; +@import "repository"; +@import "generated-token"; diff --git a/app/application/resources/site/scss/block-profile.scss b/app/application/resources/site/scss/block-profile.scss new file mode 100644 index 0000000..abfed7c --- /dev/null +++ b/app/application/resources/site/scss/block-profile.scss @@ -0,0 +1,18 @@ +.block-profile { + border-radius: 9px; + border: 1px solid #ddd; + background: #eee; + margin-top: 20px; + margin-bottom: 10px; + padding: 10px; + + h1 { + font-size: 22px; + padding: 0; + margin: 0; + } +} +.block-profile__line { + padding-top: 10px; + font-size: 16px; +} diff --git a/app/application/resources/site/scss/forms.scss b/app/application/resources/site/scss/forms.scss index e24c1d3..cad7fe8 100644 --- a/app/application/resources/site/scss/forms.scss +++ b/app/application/resources/site/scss/forms.scss @@ -23,6 +23,10 @@ border: 1px solid #ddd; } + textarea.form-control { + resize: vertical; + } + .form-control.is-invalid { border-color: #dc3545; } @@ -54,12 +58,20 @@ padding: 12px 30px; color: #fff; transition: opacity 0.2s ease-out; + text-decoration: none; + display: inline-block; + vertical-align: top; + text-align: center; } .button:hover { opacity: 0.8; border-color: #006ce7; color: #ccc; } +.button.button-red { + border: 1px solid #FF2D20; + background: #9a241f; +} .captcha-service-kor-elf { position: relative; @@ -70,6 +82,7 @@ border-radius: 6px; font-size: 16px; padding: 14px 24px; + margin-bottom: 15px; ul { padding: 0; @@ -85,3 +98,11 @@ border: 1px solid #dc3545; color: #dc3545; } +.form__example { + font-size: 14px; + font-style: italic; + color: #333; +} +.form__example__block { + display: block; +} diff --git a/app/application/resources/site/scss/generated-token.scss b/app/application/resources/site/scss/generated-token.scss new file mode 100644 index 0000000..0e8a1fa --- /dev/null +++ b/app/application/resources/site/scss/generated-token.scss @@ -0,0 +1,33 @@ +#generated-token { + background: rgba(0, 0, 0, 0.78); + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; +} +.generated-token__content { + background: #fff; + max-width: 600px; + margin: 30px auto; + padding-left: 20px; + padding-right: 20px; + padding-bottom: 20px; + + .warning { + border-radius: 3px; + border: 1px solid #FF2D20; + color: #88302a; + padding: 10px; + margin-bottom: 20px; + } +} +.generated-token__content__head { + font-size: 20px; + font-weight: bold; + border-bottom: 1px solid #ddd; + padding-top: 20px; + padding-bottom: 20px; +} + diff --git a/app/application/resources/site/scss/layout.scss b/app/application/resources/site/scss/layout.scss index 6420569..ce7cbea 100644 --- a/app/application/resources/site/scss/layout.scss +++ b/app/application/resources/site/scss/layout.scss @@ -20,7 +20,7 @@ hr { display: flex; flex-direction: column; position: relative; - min-height: 100vh; + min-height: calc(100vh - 69px); } #menu { @@ -77,13 +77,36 @@ hr { } } +.block-copy { + background: #eee; + border: 1px solid #ccc; + padding: 7px 10px; + border-radius: 9px; + position: relative; + min-height: 40px; + margin-top: 30px; + + .block-copy-text { + white-space: break-spaces; + overflow-wrap: break-word; + word-break: break-all; + } + + button { + position: absolute; + right: 0; + top: -20px; + cursor: pointer; + } +} + .container-width { padding-left: 15px; padding-right: 15px; } .section-content { - h1 { + .h1 { font-size: 27px; font-weight: bold; padding-bottom: 10px; @@ -92,6 +115,33 @@ hr { } } +.margin-bottom-20 { + margin-bottom: 20px; +} + +.quantity_pulls { + font-size: 14px; + color: #333; + display: inline-block; + vertical-align: middle; +} +.quantity_pulls:before { + content: ' '; + background: url('../images/icons/download.svg') left top no-repeat; + background-size: contain; + display: inline-block; + vertical-align: middle; + width: 13px; + height: 13px; + margin-right: 5px; +} + +.dot { + padding-left: 7px; + padding-right: 7px; + color: #999; +} + @media (min-width: 1000px) { .container-width { max-width: 1200px; @@ -123,4 +173,19 @@ hr { #content { grid-area: section-content; } + .block-copy { + margin-top: 0; + + button { + display: none; + right: 5px; + top: 7px; + } + } + + .block-copy:hover { + button { + display: block; + } + } } diff --git a/app/application/resources/site/scss/pagination.scss b/app/application/resources/site/scss/pagination.scss new file mode 100644 index 0000000..cb2fb10 --- /dev/null +++ b/app/application/resources/site/scss/pagination.scss @@ -0,0 +1,41 @@ +.pagination { + list-style: none; + font-size: 0; + padding: 0; + margin: 0; + overflow: auto; + white-space: nowrap; + + li { + display: inline-block; + vertical-align: top; + border: 1px solid #ddd; + margin-right: 10px; + + span, a { + text-decoration: none; + display: block; + text-align: center; + padding: 7px 5px; + width: 40px; + color: #000; + font-size: 16px; + transition: background 0.3s ease-out; + } + + a:hover { + background: #dddddd; + } + } + + li.active { + font-weight: bold; + background: #ddd; + } + + li.disabled { + a, span { + color: #999; + } + } +} diff --git a/app/application/resources/site/scss/repository.scss b/app/application/resources/site/scss/repository.scss new file mode 100644 index 0000000..3b2196e --- /dev/null +++ b/app/application/resources/site/scss/repository.scss @@ -0,0 +1,155 @@ +.content-repositories { + padding-top: 10px; + + .h1 { + .button__create { + display: block; + margin-bottom: 0; + } + } +} +.content-repositories__list__item { + border: 1px solid #ddd; + margin-bottom: 20px; + padding: 10px; + display: block; + text-decoration: none; + color: #000; +} +.content-repositories__list__item__head { + display: flex; + flex-direction: row; + -moz-box-pack: justify; + justify-content: space-between; + -moz-box-align: center; + align-items: center; +} +.content-repositories__list__item__line { + display: block; + padding-top: 10px; +} +.content-repositories__list__item__updated { + color: #777; +} + +.content-repositories__menu { + list-style: none; + margin: 0 0 20px; + padding: 0; + border-bottom: 1px solid #ddd; + font-size: 0; + + li { + margin: 0; + padding: 0; + display: inline-block; + vertical-align: top; + margin-right: 10px; + padding-bottom: 10px; + + a { + color: #000; + text-decoration: none; + font-size: 18px; + transition: opacity 0.2s ease-out; + padding-left: 7px; + padding-right: 7px; + } + a:hover { + opacity: 0.65; + } + } + li.active { + padding-bottom: 8px; + border-bottom: 2px solid #006400; + + a { + font-weight: bold; + } + } +} + +.content-repositories__overview__command { + background: #eee; + margin-bottom: 20px; +} +.content-repositories__overview__command__head { + font-weight: bold; + font-size: 18px; + margin-bottom: 15px; +} +.content-repositories__overview__content { + margin-bottom: 20px; +} +.content-repositories__overview__border { + border: 1px solid #ddd; + padding: 15px 10px; +} + +.content-repositories__tag__content { + margin-bottom: 20px; +} + +.content-repositories__tag__info { + + p { + margin-top: 0; + padding-bottom: 10px; + } +} + +.content-repositories__tag { + border: 1px solid #ddd; + padding: 15px 10px; + margin-bottom: 20px; +} + +@media (min-width: 550px) { + .content-repositories { + .h1 { + display: flex; + flex-direction: row; + -moz-box-pack: justify; + justify-content: space-between; + -moz-box-align: center; + align-items: center; + } + } + .content-repositories__buttons { + display: flex; + flex-direction: row; + -moz-box-pack: justify; + justify-content: space-between; + -moz-box-align: center; + align-items: center; + } +} +@media (min-width: 1000px) { + .content-repositories__overview { + display: flex; + flex-direction: row; + -moz-box-pack: justify; + justify-content: space-between; + } + + .content-repositories__overview__content { + order: 1; + width: 100%; + } + .content-repositories__overview__command { + order: 2; + width: 350px; + margin-left: 20px; + } + + .content-repositories__tag__content { + display: flex; + flex-direction: row; + -moz-box-pack: justify; + justify-content: space-between; + margin-bottom: 10px; + } + .content-repositories__tag__command { + padding-top: 20px; + } +} diff --git a/app/application/resources/site/scss/table.scss b/app/application/resources/site/scss/table.scss new file mode 100644 index 0000000..71a1c89 --- /dev/null +++ b/app/application/resources/site/scss/table.scss @@ -0,0 +1,18 @@ +.block-table { + overflow: auto; +} +.table { + width: 100%; + border-collapse: collapse; + border-spacing: 0px; + margin-bottom: 20px; + + th, td { + border: 1px solid #ddd; + padding: 10px 20px; + } + + th { + text-transform: uppercase; + } +} diff --git a/app/application/resources/views/admin/users/_from.blade.php b/app/application/resources/views/admin/users/_from.blade.php index bece9fd..f3643a2 100644 --- a/app/application/resources/views/admin/users/_from.blade.php +++ b/app/application/resources/views/admin/users/_from.blade.php @@ -2,7 +2,7 @@ - + @if (empty($user->id)) @endif diff --git a/app/application/resources/views/components/site/forms/input.blade.php b/app/application/resources/views/components/site/forms/input.blade.php index ff6a285..36d56a2 100644 --- a/app/application/resources/views/components/site/forms/input.blade.php +++ b/app/application/resources/views/components/site/forms/input.blade.php @@ -6,6 +6,14 @@ @endif +
+ @if(!empty($allowedCharacters)) + {{ __('allowed characters:') }} {{ $allowedCharacters }} + @endif + @if(!empty($example)) + {{ __('example:') }} {!! $example !!} + @endif +
@error($requestName) {{ $message }} @enderror diff --git a/app/application/resources/views/components/site/forms/textarea.blade.php b/app/application/resources/views/components/site/forms/textarea.blade.php new file mode 100644 index 0000000..ac83e88 --- /dev/null +++ b/app/application/resources/views/components/site/forms/textarea.blade.php @@ -0,0 +1,12 @@ +
+ + + @error($requestName) + {{ $message }} + @enderror +
diff --git a/app/application/resources/views/layout/site.blade.php b/app/application/resources/views/layout/site.blade.php index 843f470..69d95ff 100644 --- a/app/application/resources/views/layout/site.blade.php +++ b/app/application/resources/views/layout/site.blade.php @@ -58,9 +58,11 @@
  • request()->route()->named(['login'])])>{{ __('Log In') }}
  • @endguest @auth - @can('AdminPanel') -
  • {{ __('admin-sections.AdminPanel') }}
  • - @endif + @can('AdminPanel') +
  • {{ __('admin-sections.AdminPanel') }}
  • + @endif +
  • request()->route()->named(['user.repositories', 'repository.create'])])>{{ __('My repositories') }}
  • +
  • request()->route()->named(['profile.access-tokens.*'])])>{{ __('Access Tokens') }}
  • request()->route()->named(['profile.edit'])])>{{ __('Profile') }}
  • request()->route()->named(['profile.settings'])])>{{ __('Settings') }}
  • @@ -73,10 +75,13 @@
  • @endauth +
  • request()->route()->named(['home'])])>{{ __('Repositories') }}
  • -

    @yield('h1', '')

    + @hasSection('h1') +

    @yield('h1')

    + @endif @includeWhen($errors->any(), 'layout.site._errors', ['errors' => $errors->all()]) @includeWhen(Session::has('success'), 'layout.site._success', ['success' => Session::get('success')]) {{ $slot }} @@ -86,7 +91,7 @@
    diff --git a/app/application/resources/views/pagination/site.blade.php b/app/application/resources/views/pagination/site.blade.php new file mode 100644 index 0000000..0db70b5 --- /dev/null +++ b/app/application/resources/views/pagination/site.blade.php @@ -0,0 +1,46 @@ +@if ($paginator->hasPages()) + +@endif diff --git a/app/application/resources/views/site/access-tokens/create.blade.php b/app/application/resources/views/site/access-tokens/create.blade.php new file mode 100644 index 0000000..7d4cd0d --- /dev/null +++ b/app/application/resources/views/site/access-tokens/create.blade.php @@ -0,0 +1,89 @@ +@section('meta_title', __('site.Create an access token')) +@section('h1', __('site.Create an access token')) + +
    + @csrf + + + +
    + +
    + + + @push('scripts') + + + @endpush +
    diff --git a/app/application/resources/views/site/access-tokens/edit.blade.php b/app/application/resources/views/site/access-tokens/edit.blade.php new file mode 100644 index 0000000..ba63d34 --- /dev/null +++ b/app/application/resources/views/site/access-tokens/edit.blade.php @@ -0,0 +1,16 @@ +@section('meta_title', __('site.Editing a token')) +@section('h1', __('site.Editing a token')) + +
    + @method('PUT') + @csrf + + + + + +
    + +
    + +
    diff --git a/app/application/resources/views/site/access-tokens/index.blade.php b/app/application/resources/views/site/access-tokens/index.blade.php new file mode 100644 index 0000000..e921bde --- /dev/null +++ b/app/application/resources/views/site/access-tokens/index.blade.php @@ -0,0 +1,47 @@ +@section('meta_title', __('Access Tokens')) +@section('h1', __('Access Tokens')) + + {{ __('site.Create an access token') }} +
    + + + + + + + + + @foreach($tokens as $token) + + + + + + + + @endforeach +
    {{ __('validation.attributes.name') }}{{ __('validation.attributes.permissions') }}{{ __('Last Used') }}{{ __('Created') }}
    + {{ $token->name }} + + @foreach($token->abilities as $ability) + {{ \App\Enums\AccessTokenPermission::tryFrom($ability)?->getTitle() }}
    + @endforeach +
    + {{ $token->last_used_at?->diffForHumans() ?? __('Never') }} + + {{ $token->created_at->diffForHumans() }} + +
    + @csrf + @method('DELETE') + +
    +
    +
    + {{ $tokens->links() }} + @push('scripts') + @include('_scripts._click-confirm', ['alert' => __('Do you want to delete?')]) + @endpush +
    diff --git a/app/application/resources/views/site/home/index.blade.php b/app/application/resources/views/site/home/index.blade.php deleted file mode 100644 index 4d9c4e2..0000000 --- a/app/application/resources/views/site/home/index.blade.php +++ /dev/null @@ -1,6 +0,0 @@ -@section('meta_title', "") -@section('h1', "123") - - - 789 - diff --git a/app/application/resources/views/site/repositories/_from.blade.php b/app/application/resources/views/site/repositories/_from.blade.php new file mode 100644 index 0000000..cf6ebb7 --- /dev/null +++ b/app/application/resources/views/site/repositories/_from.blade.php @@ -0,0 +1,8 @@ +@csrf + + + + +
    + +
    diff --git a/app/application/resources/views/site/repositories/_repository-page.blade.php b/app/application/resources/views/site/repositories/_repository-page.blade.php new file mode 100644 index 0000000..600b66a --- /dev/null +++ b/app/application/resources/views/site/repositories/_repository-page.blade.php @@ -0,0 +1,39 @@ +
    + @can('update', $repository) + {{ __('Edit') }} + @endcan + @can('delete', $repository) +
    + @csrf + @method('DELETE') + +
    + @endcan +
    +
    +
    + + {{ $repository->repository }} + @abbreviate_int($repository->quantity_pulls) + + + {{ __('by') }} {{ $repository->user->username }} + + {{ __('site.Updated :date', ['date' => $repository->updated_at->diffForHumans()]) }} + + + {{ $repository->description }} + +
    +
    + +@push('scripts') + @can('delete', $repository) + @include('_scripts._click-confirm', ['alert' => __('Do you want to delete?')]) + @endcanany +@endpush diff --git a/app/application/resources/views/site/repositories/create.blade.php b/app/application/resources/views/site/repositories/create.blade.php new file mode 100644 index 0000000..20537e6 --- /dev/null +++ b/app/application/resources/views/site/repositories/create.blade.php @@ -0,0 +1,8 @@ +@section('meta_title', __('site.Create repository')) +@section('h1', __('site.Create repository')) + + +
    + @include('site.repositories._from') +
    +
    diff --git a/app/application/resources/views/site/repositories/edit.blade.php b/app/application/resources/views/site/repositories/edit.blade.php new file mode 100644 index 0000000..0e71c07 --- /dev/null +++ b/app/application/resources/views/site/repositories/edit.blade.php @@ -0,0 +1,9 @@ +@section('meta_title', __('site.Update repository :name', ['name' => $repository->name])) +@section('h1', __('site.Update repository :name', ['name' => $repository->name])) + + +
    + @method('PUT') + @include('site.repositories._from') +
    +
    diff --git a/app/application/resources/views/site/repositories/repositories.blade.php b/app/application/resources/views/site/repositories/repositories.blade.php new file mode 100644 index 0000000..949051d --- /dev/null +++ b/app/application/resources/views/site/repositories/repositories.blade.php @@ -0,0 +1,26 @@ +@section('meta_title', __('site.Repositories')) +@section('h1', __('site.Repositories')) + + + + diff --git a/app/application/resources/views/site/repositories/repository.blade.php b/app/application/resources/views/site/repositories/repository.blade.php new file mode 100644 index 0000000..77f8f09 --- /dev/null +++ b/app/application/resources/views/site/repositories/repository.blade.php @@ -0,0 +1,23 @@ +@section('meta_title', __('site.Repository :repository', ['repository' => $repository->repository])) +@section('h1', $repository->user->username . '/' . $repository->name) + +
    + @include('site.repositories._repository-page', ['repository' => $repository]) +
    +
    +
    +
    {{ __('site.Docker Pull Command') }}
    +
    + docker pull {{ $serviceAddress }}/{{ $repository->repository }}@if($repository->tag):{{ $repository->tag->name }}@endif + +
    +
    +
    +
    +
    + {{ $repository->overview }} +
    +
    +
    +
    +
    diff --git a/app/application/resources/views/site/repositories/tags.blade.php b/app/application/resources/views/site/repositories/tags.blade.php new file mode 100644 index 0000000..bcb577d --- /dev/null +++ b/app/application/resources/views/site/repositories/tags.blade.php @@ -0,0 +1,64 @@ +@section('meta_title', __('site.Repository :repository', ['repository' => $repository->user->username . '/' . $repository->name])) +@section('h1', $repository->user->username . '/' . $repository->name) + +
    + @include('site.repositories._repository-page', ['repository' => $repository]) +
    + @foreach($tags as $tag) +
    +
    +
    +

    {{ $tag->name }}

    +

    {{ __('site.Updated :date', ['date' => $tag->updated_at->diffForHumans()]) }} {{ __('by') }} {{ $repository->user->username }}

    +
    +
    +
    + docker pull {{ $serviceAddress }}/{{ $repository->repository }}:{{ $tag->name }} + +
    +
    +
    +
    + + + + + + + @can('delete', $tag) + + @endcanany + + + + + + + @can('delete', $tag) + + @endcan + +
    DigestOS/ARCHCompressed SizePulls
    + @short_digest($tag->digest) + + {{ $tag->architecture->os->name }}/{{ $tag->architecture->name }} + + @size_for_humans($tag->size) + + @abbreviate_int($tag->quantity_pulls) + +
    + @csrf + @method('DELETE') + +
    +
    +
    +
    + @endforeach +
    + {{ $tags->links() }} +
    +
    diff --git a/app/application/resources/views/site/user/user.blade.php b/app/application/resources/views/site/user/user.blade.php new file mode 100644 index 0000000..8f9ce51 --- /dev/null +++ b/app/application/resources/views/site/user/user.blade.php @@ -0,0 +1,37 @@ +@section('meta_title', __('site.profile_repositories :username', ['username' => $pageUser->username])) + + +
    +

    {{ $pageUser->username }}

    +
    {{ __('site.Name') }} {{ $pageUser->name }}
    +
    {{ __('site.Joined :date', ['date' => $pageUser->updated_at->isoFormat('LL')]) }}
    +
    + +
    +
    + {{ __('site.Repositories') }} + @can('create', [\App\Models\Repository::class, $pageUser]) + {{ __('site.Create repository') }} + @endcan +
    + + {{ $repositories->links() }} +
    +
    diff --git a/app/application/routes/console.php b/app/application/routes/console.php index eff2ed2..45537ea 100644 --- a/app/application/routes/console.php +++ b/app/application/routes/console.php @@ -1,8 +1,6 @@ daily(); -Artisan::command('inspire', function () { - $this->comment(Inspiring::quote()); -})->purpose('Display an inspiring quote')->hourly(); diff --git a/app/application/routes/registry.php b/app/application/routes/registry.php new file mode 100644 index 0000000..387d77c --- /dev/null +++ b/app/application/routes/registry.php @@ -0,0 +1,6 @@ +middleware(\App\Http\Middleware\RegistryAuth::class); +Route::post('/v2/event/', [\App\Http\Controllers\Registry\Version2::class, 'event'])->middleware(\App\Http\Middleware\RegistryAuthNotification::class); diff --git a/app/application/routes/web.php b/app/application/routes/web.php index 7d2bacb..01bf58b 100644 --- a/app/application/routes/web.php +++ b/app/application/routes/web.php @@ -2,7 +2,11 @@ use Illuminate\Support\Facades\Route; -Route::get('/', [\App\Http\Controllers\Site\HomeController::class, 'index'])->name('home'); +Route::get('/', [\App\Http\Controllers\Site\RepositoriesController::class, 'repositories'])->name('home'); + +Route::get('user/{username}', [\App\Http\Controllers\Site\UserController::class, 'user'])->name('user.repositories'); +Route::get('repository/{username}/{repository_name}', [\App\Http\Controllers\Site\RepositoriesController::class, 'repository'])->name('repository.view'); +Route::get('repository/{username}/{repository_name}/tags', [\App\Http\Controllers\Site\TagRepositoriesController::class, 'tags'])->name('repository.tags'); Route::middleware('guest')->group(function () { Route::get('login', [\App\Http\Controllers\AuthController::class, 'login'])->name('login'); @@ -21,5 +25,19 @@ Route::put('password', [\App\Http\Controllers\Site\ProfileController::class, 'updatePassword'])->name('update-password'); Route::get('settings', [\App\Http\Controllers\Site\ProfileController::class, 'settings'])->name('settings'); Route::put('settings', [\App\Http\Controllers\Site\ProfileController::class, 'updateSettings'])->name('update-settings'); + + Route::resource('access-tokens', \App\Http\Controllers\Site\AccessTokensController::class)->except(['show'])->where(['access_token' => '[0-9]+']); }); + + Route::prefix('user/{username}')->group(function () { + Route::prefix('repository')->as('repository.')->group(function () { + Route::get('create', [\App\Http\Controllers\Site\RepositoriesController::class, 'create'])->name('create'); + Route::post('/', [\App\Http\Controllers\Site\RepositoriesController::class, 'store'])->name('store'); + Route::get('edit/{repository_name}', [\App\Http\Controllers\Site\RepositoriesController::class, 'edit'])->name('edit'); + Route::put('edit/{repository_name}', [\App\Http\Controllers\Site\RepositoriesController::class, 'update'])->name('update'); + Route::delete('{repository_name}', [\App\Http\Controllers\Site\RepositoriesController::class, 'destroy'])->name('destroy'); + }); + }); + + Route::delete('repository/{username}/{repository_name}/tag/{tag_id}', [\App\Http\Controllers\Site\TagRepositoriesController::class, 'destroy'])->name('repository.tag.destroy'); }); diff --git a/app/docker/start_dev.sh b/app/docker/start_dev.sh index 007ea47..a4da1d5 100755 --- a/app/docker/start_dev.sh +++ b/app/docker/start_dev.sh @@ -5,7 +5,10 @@ if [ "$role" = "app" ]; then exec unitd --no-daemon --control unix:/var/run/control.unit.sock --user unit --group unit elif [ "$role" = "queue" ]; then echo "Running the queue..." - php /var/www/html/artisan queue:work --verbose --sleep=5 --tries=10 + while [ true ] + do + php /var/www/html/artisan queue:work --verbose --sleep=5 --tries=100 --backoff=10 --max-time=3600 --queue=high,normal,low,default + done elif [ "$role" = "websockets" ]; then echo "Running the websockets..." php /var/www/html/artisan reverb:start --port=9000 diff --git a/app/docker/start_prod.sh b/app/docker/start_prod.sh index e4d2379..a4da1d5 100755 --- a/app/docker/start_prod.sh +++ b/app/docker/start_prod.sh @@ -5,7 +5,10 @@ if [ "$role" = "app" ]; then exec unitd --no-daemon --control unix:/var/run/control.unit.sock --user unit --group unit elif [ "$role" = "queue" ]; then echo "Running the queue..." - php /var/www/html/artisan queue:work --verbose --sleep=5 --tries=10 --max-time=3600 + while [ true ] + do + php /var/www/html/artisan queue:work --verbose --sleep=5 --tries=100 --backoff=10 --max-time=3600 --queue=high,normal,low,default + done elif [ "$role" = "websockets" ]; then echo "Running the websockets..." php /var/www/html/artisan reverb:start --port=9000 diff --git a/certs/.gitignore b/certs/.gitignore new file mode 100644 index 0000000..7feff33 --- /dev/null +++ b/certs/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!readme.txt diff --git a/certs/readme.txt b/certs/readme.txt new file mode 100644 index 0000000..59fdc79 --- /dev/null +++ b/certs/readme.txt @@ -0,0 +1,2 @@ +openssl req -nodes -newkey rsa:4096 -keyout registry-auth1.key -out registry-auth1.csr -subj "/CN=token_issuer" +openssl x509 -in registry-auth1.csr -out registry-auth1.crt -req -signkey registry-auth1.key -days 3650 diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index a031a78..d82c456 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -14,6 +14,7 @@ services: - ${DOCKER_APP_PORT}:9000 volumes: - ./app/application:/var/www/html + - ./certs:/var/www/html/resources/certs:ro queue: build: @@ -28,6 +29,7 @@ services: CONTAINER_ROLE: queue volumes: - ./app/application:/var/www/html + - ./certs:/var/www/html/resources/certs:ro scheduler: build: context: app @@ -41,6 +43,7 @@ services: CONTAINER_ROLE: scheduler volumes: - ./app/application:/var/www/html + - ./certs:/var/www/html/resources/certs:ro app-redis: image: redis:3.0-alpine # restart: always @@ -61,7 +64,7 @@ services: - ./db/data:/var/lib/mysql captcha-app: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 cap_drop: - ALL cap_add: @@ -73,10 +76,14 @@ services: - db - captcha-redis env_file: captcha-app/.env + # restart: always + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs ports: - ${DOCKER_CAPTCHA_PORT}:9000 captcha-queue: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -85,7 +92,7 @@ services: CONTAINER_ROLE: queue env_file: captcha-app/.env captcha-reverb: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -93,10 +100,13 @@ services: environment: CONTAINER_ROLE: websockets env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs ports: - ${DOCKER_CAPTCHA_WEBSOCKET_PORT}:9000 captcha-scheduler: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -104,7 +114,28 @@ services: environment: CONTAINER_ROLE: scheduler env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs captcha-redis: image: redis:3.0-alpine + # restart: always volumes: - ./captcha-app/redis/data:/data + + registry-web: + image: nginx:alpine + ports: + - ${DOCKER_REGISTRY_WEB}:80 + depends_on: + - registry + # restart: always + volumes: + - ./registry/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + registry: + image: registry:2 + # restart: always + volumes: + - ./registry/data:/images + - ./certs:/certs:ro + - ./registry/config.yml:/etc/docker/registry/config.yml:ro \ No newline at end of file diff --git a/docker-compose-prod_docker-hub.yml b/docker-compose-prod_docker-hub.yml index 4f61e8f..46d2644 100644 --- a/docker-compose-prod_docker-hub.yml +++ b/docker-compose-prod_docker-hub.yml @@ -1,7 +1,7 @@ version: '3.7' services: app: - image: korelf/my-projects-website:0.1.0 + image: korelf/mdhub:0.1.0-beta # restart: always depends_on: - db @@ -13,8 +13,9 @@ services: volumes: - ./app/storage/app:/var/www/html/storage/app - ./app/storage/logs:/var/www/html/storage/logs + - ./certs:/var/www/html/resources/certs:ro queue: - image: korelf/service-captcha:0.8.1 + image: korelf/mdhub:0.1.0-beta # restart: always depends_on: - db @@ -22,8 +23,12 @@ services: environment: CONTAINER_ROLE: queue env_file: app/.env + volumes: + - ./app/storage/app:/var/www/html/storage/app + - ./app/storage/logs:/var/www/html/storage/logs + - ./certs:/var/www/html/resources/certs:ro scheduler: - image: korelf/service-captcha:0.8.1 + image: korelf/mdhub:0.1.0-beta # restart: always depends_on: - db @@ -31,6 +36,10 @@ services: environment: CONTAINER_ROLE: scheduler env_file: app/.env + volumes: + - ./app/storage/app:/var/www/html/storage/app + - ./app/storage/logs:/var/www/html/storage/logs + - ./certs:/var/www/html/resources/certs:ro app-redis: image: redis:3.0-alpine # restart: always @@ -52,7 +61,7 @@ services: - ./db/data:/var/lib/mysql captcha-app: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always cap_drop: - ALL @@ -65,10 +74,13 @@ services: - db - captcha-redis env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs ports: - ${DOCKER_CAPTCHA_PORT}:9000 captcha-queue: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -76,8 +88,11 @@ services: environment: CONTAINER_ROLE: queue env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs captcha-reverb: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -85,10 +100,13 @@ services: environment: CONTAINER_ROLE: websockets env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs ports: - ${DOCKER_CAPTCHA_WEBSOCKET_PORT}:9000 captcha-scheduler: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -96,8 +114,28 @@ services: environment: CONTAINER_ROLE: scheduler env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs captcha-redis: image: redis:3.0-alpine # restart: always volumes: - - ./captcha-app/redis/data:/data \ No newline at end of file + - ./captcha-app/redis/data:/data + + registry-web: + image: nginx:alpine + ports: + - ${DOCKER_REGISTRY_WEB}:80 + depends_on: + - registry + # restart: always + volumes: + - ./registry/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + registry: + image: registry:2 + # restart: always + volumes: + - ./registry/data:/images + - ./certs:/certs:ro + - ./registry/config.yml:/etc/docker/registry/config.yml:ro \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 64d3ed8..d38c341 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - ${DOCKER_APP_PORT}:9000 volumes: - ./app/application:/var/www/html + - ./certs:/var/www/html/resources/certs:ro queue: build: context: app @@ -25,6 +26,7 @@ services: CONTAINER_ROLE: queue volumes: - ./app/application:/var/www/html + - ./certs:/var/www/html/resources/certs:ro scheduler: build: context: app @@ -37,6 +39,7 @@ services: CONTAINER_ROLE: scheduler volumes: - ./app/application:/var/www/html + - ./certs:/var/www/html/resources/certs:ro app-redis: image: redis:3.0-alpine volumes: @@ -87,7 +90,7 @@ services: - ./app/application:/var/www/html captcha-app: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 cap_drop: - ALL cap_add: @@ -99,10 +102,13 @@ services: - db - captcha-redis env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs ports: - ${DOCKER_CAPTCHA_PORT}:9000 captcha-queue: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -110,8 +116,11 @@ services: environment: CONTAINER_ROLE: queue env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs captcha-reverb: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -119,10 +128,13 @@ services: environment: CONTAINER_ROLE: websockets env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs ports: - ${DOCKER_CAPTCHA_WEBSOCKET_PORT}:9000 captcha-scheduler: - image: korelf/service-captcha:0.8.1 + image: korelf/service-captcha:0.8.2 # restart: always depends_on: - db @@ -130,7 +142,25 @@ services: environment: CONTAINER_ROLE: scheduler env_file: captcha-app/.env + volumes: + - ./captcha-app/app/storage/app:/var/www/html/storage/app + - ./captcha-app/app/storage/logs:/var/www/html/storage/logs captcha-redis: image: redis:3.0-alpine volumes: - - ./captcha-app/redis/data:/data \ No newline at end of file + - ./captcha-app/redis/data:/data + + registry-web: + image: nginx:alpine + ports: + - ${DOCKER_REGISTRY_WEB}:80 + depends_on: + - registry + volumes: + - ./registry/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + registry: + image: registry:2 + volumes: + - ./registry/data:/images + - ./certs:/certs:ro + - ./registry/config.yml:/etc/docker/registry/config.yml:ro \ No newline at end of file diff --git a/registry/.gitignore b/registry/.gitignore new file mode 100644 index 0000000..8f4ed8a --- /dev/null +++ b/registry/.gitignore @@ -0,0 +1,2 @@ +data/ +config.yml \ No newline at end of file diff --git a/registry/config.yml.exmplate b/registry/config.yml.exmplate new file mode 100644 index 0000000..53e69ca --- /dev/null +++ b/registry/config.yml.exmplate @@ -0,0 +1,48 @@ +version: 0.1 +log: + fields: + service: registry +storage: + cache: + blobdescriptor: inmemory + filesystem: + rootdirectory: /images + delete: + enabled: true +auth: + token: + realm: http://real IP address OR domain/registry/v2/auth/ + service: container_registry + issuer: token_issuer + rootcertbundle: /certs/registry-auth1.crt + signingalgorithms: + - EdDSA + - HS256 + - ES512 +http: + addr: :5000 + secret: random string + headers: + X-Content-Type-Options: [nosniff] +health: + storagedriver: + enabled: true + interval: 10s + threshold: 3 +notifications: + events: + includereferences: true + endpoints: + - name: register_listener + disabled: false + url: http://app:9000/registry/v2/event/ + headers: + Authorization: [token_for_notifications] + timeout: 1s + threshold: 5 + backoff: 3s + ignoredmediatypes: + - application/octet-stream + ignore: + mediatypes: + - application/octet-stream \ No newline at end of file diff --git a/registry/nginx/nginx.conf b/registry/nginx/nginx.conf new file mode 100644 index 0000000..7cd1fdf --- /dev/null +++ b/registry/nginx/nginx.conf @@ -0,0 +1,50 @@ +events { + worker_connections 1024; +} + +http { + + upstream docker-registry { + server registry:5000; + } + + ## Set a variable to help us decide if we need to add the + ## 'Docker-Distribution-Api-Version' header. + ## The registry always sets this header. + ## In the case of nginx performing auth, the header is unset + ## since nginx is auth-ing before proxying. + map $upstream_http_docker_distribution_api_version $docker_distribution_api_version { + '' 'registry/2.0'; + } + + server { + listen 80; + + # disable any limits to avoid HTTP 413 for large image uploads + client_max_body_size 0; + + # required to avoid HTTP 411: see Issue #1486 (https://github.com/moby/moby/issues/1486) + chunked_transfer_encoding on; + + location /v2/ { + #auth_request /auth; + + # Do not allow connections from docker 1.5 and earlier + # docker pre-1.6.0 did not properly set the user agent on ping, catch "Go *" user agents + if ($http_user_agent ~ "^(docker\/1\.(3|4|5(?!\.[0-9]-dev))|Go ).*$" ) { + return 404; + } + + ## If $docker_distribution_api_version is empty, the header is not added. + ## See the map directive above where this variable is defined. + add_header 'Docker-Distribution-Api-Version' $docker_distribution_api_version always; + + proxy_pass http://registry:5000; + proxy_set_header Host $http_host; # required for docker client's sake + proxy_set_header X-Real-IP $remote_addr; # pass on real client's IP + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 900; + } + } +} \ No newline at end of file