Compare commits

...

6 Commits

155 changed files with 5160 additions and 90 deletions

View File

@ -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

View File

@ -1,11 +1,19 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:LAXuVFnfcrPlPcYftpV/hJ7mEO1TlbriMaDN7hT5WOo=
APP_KEY=
APP_DEBUG=true
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=

View File

@ -0,0 +1,9 @@
<?php declare(strict_types=1);
namespace App\Contracts\Services\Registry;
interface Base32
{
public function encode(string $input, bool $padding = true): string;
public function decode(string $input): string;
}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace App\Dto\Builder;
final readonly class Repository
{
public function __construct(
private ?bool $isPublic,
) { }
public function getIsPublic(): ?bool
{
return $this->isPublic;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,39 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2;
final readonly class AuthorizationConfig
{
public function __construct(
private string $serviceName,
private string $privateKeyName,
private string $issuer,
private string $algorithm,
private int $expiresInSeconds,
) { }
public function getServiceName(): string
{
return $this->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;
}
}

View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2\AuthorizationService;
use App\Dto\Service\Dto;
use App\Models\User;
final readonly class Authorization extends Dto
{
public function __construct(
private string $service,
private ?Scope $scope,
private ?User $user = null,
) { }
public function getService(): string
{
return $this->service;
}
public function getScope(): ?Scope
{
return $this->scope;
}
public function getUser(): ?User
{
return $this->user;
}
}

View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2\AuthorizationService;
final readonly class Scope
{
public function __construct(
private string $username,
private string $repositoryName,
private array $actions = [],
) { }
public static function parse(string $scope): Scope
{
[$none, $repository, $actions] = explode(':', $scope, 3);
unset($none);
[$username, $repositoryName] = explode('/', $repository, 2);
return new Scope(
username: $username,
repositoryName: $repositoryName,
actions: explode(',', $actions),
);
}
public function getUsername(): string
{
return $this->username;
}
public function getRepositoryName(): string
{
return $this->repositoryName;
}
public function getActions(): array
{
return $this->actions;
}
}

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2\EventService;
use App\Dto\Service\Dto;
final readonly class DataProcessing extends Dto
{
public function __construct(
private Events $events
) { }
public function getEvents(): Events
{
return $this->events;
}
}

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2\EventService;
use App\Dto\Service\Dto;
final readonly class Event extends Dto
{
public function __construct(
private string $timestamp,
private string $action,
private array $target,
) { }
public function getTimestamp(): string
{
return $this->timestamp;
}
public function getAction(): string
{
return $this->action;
}
public function getTarget(): array
{
return $this->target;
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2\EventService;
final class Events
{
/**
* @var array [...Event]
*/
private array $events;
public function addEvent(Event $event): void
{
$this->events[] = $event;
}
public function getEvents(): array
{
return $this->events;
}
}

View File

@ -0,0 +1,33 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2\EventService;
final readonly class ImageDataPull
{
public function __construct(
private string $username,
private string $repositoryName,
private string $tag,
private string $digest,
) { }
public function getUsername(): string
{
return $this->username;
}
public function getRepositoryName(): string
{
return $this->repositoryName;
}
public function getTag(): string
{
return $this->tag;
}
public function getDigest(): string
{
return $this->digest;
}
}

View File

@ -0,0 +1,39 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Registry\V2\EventService;
final readonly class ImageDataPush
{
public function __construct(
private string $username,
private string $repositoryName,
private string $tag,
private string $digest,
private int $size,
) { }
public function getUsername(): string
{
return $this->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;
}
}

View File

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site\AccessTokens;
use App\Dto\Service\Pages;
final readonly class Index extends Pages
{
public function __construct(
int $page
) {
parent::__construct($page);
}
}

View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site\AccessTokens;
use App\Dto\Service\Dto;
use App\Enums\AccessTokenPermission;
final readonly class StoreUpdate extends Dto
{
public function __construct(
private string $name,
private AccessTokenPermission $permission,
) { }
public function getName(): string
{
return $this->name;
}
public function getPermission(): AccessTokenPermission
{
return $this->permission;
}
}

View File

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site\Repository;
use App\Dto\Service\Pages;
final readonly class Repositories extends Pages
{
public function __construct(
int $page
) {
parent::__construct($page);
}
}

View File

@ -0,0 +1,35 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site\Repository;
use App\Dto\Service\Dto;
final readonly class StoreUpdate extends Dto
{
public function __construct(
private bool $isPublic,
private ?string $description,
private ?string $overview,
private ?string $name = null,
) { }
public function getDescription(): ?string
{
return $this->description;
}
public function getOverview(): ?string
{
return $this->overview;
}
public function isPublic(): bool
{
return $this->isPublic;
}
public function getName(): ?string
{
return $this->name;
}
}

View File

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site\TagRepository;
use App\Dto\Service\Pages;
final readonly class Tags extends Pages
{
public function __construct(
int $page
) {
parent::__construct($page);
}
}

View File

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site\User;
use App\Dto\Service\Pages;
final readonly class User extends Pages
{
public function __construct(
int $page
) {
parent::__construct($page);
}
}

View File

@ -0,0 +1,35 @@
<?php declare(strict_types=1);
namespace App\Enums;
use Illuminate\Support\Collection;
enum AccessTokenPermission: string
{
case RepoReadWriteDelete = 'repo:read.write.delete';
case RepoReadWrite = 'repo:read.write';
case RepoRead = 'repo:read';
public function getTitle(): string
{
return __('access-token-permission.' . $this->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());
}
}

View File

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace App\Enums;
use Illuminate\Cache\TaggedCache;
use Illuminate\Support\Facades\Cache;
enum CacheTag: string
{
case RegistryV2PrivateKey = 'registry_v2_private_key';
public function getCache(): TaggedCache
{
return Cache::tags($this->value);
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace App\Enums;
use App\Models\User;
enum Morph: int
{
case User = 1;
public function getPathModel(): string
{
return match ($this) {
self::User => 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;
}
}

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Jobs\Registry\V2;
final class JobException extends \Exception
{
}

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services\Registry\V2\ArchitectureCommand;
final class BlobException extends \Exception
{
}

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services\Registry\V2\ArchitectureCommand;
final class ManifestException extends \Exception
{
}

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services\Registry\V2\EventService;
final class DataProcessingException extends \Exception
{
}

View File

@ -32,4 +32,15 @@ public static function formatAttributeNameToRequestName(string $name): string
->rtrim('.')
->value();
}
public static function shortDigest(string $digest): string
{
$digest = Str::of($digest)->explode(':', 2)->last();
return Str::of($digest)->take(12)->toString();
}
public static function dockerServiceAddress(): string
{
return Str::of(config('registry.service_http'))->remove(['http://', 'https://'])->toString();
}
}

View File

@ -0,0 +1,74 @@
<?php declare(strict_types=1);
namespace App\Helpers;
use Illuminate\Support\Number as LaravelNumber;
final readonly class Number
{
private const array ABBREVIATE_UNITS = [
3 => '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;
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Registry;
use App\Http\Controllers\Controller as BaseController;
abstract class Controller extends BaseController
{
}

View File

@ -0,0 +1,40 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Registry;
use App\Http\Requests\Registry\Version2\AuthRequest;
use App\Http\Requests\Registry\Version2\EventRequest;
use App\Services\Registry\V2\AuthorizationService;
use App\Services\Registry\V2\EventService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
final class Version2 extends Controller
{
public function __construct(
private readonly AuthorizationService $authorizationService,
private readonly EventService $eventService,
) { }
public function auth(AuthRequest $request): JsonResponse
{
$data = $request->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()]);
}
}

View File

@ -0,0 +1,94 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Dto\QuerySettingsDto;
use App\Http\Requests\Site\AccessTokens\IndexRequest;
use App\Http\Requests\Site\AccessTokens\StoreUpdateRequest;
use App\Http\Resources\Site\AccessTokens\Store;
use App\Services\Site\AccessTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class AccessTokensController extends Controller
{
public function __construct(
private readonly AccessTokenService $accessTokenService
) { }
public function index(IndexRequest $request): View
{
$user = $request->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());
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers\Site;
abstract class Controller
use App\Http\Controllers\Controller as BaseController;
abstract class Controller extends BaseController
{
//
}

View File

@ -1,13 +0,0 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use Illuminate\View\View;
final class HomeController extends Controller
{
public function index(): View
{
return \view('site.home.index');
}
}

View File

@ -0,0 +1,96 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Dto\QuerySettingsDto;
use App\Http\Requests\Site\Repositories\RepositoriesRequest;
use App\Http\Requests\Site\Repositories\StoreUpdateRequest;
use App\Services\Site\RepositoryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class RepositoriesController extends Controller
{
public function __construct(
private readonly RepositoryService $repositoryService,
) { }
public function repositories(RepositoriesRequest $request): View
{
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $request->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());
}
}

View File

@ -0,0 +1,42 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Dto\QuerySettingsDto;
use App\Http\Requests\Site\TagRepositories\TagsRequest;
use App\Services\Site\TagRepositoryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
final class TagRepositoriesController extends Controller
{
public function __construct(
private readonly TagRepositoryService $tagRepositoryService,
) { }
public function tags(string $username, string $repositoryName, TagsRequest $request): View
{
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $request->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());
}
}

View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Dto\QuerySettingsDto;
use App\Http\Requests\Site\User\UserRequest;
use App\Services\Site\UserService;
use Illuminate\View\View;
final class UserController extends Controller
{
public function __construct(
private readonly UserService $userService,
) { }
public function user(string $username, UserRequest $request): View
{
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $request->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());
}
}

View File

@ -0,0 +1,69 @@
<?php declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use Closure;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
final class RegistryAuth
{
/**
* @throws AuthenticationException
*/
public function handle(Request $request, Closure $next)
{
if (RateLimiter::tooManyAttempts(key: 'registry-auth:' . $request->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;
}
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;
use Closure;
use Illuminate\Routing\Exceptions\MissingRateLimiterException;
final class RegistryAuthNotification
{
/**
* @throws AuthenticationException | MissingRateLimiterException
*/
public function handle(Request $request, Closure $next)
{
if (RateLimiter::tooManyAttempts(key: 'registry-auth-notification:' . $request->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);
}
}

View File

@ -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),
);
}

View File

@ -0,0 +1,36 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Registry\Version2;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Registry\V2\AuthorizationService\Authorization;
use App\Dto\Service\Registry\V2\AuthorizationService\Scope;
use Illuminate\Foundation\Http\FormRequest;
final class AuthRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'service' => ['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(),
);
}
}

View File

@ -0,0 +1,42 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Registry\Version2;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Registry\V2\EventService\DataProcessing;
use App\Dto\Service\Registry\V2\EventService\Event;
use App\Dto\Service\Registry\V2\EventService\Events;
use Illuminate\Foundation\Http\FormRequest;
final class EventRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'events' => '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);
}
}

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Site\AccessTokens;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Site\AccessTokens\Index;
use Illuminate\Foundation\Http\FormRequest;
final class IndexRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$this->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)
);
}
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Site\AccessTokens;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Site\AccessTokens\StoreUpdate;
use App\Enums\AccessTokenPermission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
final class StoreUpdateRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['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')
),
);
}
}

View File

@ -0,0 +1,27 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Site\Repositories;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Site\Repository\Repositories;
use Illuminate\Foundation\Http\FormRequest;
final class RepositoriesRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'page' => ['nullable', 'numeric', 'min:1']
];
}
public function getDto(): Repositories
{
return new Repositories(
page: (int) $this->input('page', 1)
);
}
}

View File

@ -0,0 +1,40 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Site\Repositories;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Site\Repository\StoreUpdate;
use App\Rules\Repository;
use Illuminate\Foundation\Http\FormRequest;
final class StoreUpdateRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$rules = [
'description' => ['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'),
);
}
}

View File

@ -0,0 +1,27 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Site\TagRepositories;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Site\TagRepository\Tags;
use Illuminate\Foundation\Http\FormRequest;
final class TagsRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'page' => ['nullable', 'numeric', 'min:1']
];
}
public function getDto(): Tags
{
return new Tags(
page: (int) $this->input('page', 1)
);
}
}

View File

@ -0,0 +1,27 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Site\User;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Site\User\User;
use Illuminate\Foundation\Http\FormRequest;
class UserRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'page' => ['nullable', 'numeric', 'min:1']
];
}
public function getDto(): User
{
return new User(
page: (int) $this->input('page', 1)
);
}
}

View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
namespace App\Http\Resources\Site\AccessTokens;
use App\ServiceResults\Site\AccessTokenService\StoreTokenResult;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class Store extends JsonResource
{
/**
* @var StoreTokenResult
*/
public $resource;
public function toArray(Request $request): array
{
return [
'name' => $this->resource->getName(),
'permission' => $this->resource->getPermission()->getTitle(),
'token' => $this->resource->getToken(),
];
}
}

View File

@ -0,0 +1,60 @@
<?php declare(strict_types=1);
namespace App\Jobs\Registry\V2;
use App\Dto\Service\Registry\V2\EventService\ImageDataPull;
use App\Exceptions\Jobs\Registry\V2\JobException;
use App\Services\Registry\V2\EventService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class ProcessEventPull implements ShouldQueue, ShouldBeEncrypted
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public readonly array $target
) {
//
}
/**
* Execute the job.
*/
public function handle(EventService $eventService): void
{
if (!isset($this->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<int, int>
*/
public function backoff(): array
{
return [10, 60, 600, 1800, 3600];
}
}

View File

@ -0,0 +1,70 @@
<?php declare(strict_types=1);
namespace App\Jobs\Registry\V2;
use App\Dto\Service\Registry\V2\EventService\ImageDataPush;
use App\Exceptions\Jobs\Registry\V2\JobException;
use App\Services\Registry\V2\EventService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class ProcessEventPush implements ShouldQueue, ShouldBeEncrypted
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 1000;
/**
* Create a new job instance.
*/
public function __construct(
public readonly array $target
) {
//
}
/**
* Calculate the number of seconds to wait before retrying the job.
*
* @return array<int, int>
*/
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());
}
}
}

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Architecture extends Model
{
use HasFactory;
protected $table = 'architectures';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
];
public function os(): BelongsTo
{
return $this->belongsTo(OperatingSystem::class, 'operating_system_id');
}
}

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class OperatingSystem extends Model
{
use HasFactory;
protected $table = 'operating_systems';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
];
public function architectures(): HasMany
{
return $this->hasMany(Architecture::class, 'operating_system_id');
}
}

View File

@ -0,0 +1,71 @@
<?php declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Repository extends Model
{
use HasFactory, SoftDeletes;
/**
* The model's default values for attributes.
*
* @var array
*/
protected $attributes = [
'is_public' => true,
];
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'is_public',
'description',
'overview',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
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');
}
}

View File

@ -0,0 +1,53 @@
<?php declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
final class TagRepository extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'tags_repository';
/**
* All of the relationships to be touched.
*
* @var array
*/
protected $touches = ['repository'];
/**
* The model's default values for attributes.
*
* @var array
*/
protected $attributes = [
'quantity_pulls' => 0,
];
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
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');
}
}

View File

@ -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
*/

View File

@ -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;
}

View File

@ -0,0 +1,89 @@
<?php declare(strict_types=1);
namespace App\Policies;
use App\Enums\AccessTokenPermission;
use App\Models\Repository;
use App\Models\User;
use Laravel\Sanctum\Contracts\HasAbilities;
final readonly class RepositoryPolicy extends Policy
{
public function create(User $user, User $repositoryUser): bool
{
return $user->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;
}
}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace App\Policies;
use App\Models\Repository;
use App\Models\TagRepository;
use App\Models\User;
final readonly class TagRepositoryPolicy extends Policy
{
public function delete(User $user, TagRepository $tag): bool
{
return $user->id === $tag->repository?->user_id;
}
}

View File

@ -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;
}
);
}
/**

View File

@ -0,0 +1,35 @@
<?php declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
final class BladeProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Blade::directive('abbreviate_int', function ($expression) {
return sprintf('<?php echo \App\Helpers\Number::abbreviate(%s) ; ?>', $expression);
});
Blade::directive('short_digest', function ($expression) {
return sprintf('<?php echo \App\Helpers\Helpers::shortDigest(%s) ; ?>', $expression);
});
Blade::directive('size_for_humans', function ($expression) {
return sprintf('<?php echo \App\Helpers\Number::sizeForHumans(%s) ; ?>', $expression);
});
}
}

View File

@ -0,0 +1,45 @@
<?php declare(strict_types=1);
namespace App\Providers;
use App\Contracts\Services\Registry\Base32;
use App\Dto\Service\Registry\V2\AuthorizationConfig;
use App\Services\Registry\V2\Api\RequestCommandHandler;
use App\Services\Registry\V2\Base32CommandHandler;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
final class RegistryServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->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
{
}
}

View File

@ -0,0 +1,27 @@
<?php declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\Search;
use App\Models\User;
use App\Services\Search\CreateSearchInstanceCommand;
use Laravel\Sanctum\PersonalAccessToken;
final readonly class AccessTokenRepository
{
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
) { }
public function getUserTokens(User $user, array $with = []): Search
{
$query = $user->tokens()->with($with);
return $this->createSearchInstanceCommand->execute($query);
}
public function getTokenById(User $user, int $id): ?PersonalAccessToken
{
return $user->tokens()->where('id', $id)->first();
}
}

View File

@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\Search;
use App\Models\Repository;
use App\Models\User;
use App\Dto\Builder\Repository as RepositoryBuilderDto;
use App\Services\Repository\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
final readonly class RepositoryRepository
{
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
private BuilderCommand $builderCommand,
) { }
public function getRepositoryByName(User $user, string $name, bool $withTrashed = false): ?Repository
{
$query = $user->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();
}
}

View File

@ -0,0 +1,27 @@
<?php declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\Search;
use App\Models\Repository;
use App\Models\TagRepository;
use App\Services\Search\CreateSearchInstanceCommand;
final readonly class TagRepositoryRepository
{
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
) { }
public function getTagById(int $id): ?TagRepository
{
return TagRepository::query()->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);
}
}

View File

@ -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(

View File

@ -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) {

View File

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace App\ServiceResults\Registry\V2;
use App\Models\Architecture;
use App\ServiceResults\ServiceResult;
final class ArchitectureResult extends ServiceResult
{
public function __construct(
private readonly Architecture $architecture,
) { }
public function getArchitecture(): Architecture
{
return $this->architecture;
}
}

View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
namespace App\ServiceResults\Site\AccessTokenService;
use App\Enums\AccessTokenPermission;
use App\ServiceResults\ServiceResult;
final class StoreTokenResult extends ServiceResult
{
public function __construct(
private readonly string $name,
private readonly AccessTokenPermission $permission,
private readonly string $token,
) { }
public function getName(): string
{
return $this->name;
}
public function getPermission(): AccessTokenPermission
{
return $this->permission;
}
public function getToken(): string
{
return $this->token;
}
}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace App\ServiceResults\TagRepository;
use App\Models\Repository;
use App\Models\TagRepository;
use App\ServiceResults\ServiceResult;
final class IncrementPullResult extends ServiceResult
{
public function __construct(
private readonly Repository $repository,
private readonly TagRepository $tagRepository,
) { }
}

View File

@ -62,7 +62,7 @@ public function edit(int $id, User $user): ServiceResultError | ServiceResultArr
{
$modelUser = $this->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(),
];
}

View File

@ -0,0 +1,70 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Dto\Service\Registry\V2\AuthorizationService\Scope;
use App\Models\Repository;
use App\Models\User;
use App\Repositories\RepositoryRepository;
use App\Repositories\UserRepository;
final readonly class AccessCommand
{
public function __construct(
private RepositoryRepository $repositoryRepository,
private UserRepository $userRepository,
) { }
public function execute(?Scope $scope, ?User $user): array
{
if (\is_null($scope)) {
return [];
}
$userScope = $this->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;
}
}

View File

@ -0,0 +1,67 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2\Api;
use App\Contracts\ServiceResultError;
use App\ServiceResults\ServiceResultArray;
use App\Services\Service;
use Illuminate\Support\Facades\Log;
final class ApiService extends Service
{
public function __construct(
private readonly RequestCommandHandler $requestCommandHandler,
private readonly GenerateJwtTokenCommand $generateJwtTokenCommand,
) { }
public function getManifest(string $repositoryRegistry, string $reference): ServiceResultError | ServiceResultArray
{
$access = [
$this->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,
];
}
}

View File

@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2\Api;
use App\Dto\Service\Registry\V2\AuthorizationConfig;
use App\Services\Registry\V2\JwtCommand;
use App\Services\Registry\V2\PayloadCommand;
final readonly class GenerateJwtTokenCommand
{
public function __construct(
private AuthorizationConfig $authorizationConfig,
private JwtCommand $jwtCommand,
private PayloadCommand $payloadCommand,
) { }
public function execute($access, $username): string
{
$payload = $this->payloadCommand->execute($this->authorizationConfig, $access, $username);
return $this->jwtCommand->execute($this->authorizationConfig, $payload);
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2\Api;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
final readonly class RequestCommandHandler
{
public function __construct(
private string $baseUrl,
) { }
public function handleGet(string $path, string $token): array
{
$response = $this->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);
}
}

View File

@ -0,0 +1,62 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Exceptions\Services\Registry\V2\ArchitectureCommand\BlobException;
use App\Exceptions\Services\Registry\V2\ArchitectureCommand\ManifestException;
use App\Models\Architecture;
use App\Models\OperatingSystem;
use App\ServiceResults\Registry\V2\ArchitectureResult;
use App\ServiceResults\ServiceResultError;
use App\Services\Registry\V2\Api\ApiService;
use App\Services\Service;
final class ArchitectureCommand extends Service
{
public function __construct(
private readonly ApiService $apiService,
) { }
public function execute(string $repositoryRegistry, string $digest): ServiceResultError | ArchitectureResult
{
try {
$manifest = $this->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);
}
}

View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Contracts\ServiceResultError;
use App\Dto\Service\Registry\V2\AuthorizationConfig;
use App\Dto\Service\Registry\V2\AuthorizationService\Authorization;
use App\ServiceResults\ServiceResultArray;
use App\Services\Service;
final class AuthorizationService extends Service
{
public function __construct(
private readonly AuthorizationConfig $authorizationConfig,
private readonly JwtCommand $jwtCommand,
private readonly PayloadCommand $payloadCommand,
private readonly AccessCommand $accessCommand,
) { }
public function authorization(Authorization $authorization): ServiceResultArray | ServiceResultError
{
if ($authorization->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
]);
}
}

View File

@ -0,0 +1,239 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Contracts\Services\Registry\Base32;
use InvalidArgumentException;
/**
* Encode in Base32 based on RFC 4648.
*
* https://github.com/selective-php/base32/blob/master/src/Base32.php
*/
final class Base32CommandHandler implements Base32
{
/**
* @var array<string>
*/
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<string>
*/
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<string> $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<string> $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;
}
}

View File

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Enums\CacheTag;
final readonly class ContentPrivateKeyCommand
{
public function execute(string $privateKeyName): string
{
$seconds = 3600;
return CacheTag::RegistryV2PrivateKey->getCache()
->remember(self::class . $privateKeyName, $seconds, function () use ($privateKeyName) {
return \file_get_contents( \resource_path('certs/' . $privateKeyName) );
});
}
}

View File

@ -0,0 +1,120 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Dto\Service\Registry\V2\EventService\DataProcessing;
use App\Dto\Service\Registry\V2\EventService\Event;
use App\Dto\Service\Registry\V2\EventService\ImageDataPull;
use App\Dto\Service\Registry\V2\EventService\ImageDataPush;
use App\Exceptions\Services\Registry\V2\EventService\DataProcessingException;
use App\Jobs\Registry\V2\ProcessEventPull;
use App\Jobs\Registry\V2\ProcessEventPush;
use App\Repositories\RepositoryRepository;
use App\Repositories\UserRepository;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\Services\Service;
use App\Services\TagRepository\IncrementPullCommand;
use App\Services\TagRepository\UpdateOrCreateCommand;
use Illuminate\Support\Facades\DB;
final class EventService extends Service
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly RepositoryRepository $repositoryRepository,
private readonly ArchitectureCommand $architectureCommand,
private readonly UpdateOrCreateCommand $updateOrCreateCommand,
private readonly IncrementPullCommand $incrementPullCommand,
) { }
public function dataProcessing(DataProcessing $dataProcessing): ServiceResultError | ServiceResultSuccess
{
$events = $dataProcessing->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!');
}
}

View File

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Dto\Service\Registry\V2\AuthorizationConfig;
use Firebase\JWT\JWT;
final readonly class JwtCommand
{
public function __construct(
private KidCommand $kidCommand,
private ContentPrivateKeyCommand $contentPrivateKeyCommand,
) { }
public function execute(AuthorizationConfig $authorizationConfig, array $payload): string
{
$contentPrivateKey = $this->contentPrivateKeyCommand->execute($authorizationConfig->getPrivateKeyName());
return JWT::encode(
$payload,
$contentPrivateKey,
$authorizationConfig->getAlgorithm(),
$this->kidCommand->execute($authorizationConfig->getPrivateKeyName(), $contentPrivateKey)
);
}
}

View File

@ -0,0 +1,56 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Contracts\Services\Registry\Base32;
use App\Enums\CacheTag;
final readonly class KidCommand
{
public function __construct(
private Base32 $base32CommandHandler,
) { }
public function execute(string $privateKeyName, string $privateKeyContent): string
{
$seconds = 3600;
return CacheTag::RegistryV2PrivateKey->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);
}
}

View File

@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace App\Services\Registry\V2;
use App\Dto\Service\Registry\V2\AuthorizationConfig;
final readonly class PayloadCommand
{
public function execute(AuthorizationConfig $authorizationConfig, array $access, string $username): array
{
return [
'iss' => $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,
];
}
}

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace App\Services\Repository;
use App\Dto\Builder\Repository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
final readonly class BuilderCommand
{
public function execute(Relation | Builder $query, Repository $builderDto): Relation | Builder
{
return $query->when($builderDto->getIsPublic(), function (Builder $query) use ($builderDto) {
$query->where('is_public', $builderDto->getIsPublic());
});
}
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace App\Services\Repository;
use App\Models\Repository;
use App\Models\User;
use Illuminate\Support\Str;
final readonly class RepositoryCommandHandler
{
public function handleStore(User $user, array $data, int|string $name): Repository
{
$data['name'] = Str::lower($name);
$repository = $user->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();
}
}

View File

@ -0,0 +1,114 @@
<?php declare(strict_types=1);
namespace App\Services\Site;
use App\Dto\QuerySettingsDto;
use App\Dto\Service\Site\AccessTokens\StoreUpdate;
use App\Enums\AccessTokenPermission;
use App\Helpers\Helpers;
use App\Models\User;
use App\Repositories\AccessTokenRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\Site\AccessTokenService\StoreTokenResult;
use App\ServiceResults\StoreUpdateResult;
use App\Services\Service;
final class AccessTokenService extends Service
{
public function __construct(
private readonly AccessTokenRepository $accessTokenRepository,
) { }
public function index(QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
{
$tokens = $this->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' => Helpers::dockerServiceAddress(),
'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'));
}
}

View File

@ -0,0 +1,210 @@
<?php declare(strict_types=1);
namespace App\Services\Site;
use App\Dto\Builder\Repository as RepositoryBuilder;
use App\Dto\QuerySettingsDto;
use App\Dto\Service\Site\Repository\StoreUpdate;
use App\Helpers\Helpers;
use App\Models\Repository;
use App\Models\User;
use App\Repositories\RepositoryRepository;
use App\Repositories\TagRepositoryRepository;
use App\Repositories\UserRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\StoreUpdateResult;
use App\Services\Repository\RepositoryCommandHandler;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
final class RepositoryService extends Service
{
public function __construct(
private readonly RepositoryRepository $repositoryRepository,
private readonly RepositoryCommandHandler $repositoryCommandHandler,
private readonly UserRepository $userRepository,
private readonly TagRepositoryRepository $tagRepositoryRepository,
) { }
public function repositories(QuerySettingsDto $querySettingsDto): ServiceResultArray | ServiceResultError
{
$repositories = $this->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' => Helpers::dockerServiceAddress(),
]);
}
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(),
];
}
}

View File

@ -0,0 +1,95 @@
<?php declare(strict_types=1);
namespace App\Services\Site;
use App\Dto\QuerySettingsDto;
use App\Helpers\Helpers;
use App\Models\User;
use App\Repositories\RepositoryRepository;
use App\Repositories\TagRepositoryRepository;
use App\Repositories\UserRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\Services\Service;
use App\Services\TagRepository\TagRepositoryCommandHandler;
use Illuminate\Support\Facades\DB;
final class TagRepositoryService extends Service
{
public function __construct(
private readonly RepositoryRepository $repositoryRepository,
private readonly UserRepository $userRepository,
private readonly TagRepositoryRepository $tagRepositoryRepository,
private readonly TagRepositoryCommandHandler $tagRepositoryCommandHandler,
) { }
public function tags(string $username, string $repositoryName, QuerySettingsDto $querySettingsDto, ?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'));
}
$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' => Helpers::dockerServiceAddress(),
]);
}
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'));
}
}

View File

@ -0,0 +1,51 @@
<?php declare(strict_types=1);
namespace App\Services\Site;
use App\Dto\Builder\Repository as RepositoryBuilder;
use App\Dto\QuerySettingsDto;
use App\Models\User;
use App\Repositories\RepositoryRepository;
use App\Repositories\UserRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\Services\Service;
final class UserService extends Service
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly RepositoryRepository $repositoryRepository,
) { }
public function repositories(string $username, QuerySettingsDto $querySettingsDto, ?User $user = null): ServiceResultArray | ServiceResultError
{
$pageUser = $this->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,
]);
}
}

View File

@ -0,0 +1,37 @@
<?php declare(strict_types=1);
namespace App\Services\TagRepository;
use App\Models\Repository;
use App\Models\TagRepository;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\TagRepository\IncrementPullResult;
use App\Services\Service;
final class IncrementPullCommand extends Service
{
public function execute(Repository $repository, string $tagName, string $digest): ServiceResultError | IncrementPullResult
{
$tag = $repository->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);
}
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace App\Services\TagRepository;
use App\Models\Architecture;
use App\Models\Repository;
use App\Models\TagRepository;
final readonly class TagRepositoryCommandHandler
{
public function handleStore(Repository $repository, Architecture $architecture, string $name, array $data): TagRepository
{
$tag = new TagRepository();
$tag->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();
}
}

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Services\TagRepository;
use App\Models\Architecture;
use App\Models\Repository;
use App\Models\TagRepository;
final readonly class UpdateOrCreateCommand
{
public function __construct(
private TagRepositoryCommandHandler $tagRepositoryCommandHandler,
) { }
public function execute(Repository $repository, Architecture $architecture, string $name, array $data): TagRepository
{
$tag = TagRepository::withTrashed()
->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);
}
}

View File

@ -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'])) {

View File

@ -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');
}
}

View File

@ -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(),
]);
}
}

View File

@ -0,0 +1,44 @@
<?php declare(strict_types=1);
namespace App\View\Components\Site\Forms;
use App\View\Components\Volt\Forms\Form;
use Illuminate\Support\Str;
use Illuminate\View\View;
final class Textarea extends Form
{
public function __construct(
private readonly string $title,
private readonly string $name,
private readonly ?string $value = '',
) { }
protected function getName(): string
{
return $this->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(),
]);
}
}

View File

@ -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();

View File

@ -2,4 +2,6 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\RegistryServiceProvider::class,
App\Providers\BladeProvider::class,
];

View File

@ -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",

View File

@ -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",

Some files were not shown because too many files have changed in this diff Show More