Skip to content

Commit

Permalink
PS-649 expose - authenticate webvtt (#438)
Browse files Browse the repository at this point in the history
  • Loading branch information
4rthem authored May 14, 2024
1 parent 0323b3a commit 199e981
Show file tree
Hide file tree
Showing 20 changed files with 212 additions and 92 deletions.
28 changes: 28 additions & 0 deletions expose/api/fixtures/Asset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,56 @@ App\Entity\Asset:
asset (template):
size: 42
title: '<name()>'
description: '<name()>'
publication: '@pub_*'

a_audio{1..10} (extends asset):
mimeType: audio/mp3
title: 'Audio <current()>'
description: 'Audio <current()>'
path: '<randomMedia("assets", "mp3", <current()>)>'
originalName: '<current()>.mp3'

a_video{1..10} (extends asset):
title: 'Video <current()>'
description: 'Video <current()>'
mimeType: video/mp4
path: '<randomMedia("assets", "mp4", <current()>)>'
originalName: '<current()>.mp4'
webVTT:
- locale: fr
label: FR
content: |
WEBVTT
00:01.000 --> 00:04.000
- Ceci est un sous titre en français.
00:05.000 --> 00:09.000
- Une première répblique !
- Une deuxième.
- locale: en
label: EN
content: |
WEBVTT
00:01.000 --> 00:04.000
- This is an english subtitle
00:05.000 --> 00:09.000
- A sentence !
- A second one.
a_pdf{1..10} (extends asset):
title: 'PDF <current()>'
description: 'PDF <current()>'
mimeType: application/pdf
path: '<randomMedia("assets", "pdf", <current()>)>'
originalName: '<current()>.pdf'

a_img{1..10} (extends asset):
title: 'Image <current()>'
description: 'Image <current()>'
mimeType: image/jpeg
path: '<imageUrlRandomRatio("assets", <current()>, 1000)>'
originalName: '<current()>.jpg'
Expand Down
11 changes: 11 additions & 0 deletions expose/api/fixtures/Publication.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ App\Entity\PublicationConfig:
enabled: true
publiclyListed: true
layout: grid
password-protected:
enabled: true
publiclyListed: true
layout: grid
securityMethod: password
password: xxx

App\Entity\PublicationProfile:
pr-1:
Expand All @@ -39,3 +45,8 @@ App\Entity\Publication:
slug: grid-pub
profile: '@pr-1'
config: '@config-grid'

pub_password:
title: Password protected publication
profile: '@pr-1'
config: '@password-protected'
10 changes: 6 additions & 4 deletions expose/api/src/Controller/GetAssetWebVTTAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ public function __invoke(string $id, string $vttId, Request $request): Response
}

$response = new Response($webVTT['content'], 200, [...$corsHeaders, 'Content-Type' => 'text/vtt']);
$response->setCache([
's_maxage' => 7_776_000,
'max_age' => 7_776_000,
$options = [
's_maxage' => 86400,
'max_age' => 86400,
'public' => true,
]);
];

$response->setCache($options);

return $response;
}
Expand Down
3 changes: 3 additions & 0 deletions expose/api/src/Entity/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@ class Asset implements MediaInterface, \Stringable
'content' => [
new Assert\NotBlank(),
],
'kind' => [
new Assert\Optional(),
],
],
),
])]
Expand Down
5 changes: 5 additions & 0 deletions expose/api/src/Entity/Publication.php
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,11 @@ public function getTheme(): ?string
return $this->config->getTheme() ?? $this->profile?->getConfig()->getTheme();
}

public function isPublic(): bool
{
return null === $this->getSecurityMethod();
}

#[Groups(['_', self::GROUP_LIST, self::GROUP_READ, Asset::GROUP_READ])]
public function getSecurityMethod(): ?string
{
Expand Down
10 changes: 7 additions & 3 deletions expose/api/src/Security/Voter/AssetVoter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@

class AssetVoter extends Voter
{
use JwtVoterTrait;

final public const READ = 'READ';
final public const EDIT = 'EDIT';
final public const DELETE = 'DELETE';
final public const CREATE = 'CREATE';

public function __construct(private readonly Security $security)
{
public function __construct(
private readonly Security $security,
) {
}

protected function supports($attribute, $subject): bool
Expand All @@ -31,7 +34,8 @@ protected function supports($attribute, $subject): bool
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
return match ($attribute) {
self::READ => $this->security->isGranted(PublicationVoter::READ_DETAILS, $subject->getPublication()),
self::READ => $this->isValidJWTForRequest()
|| $this->security->isGranted(PublicationVoter::READ_DETAILS, $subject->getPublication()),
self::CREATE, self::DELETE, self::EDIT => $this->security->isGranted(PublicationVoter::EDIT, $subject->getPublication()),
default => false,
};
Expand Down
48 changes: 48 additions & 0 deletions expose/api/src/Security/Voter/JwtVoterTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace App\Security\Voter;

use App\Security\Authentication\JWTManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Contracts\Service\Attribute\Required;

trait JwtVoterTrait
{
private RequestStack $requestStack;
private JWTManager $JWTManager;

#[Required]
public function setRequestStack(RequestStack $requestStack): void
{
$this->requestStack = $requestStack;
}

#[Required]
public function setJWTManager(JWTManager $JWTManager): void
{
$this->JWTManager = $JWTManager;
}

protected function isValidJWTForRequest(): bool
{
$currentRequest = $this->requestStack->getCurrentRequest();
if (!$currentRequest instanceof Request) {
return false;
}

$token = $currentRequest->query->get('jwt');
if (!$token) {
return false;
}

try {
$this->JWTManager->validateJWT($currentRequest->getUri(), $token);
} catch (AccessDeniedHttpException) {
return false;
}

return true;
}
}
32 changes: 4 additions & 28 deletions expose/api/src/Security/Voter/PublicationVoter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,18 @@
use Alchemy\AuthBundle\Security\JwtUser;
use Alchemy\AuthBundle\Security\Voter\ScopeVoterTrait;
use App\Entity\Publication;
use App\Security\Authentication\JWTManager;
use App\Security\Authentication\PasswordToken;
use App\Security\AuthenticationSecurityMethodInterface;
use App\Security\PasswordSecurityMethodInterface;
use App\Security\ScopeInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PublicationVoter extends Voter
{
use ScopeVoterTrait;
use JwtVoterTrait;

final public const PUBLISH = 'publication:publish';
final public const CREATE = 'CREATE';
Expand All @@ -33,37 +30,16 @@ class PublicationVoter extends Voter
final public const EDIT = 'EDIT';
final public const DELETE = 'DELETE';

public function __construct(private readonly Security $security, private readonly RequestStack $requestStack, private readonly JWTManager $JWTManager)
{
public function __construct(
private readonly Security $security,
) {
}

protected function supports($attribute, $subject): bool
{
return $subject instanceof Publication;
}

private function isValidJWTForRequest(): bool
{
$currentRequest = $this->requestStack->getCurrentRequest();
if (!$currentRequest instanceof Request) {
return false;
}

$uri = $currentRequest->getUri();
$token = $currentRequest->query->get('jwt');
if (!$token) {
return false;
}

try {
$this->JWTManager->validateJWT($uri, $token);
} catch (AccessDeniedHttpException) {
return false;
}

return true;
}

/**
* @param Publication|null $subject
*/
Expand Down
18 changes: 14 additions & 4 deletions expose/api/src/Serializer/Normalizer/AssetNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,26 @@ public function normalize($object, array &$context = []): void
$object->setPosterUrl($this->generateAssetUrl($poster));
}

$isPublic = $object->getPublication()->isPublic();

if (!empty($webVTTs = $object->getWebVTT())) {
$links = [];
foreach ($webVTTs as $webVTT) {
$vttUrl = $this->urlGenerator->generate('asset_webvtt', [
'id' => $object->getId(),
'vttId' => $webVTT['id'],
], UrlGeneratorInterface::ABSOLUTE_URL);

if (!$isPublic) {
$vttUrl = $this->JWTManager->signUri($vttUrl);
}

$links[] = [
'locale' => $webVTT['locale'],
'label' => $webVTT['label'] ?? $webVTT['locale'],
'url' => $this->urlGenerator->generate('asset_webvtt', [
'id' => $object->getId(),
'vttId' => $webVTT['id'],
], UrlGeneratorInterface::ABSOLUTE_URL),
'url' => $vttUrl,
'id' => $webVTT['id'],
'kind' => $webVTT['kind'] ?? 'subtitles',
];
}
$object->setWebVTTLinks($links);
Expand Down
6 changes: 4 additions & 2 deletions expose/client/src/component/PublicationNavigation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {PureComponent} from 'react';
// import { PropTypes } from 'prop-types'
import {Link} from '@alchemy/navigation';
import {loadPublication} from './api';
import {getTranslatedTitle} from "../i18n";
import {getTranslatedTitle} from '../i18n';

class PublicationNavigation extends PureComponent {
// static propTypes = {
Expand Down Expand Up @@ -143,7 +143,9 @@ class NavTree extends PureComponent {
return (
<li key={p.id}>
{isCurrent ? (
<div className={navClass}>{getTranslatedTitle(p)}</div>
<div className={navClass}>
{getTranslatedTitle(p)}
</div>
) : (
<Link
onClick={() => onSelect(p.id)}
Expand Down
10 changes: 7 additions & 3 deletions expose/client/src/component/index/PublicationIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {getThumbPlaceholder} from '../layouts/shared-components/placeholders';
import apiClient from '../../lib/api-client';
import {useTranslation} from 'react-i18next';
import {Publication} from '../../types.ts';
import {getTranslatedDescription, getTranslatedTitle} from "../../i18n.ts";
import {getTranslatedDescription, getTranslatedTitle} from '../../i18n.ts';

enum SortBy {
Date = 'date',
Expand Down Expand Up @@ -90,7 +90,9 @@ export default function PublicationIndex({}: Props) {
alt={p.title}
/>
<div className="media-body">
<h5 className="mt-0">{getTranslatedTitle(p)}</h5>
<h5 className="mt-0">
{getTranslatedTitle(p)}
</h5>
{p.date ? (
<time>
{moment(p.date).format(
Expand All @@ -101,7 +103,9 @@ export default function PublicationIndex({}: Props) {
''
)}
<Description
descriptionHtml={getTranslatedDescription(p)}
descriptionHtml={getTranslatedDescription(
p
)}
/>
</div>
</div>
Expand Down
13 changes: 7 additions & 6 deletions expose/client/src/component/layouts/download/DownloadAsset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import {Trans} from 'react-i18next';
import {getThumbPlaceholder} from '../shared-components/placeholders';
import {Asset} from '../../../types.ts';
import React from 'react';
import {getTranslatedDescription} from "../../../i18n.ts";

import {getTranslatedDescription} from '../../../i18n.ts';

type Props = {
asset: Asset;
Expand Down Expand Up @@ -34,10 +33,12 @@ export default function DownloadAsset({
<h5 className="mt-0">
{originalName} - {mimeType}
</h5>
<Description descriptionHtml={getTranslatedDescription({
translations,
description,
})} />
<Description
descriptionHtml={getTranslatedDescription({
translations,
description,
})}
/>
<div className={'download-btns'}>
<a
onClick={e => onDownload(downloadUrl, e)}
Expand Down
6 changes: 4 additions & 2 deletions expose/client/src/component/layouts/gallery/GalleryLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import PublicationHeader from '../shared-components/PublicationHeader';
import {Trans} from 'react-i18next';
import {logAssetView} from '../../../lib/log';
import {getThumbPlaceholder} from '../shared-components/placeholders';
import {getTranslatedDescription} from "../../../i18n";
import {getTranslatedDescription} from '../../../i18n';

class GalleryLayout extends React.Component {
// static propTypes = {
Expand Down Expand Up @@ -219,7 +219,9 @@ class GalleryLayout extends React.Component {
<AssetProxy isCurrent={isCurrent} asset={asset} />
{asset.description ? (
<div className="image-gallery-description">
<Description descriptionHtml={getTranslatedDescription(asset)} />
<Description
descriptionHtml={getTranslatedDescription(asset)}
/>
</div>
) : (
''
Expand Down
2 changes: 1 addition & 1 deletion expose/client/src/component/layouts/grid/GridLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {Trans} from 'react-i18next';
import {FullPageLoader} from '@alchemy/phrasea-ui';
import {logAssetView} from '../../../lib/log';
import {getThumbPlaceholder} from '../shared-components/placeholders';
import {getTranslatedDescription, getTranslatedTitle} from "../../../i18n";
import {getTranslatedDescription, getTranslatedTitle} from '../../../i18n';

const CustomView = ({data, carouselProps, currentView}) => {
const isCurrent = currentView === data;
Expand Down
Loading

0 comments on commit 199e981

Please sign in to comment.