diff --git a/.env b/.env index 114aa336..c5d9d6b0 100644 --- a/.env +++ b/.env @@ -41,6 +41,8 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###> App ### APP_DEFAULT_DATE_FORMAT='Y-m-d\TH:i:s.v\Z' APP_ACTIVATION_CODE_EXPIRE_INTERNAL=P2D +APP_TRACK_SCREEN_INFO=false +APP_TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 APP_KEY_VAULT_SOURCE=ENVIRONMENT APP_KEY_VAULT_JSON="{}" ###< App ### diff --git a/CHANGELOG.md b/CHANGELOG.md index e6badc10..8ee02783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#229](https://github.com/os2display/display-api-service/pull/229) + - Added screen status to cache and endpoint for exposing screen status. - [#225](https://github.com/os2display/display-api-service/pull/225) - Added ADRs. - [#215](https://github.com/os2display/display-api-service/pull/215) diff --git a/config/api_platform/screen.yaml b/config/api_platform/screen.yaml index 38521e43..f6388964 100644 --- a/config/api_platform/screen.yaml +++ b/config/api_platform/screen.yaml @@ -70,6 +70,8 @@ resources: ApiPlatform\Metadata\GetCollection: filters: - 'App\Filter\MultipleSearchFilter' + - 'screen.screen_user_exists_filter' + - 'screen.screen_user_latest_request_filter' - 'entity.blameable_filter' - 'entity.order_filter' - 'created.at.order_filter' @@ -100,7 +102,6 @@ resources: content: application/ld+json: examples: - headers: {} ApiPlatform\Metadata\Post: diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index c4c0fd61..653117ca 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -27,6 +27,12 @@ framework: # Default expire set to 1 day default_lifetime: 86400 + # Creates a "screen.status.cache" service ($screenStatusCache) + screen.status.cache: + adapter: cache.adapter.redis + # Default expire set to infinity + default_lifetime: 0 + # Creates an "interactive_slide.cache" service interactive_slide.cache: adapter: cache.adapter.redis diff --git a/config/packages/test/cache.yaml b/config/packages/test/cache.yaml index 7b2c8b41..ed6d6823 100644 --- a/config/packages/test/cache.yaml +++ b/config/packages/test/cache.yaml @@ -7,3 +7,6 @@ framework: auth.screen.cache: adapter: cache.adapter.filesystem default_lifetime: 3600 + screen.status.cache: + adapter: cache.adapter.filesystem + default_lifetime: 0 diff --git a/config/services.yaml b/config/services.yaml index 6f1a0b8c..48c5c8c0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -17,6 +17,8 @@ services: # @see https://api-platform.com/docs/core/state-processors/#hooking-into-the-built-in-state-processors $persistProcessor: '@api_platform.doctrine.orm.state.persist_processor' $removeProcessor: '@api_platform.doctrine.orm.state.remove_processor' + $trackScreenInfo: '%env(bool:APP_TRACK_SCREEN_INFO)%' + $trackScreenInfoUpdateIntervalSeconds: '%env(APP_TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS)%' _instanceof: App\Feed\FeedTypeInterface: @@ -295,6 +297,16 @@ services: tags: ['api_platform.filter'] arguments: [ { fullName: 'partial', email: 'partial' } ] + screen.screen_user_exists_filter: + parent: 'api_platform.doctrine.orm.exists_filter' + tags: ['api_platform.filter'] + arguments: [ { screenUser: ~ } ] + + screen.screen_user_latest_request_filter: + parent: 'api_platform.doctrine.orm.date_filter' + tags: ['api_platform.filter'] + arguments: [ { screenUser.latestRequest: ~ } ] + # App filters for Api Platform App\Filter\PublishedFilter: tags: ['api_platform.filter'] diff --git a/migrations/Version20240506084815.php b/migrations/Version20240506084815.php new file mode 100644 index 00000000..67a04e37 --- /dev/null +++ b/migrations/Version20240506084815.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE screen_user ADD release_timestamp INT DEFAULT NULL, ADD release_version VARCHAR(255) DEFAULT NULL, ADD latest_request DATETIME DEFAULT NULL, ADD client_meta JSON DEFAULT NULL COMMENT \'(DC2Type:json)\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE screen_user DROP release_timestamp, DROP release_version, DROP latest_request, DROP client_meta'); + } +} diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index 4668e98f..a84f0bb9 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -3854,6 +3854,76 @@ "explode": false, "allowReserved": false }, + { + "name": "exists[screenUser]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "screenUser.latestRequest[before]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "screenUser.latestRequest[strictly_before]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "screenUser.latestRequest[after]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "screenUser.latestRequest[strictly_after]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, { "name": "createdBy", "in": "query", @@ -11328,6 +11398,15 @@ "null" ] }, + "status": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "modifiedBy": { "type": "string" }, @@ -11403,6 +11482,15 @@ "null" ] }, + "status": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "relationsChecksum": { "type": "object" } @@ -11470,6 +11558,15 @@ "null" ] }, + "status": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "modifiedBy": { "type": "string" }, @@ -11580,6 +11677,15 @@ "null" ] }, + "status": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "modifiedBy": { "type": "string" }, @@ -11794,6 +11900,15 @@ "null" ] }, + "status": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "modifiedBy": { "type": "string" }, @@ -11904,6 +12019,15 @@ "null" ] }, + "status": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "relationsChecksum": { "type": "object" } diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index fddf4aef..6a0489e3 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -2746,6 +2746,66 @@ paths: style: form explode: false allowReserved: false + - + name: 'exists[screenUser]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: boolean + style: form + explode: false + allowReserved: false + - + name: 'screenUser.latestRequest[before]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false + - + name: 'screenUser.latestRequest[strictly_before]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false + - + name: 'screenUser.latestRequest[after]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false + - + name: 'screenUser.latestRequest[strictly_after]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false - name: createdBy in: query @@ -7911,6 +7971,12 @@ components: type: - boolean - 'null' + status: + type: + - array + - 'null' + items: + type: string modifiedBy: type: string createdBy: @@ -7963,6 +8029,12 @@ components: type: - boolean - 'null' + status: + type: + - array + - 'null' + items: + type: string relationsChecksum: type: object Screen-screen-campaigns.read: @@ -8009,6 +8081,12 @@ components: type: - boolean - 'null' + status: + type: + - array + - 'null' + items: + type: string modifiedBy: type: string createdBy: @@ -8084,6 +8162,12 @@ components: type: - boolean - 'null' + status: + type: + - array + - 'null' + items: + type: string modifiedBy: type: string createdBy: @@ -8229,6 +8313,12 @@ components: type: - boolean - 'null' + status: + type: + - array + - 'null' + items: + type: string modifiedBy: type: string createdBy: @@ -8304,6 +8394,12 @@ components: type: - boolean - 'null' + status: + type: + - array + - 'null' + items: + type: string relationsChecksum: type: object Screen.jsonld-screen-campaigns.read: diff --git a/src/Dto/Screen.php b/src/Dto/Screen.php index 6ae28510..825a013a 100644 --- a/src/Dto/Screen.php +++ b/src/Dto/Screen.php @@ -52,4 +52,7 @@ class Screen #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])] public ?bool $enableColorSchemeChange = null; + + #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])] + public ?array $status = null; } diff --git a/src/Entity/ScreenUser.php b/src/Entity/ScreenUser.php index 43893a7b..719e140c 100644 --- a/src/Entity/ScreenUser.php +++ b/src/Entity/ScreenUser.php @@ -12,6 +12,7 @@ use App\Utils\Roles; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -20,15 +21,27 @@ class ScreenUser extends AbstractTenantScopedEntity implements UserInterface, Te { final public const string ROLE_SCREEN = Roles::ROLE_SCREEN; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 180, unique: true)] + #[ORM\Column(type: Types::STRING, length: 180, unique: true)] private string $username; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)] + #[ORM\Column(type: Types::JSON)] private array $roles = []; #[ORM\OneToOne(inversedBy: 'screenUser', targetEntity: Screen::class)] private Screen $screen; + #[ORM\Column(nullable: true)] + private ?int $releaseTimestamp = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $releaseVersion = null; + + #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] + private ?\DateTimeInterface $latestRequest = null; + + #[ORM\Column(nullable: true)] + private ?array $clientMeta = null; + /** * @deprecated since Symfony 5.3, use getUserIdentifier instead */ @@ -152,4 +165,52 @@ public function getBlamableIdentifier(): string { return 'Screen-'.$this->screen->getId()?->jsonSerialize(); } + + public function getReleaseTimestamp(): ?int + { + return $this->releaseTimestamp; + } + + public function setReleaseTimestamp(?int $releaseTimestamp): static + { + $this->releaseTimestamp = $releaseTimestamp; + + return $this; + } + + public function getReleaseVersion(): ?string + { + return $this->releaseVersion; + } + + public function setReleaseVersion(?string $releaseVersion): static + { + $this->releaseVersion = $releaseVersion; + + return $this; + } + + public function getLatestRequest(): ?\DateTimeInterface + { + return $this->latestRequest; + } + + public function setLatestRequest(?\DateTimeInterface $latestRequest): static + { + $this->latestRequest = $latestRequest; + + return $this; + } + + public function getClientMeta(): ?array + { + return $this->clientMeta; + } + + public function setClientMeta(?array $clientMeta): static + { + $this->clientMeta = $clientMeta; + + return $this; + } } diff --git a/src/EventSubscriber/ScreenUserRequestSubscriber.php b/src/EventSubscriber/ScreenUserRequestSubscriber.php new file mode 100644 index 00000000..335bcc58 --- /dev/null +++ b/src/EventSubscriber/ScreenUserRequestSubscriber.php @@ -0,0 +1,117 @@ +getRequest()->getPathInfo(); + + if ($this->trackScreenInfo && preg_match("/^\/v2\/screens\/[A-Za-z0-9]{26}$/i", $pathInfo)) { + $user = $this->security->getUser(); + + if ($user instanceof ScreenUser) { + $key = $user->getId()?->jsonSerialize() ?? null; + + if (null === $key) { + return; + } + + $this->screenStatusCache->get($key, fn (CacheItemInterface $item) => $this->createCacheEntry($item, $event, $user)); + } + } + } + + private function createCacheEntry(CacheItemInterface $item, RequestEvent $event, ScreenUser $screenUser): array + { + $item->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds); + + $requestDateTime = new \DateTime(); + + $request = $event->getRequest(); + $referer = $request->headers->get('referer') ?? ''; + $url = parse_url($referer); + $queryString = $url['query'] ?? ''; + $queryArray = []; + + if (!empty($queryString)) { + parse_str($queryString, $queryArray); + } + + $releaseVersion = $queryArray['releaseVersion'] ?? null; + $releaseTimestamp = $queryArray['releaseTimestamp'] ?? null; + + // Update screen user fields. + $screenUser->setReleaseTimestamp((int) $releaseTimestamp); + $screenUser->setReleaseVersion($releaseVersion); + $screenUser->setLatestRequest($requestDateTime); + + $userAgent = $request->headers->get('user-agent') ?? ''; + $ip = $request->getClientIp(); + $host = preg_replace("/\?.*$/i", '', $referer); + + $clientMeta = [ + 'host' => $host, + 'userAgent' => $userAgent, + 'ip' => $ip, + ]; + + $token = $this->security->getToken(); + + if (null !== $token) { + $decodedToken = $this->tokenManager->decode($token); + $expire = $decodedToken['exp'] ?? 0; + $expireDateTime = (new \DateTime())->setTimestamp($expire); + $now = new \DateTime(); + + $tokenExpired = $expireDateTime < $now; + + $clientMeta['tokenExpired'] = $tokenExpired; + } + + $screenUser->setClientMeta($clientMeta); + + $this->entityManager->flush(); + $this->entityManager->clear(); + + return [ + 'latestRequestDateTime' => $requestDateTime->format('c'), + 'releaseVersion' => $releaseVersion, + 'releaseTimestamp' => $releaseTimestamp, + 'clientMeta' => $clientMeta, + ]; + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => 'onKernelRequest', + ]; + } +} diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php index 3974577e..f4015d9c 100644 --- a/src/State/ScreenProvider.php +++ b/src/State/ScreenProvider.php @@ -7,6 +7,7 @@ use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\State\ProviderInterface; use App\Dto\Screen as ScreenDTO; +use App\Entity\ScreenUser; use App\Entity\Tenant\Screen; use App\Repository\ScreenRepository; @@ -16,6 +17,7 @@ public function __construct( private readonly IriConverterInterface $iriConverter, ProviderInterface $collectionProvider, ScreenRepository $entityRepository, + private readonly bool $trackScreenInfo = false, ) { parent::__construct($collectionProvider, $entityRepository); } @@ -62,6 +64,28 @@ public function toOutput(object $object): ScreenDTO } } + if ($this->trackScreenInfo) { + $screenUser = $object->getScreenUser(); + + $status = null; + + if (null != $screenUser) { + $status = $this->getStatus($screenUser); + } + + $output->status = $status; + } + return $output; } + + private function getStatus(ScreenUser $screenUser): array + { + return [ + 'latestRequestDateTime' => $screenUser->getLatestRequest()?->format('c'), + 'releaseVersion' => $screenUser->getReleaseVersion(), + 'releaseTimestamp' => $screenUser->getReleaseTimestamp(), + 'clientMeta' => $screenUser->getClientMeta(), + ]; + } }