Skip to content

Commit ea5778a

Browse files
authored
Added article preview controller (#326)
* added article preview controller * article preview controller * removed not needed const * tests * docs * docs * improvements * include spec, fixes
1 parent d291877 commit ea5778a

File tree

8 files changed

+432
-5
lines changed

8 files changed

+432
-5
lines changed

app/config/security.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ security:
55
role_hierarchy:
66
ROLE_USER: [ROLE_READER]
77
ROLE_JOURNALIST: [ROLE_USER]
8-
ROLE_EDITOR: [ROLE_JOURNALIST, ROLE_CAN_VIEW_NON_PUBLISHED]
8+
ROLE_EDITOR: [ROLE_JOURNALIST, ROLE_ARTICLE_PREVIEW]
99
ROLE_INTERNAL_API: [ROLE_EDITOR]
1010
ROLE_ADMIN: [ROLE_EDITOR]
1111
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
@@ -35,6 +35,14 @@ security:
3535
authenticators:
3636
- swp.security.token_authenticator
3737
stateless: true
38+
preview:
39+
pattern: ^/preview/article
40+
anonymous: false
41+
logout: false
42+
guard:
43+
authenticators:
44+
- swp.security.preview_token_authenticator
45+
stateless: true
3846
main:
3947
pattern: ^/
4048
form_login:
@@ -45,3 +53,6 @@ security:
4553
logout:
4654
path: security_logout
4755
anonymous: true
56+
57+
access_control:
58+
- { path: ^/preview/article, roles: ROLE_EDITOR }

docs/cookbooks/developers/article_preview.rst

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
Article Preview
22
===============
33

4-
Article preview is based on user privileges. Every article which is not published yet can be previewed by users with special privileges assigned to them. If a user has, for example, ``ROLE_EDITOR`` role, he/she can preview article using url: ``domain.com/<section>/<article-slug>``.
4+
Article preview is based on user roles. Every article which is not published yet can be previewed by users with special roles assigned to them. This role is named ``ROLE_ARTICLE_PREVIEW``.
55

6-
For example, if you created an article that has a slug ``test-article`` and is assigned to ``news`` route, it will be available for preview under ``/news/test-article`` url but only when the user has ``ROLE_CAN_VIEW_NON_PUBLISHED`` role assigned. In other cases 404 error will be thrown.
6+
If a user has ``ROLE_ARTICLE_PREVIEW`` role assigned, he/she can preview article using url: ``domain.com/preview/article/<routeId>/<article-slug>/?auth_token=<token>``.
77

8-
If you are building JavaScript app and you want to preview article, the preview url of an article can be taken from articles API (``/content/articles/``), from ``_links`` JSON object - ``online`` link and loaded in an iframe for preview.
8+
Where ``<routeId>`` is route identifier on which you want to preview given article by it's slug (``<article-slug>`` parameter).
9+
10+
Important here is to provide token, in order to be authorized to preview an article.
11+
12+
.. tip::
13+
14+
See :doc:`API Authentication </internal_api/authentication>` section for more details on how to obtain user token.
15+
16+
For example, if you created an article that has a slug ``test-article`` and this article is assigned to ``news`` route which id is 5, it will be available for preview under ``/preview/article/5/test-article?auth_token=uty56392323==`` url but only when the user has ``ROLE_ARTICLE_PREVIEW`` role assigned. In other cases 403 error will be thrown.
17+
18+
If you are building JavaScript app and you want to preview article, the preview url of an article can be taken and loaded in an iframe for preview.
919

1020
User roles eligible for article preview:
1121
----------------------------------------
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Superdesk Web Publisher Content Bundle.
7+
*
8+
* Copyright 2017 Sourcefabric z.ú. and contributors.
9+
*
10+
* For the full copyright and license information, please see the
11+
* AUTHORS and LICENSE files distributed with this source code.
12+
*
13+
* @copyright 2017 Sourcefabric z.ú
14+
* @license http://www.superdesk.org/license
15+
*/
16+
17+
namespace SWP\Bundle\ContentBundle\Controller;
18+
19+
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
20+
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
21+
use SWP\Bundle\ContentBundle\Model\ArticleInterface;
22+
use SWP\Bundle\ContentBundle\Model\RouteInterface;
23+
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
24+
25+
class ArticlePreviewController extends Controller
26+
{
27+
/**
28+
* @Route("/preview/article/{routeId}/{slug}", options={"expose"=true}, requirements={"slug"=".+", "routeId"="\d+", "token"=".+"}, name="swp_article_preview")
29+
* @Method("GET")
30+
*/
31+
public function previewAction(int $routeId, string $slug)
32+
{
33+
/** @var RouteInterface $route */
34+
$route = $this->findRouteOr404($routeId);
35+
/** @var ArticleInterface $article */
36+
$article = $this->findArticleOr404($slug);
37+
38+
$metaFactory = $this->get('swp_template_engine_context.factory.meta_factory');
39+
$templateEngineContext = $this->get('swp_template_engine_context');
40+
$templateEngineContext->setCurrentPage($metaFactory->create($route));
41+
$templateEngineContext->getMetaForValue($article);
42+
43+
if (null === $route->getArticlesTemplateName()) {
44+
throw $this->createNotFoundException(
45+
sprintf('Template for route with id "%d" (%s) not found!', $route->getId(), $route->getName())
46+
);
47+
}
48+
49+
return $this->render($route->getArticlesTemplateName());
50+
}
51+
52+
private function findRouteOr404(int $id)
53+
{
54+
if (null === ($route = $this->get('swp.repository.route')->findOneBy(['id' => $id]))) {
55+
throw $this->createNotFoundException(sprintf('Route with id: "%s" not found!', $id));
56+
}
57+
58+
return $route;
59+
}
60+
61+
private function findArticleOr404(string $slug)
62+
{
63+
if (null === ($article = $this->get('swp.repository.article')->findOneBy(['slug' => $slug]))) {
64+
throw $this->createNotFoundException(sprintf('Article with slug: "%s" not found!', $slug));
65+
}
66+
67+
return $article;
68+
}
69+
}

src/SWP/Bundle/CoreBundle/Resources/config/services.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ services:
104104
- "@swp.repository.tenant"
105105
- "@event_dispatcher"
106106

107+
swp.security.preview_token_authenticator:
108+
class: SWP\Bundle\CoreBundle\Security\Authenticator\PreviewTokenAuthenticator
109+
parent: swp.security.token_authenticator
110+
107111
swp.security.user_provider:
108112
class: SWP\Bundle\CoreBundle\Security\Provider\UserProvider
109113
arguments:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Superdesk Web Publisher Core Bundle.
7+
*
8+
* Copyright 2017 Sourcefabric z.ú. and contributors.
9+
*
10+
* For the full copyright and license information, please see the
11+
* AUTHORS and LICENSE files distributed with this source code.
12+
*
13+
* @copyright 2017 Sourcefabric z.ú
14+
* @license http://www.superdesk.org/license
15+
*/
16+
17+
namespace SWP\Bundle\CoreBundle\Security\Authenticator;
18+
19+
use Symfony\Component\HttpFoundation\Request;
20+
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
21+
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
22+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
23+
24+
class PreviewTokenAuthenticator extends TokenAuthenticator
25+
{
26+
/**
27+
* {@inheritdoc}
28+
*/
29+
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
30+
{
31+
throw new AccessDeniedHttpException(strtr($exception->getMessageKey(), $exception->getMessageData()));
32+
}
33+
34+
/**
35+
* {@inheritdoc}
36+
*/
37+
public function start(Request $request, AuthenticationException $authException = null)
38+
{
39+
throw new UnauthorizedHttpException('Authentication Required');
40+
}
41+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Superdesk Web Publisher Core Bundle.
7+
*
8+
* Copyright 2017 Sourcefabric z.ú. and contributors.
9+
*
10+
* For the full copyright and license information, please see the
11+
* AUTHORS and LICENSE files distributed with this source code.
12+
*
13+
* @copyright 2017 Sourcefabric z.ú
14+
* @license http://www.superdesk.org/license
15+
*/
16+
17+
namespace SWP\Bundle\CoreBundle\Tests\Functional;
18+
19+
use SWP\Bundle\FixturesBundle\WebTestCase;
20+
21+
final class ArticlePreviewTest extends WebTestCase
22+
{
23+
private $router;
24+
25+
public function setUp()
26+
{
27+
self::bootKernel();
28+
$this->initDatabase();
29+
$this->loadCustomFixtures(['tenant']);
30+
$this->loadFixtureFiles([
31+
'@SWPFixturesBundle/Resources/fixtures/ORM/test/article_preview.yml',
32+
], true);
33+
34+
$this->router = $this->getContainer()->get('router');
35+
}
36+
37+
public function testArticlePreview()
38+
{
39+
$route = $this->createRoute();
40+
41+
$this->ensureArticleIsNotAccessible();
42+
43+
$client = static::createClient([], ['HTTP_Authorization' => null]);
44+
$crawler = $client->request('GET', $this->router->generate(
45+
'swp_article_preview',
46+
['routeId' => $route['id'], 'slug' => 'art1-not-published', 'auth_token' => base64_encode('test_token:')]
47+
));
48+
49+
self::assertTrue($client->getResponse()->isSuccessful());
50+
self::assertGreaterThan(0, $crawler->filter('html:contains("Slug: art1-not-published")')->count());
51+
self::assertGreaterThan(0, $crawler->filter('html:contains("Current tenant: Default tenant")')->count());
52+
}
53+
54+
public function testArticlePreviewWithoutToken()
55+
{
56+
$route = $this->createRoute();
57+
$client = static::createClient([], ['HTTP_Authorization' => null]);
58+
$client->request('GET', $this->router->generate(
59+
'swp_article_preview',
60+
['routeId' => $route['id'], 'slug' => 'art1-not-published']
61+
));
62+
63+
self::assertEquals(401, $client->getResponse()->getStatusCode());
64+
}
65+
66+
public function testArticlePreviewWithFakeToken()
67+
{
68+
$route = $this->createRoute();
69+
70+
$this->ensureArticleIsNotAccessible();
71+
72+
$client = static::createClient([], ['HTTP_Authorization' => null]);
73+
$client->request('GET', $this->router->generate(
74+
'swp_article_preview',
75+
['routeId' => $route['id'], 'slug' => 'art1-not-published', 'auth_token' => 'fake']
76+
));
77+
78+
self::assertEquals(403, $client->getResponse()->getStatusCode());
79+
}
80+
81+
public function testArticlePreviewWithNotExistingArticle()
82+
{
83+
$route = $this->createRoute();
84+
85+
$this->ensureArticleIsNotAccessible();
86+
87+
$client = static::createClient([], ['HTTP_Authorization' => null]);
88+
$client->request('GET', $this->router->generate(
89+
'swp_article_preview',
90+
['routeId' => $route['id'], 'slug' => 'fake-article', 'auth_token' => base64_encode('test_token:')]
91+
));
92+
93+
self::assertFalse($client->getResponse()->isSuccessful());
94+
self::assertEquals($client->getResponse()->getStatusCode(), 404);
95+
}
96+
97+
public function testArticlePreviewWithNotExistingRoute()
98+
{
99+
$this->ensureArticleIsNotAccessible();
100+
101+
$client = static::createClient([], ['HTTP_Authorization' => null]);
102+
$client->request('GET', $this->router->generate(
103+
'swp_article_preview',
104+
['routeId' => 9999, 'slug' => 'art1-not-published', 'auth_token' => base64_encode('test_token:')]
105+
));
106+
107+
self::assertFalse($client->getResponse()->isSuccessful());
108+
self::assertEquals($client->getResponse()->getStatusCode(), 404);
109+
}
110+
111+
public function testArticlePreviewWithoutRouteTemplate()
112+
{
113+
$route = $this->createRouteWithoutTemplate();
114+
115+
$this->ensureArticleIsNotAccessible();
116+
117+
$client = static::createClient([], ['HTTP_Authorization' => null]);
118+
$client->request('GET', $this->router->generate(
119+
'swp_article_preview',
120+
['routeId' => $route['id'], 'slug' => 'art1-not-published', 'auth_token' => base64_encode('test_token:')]
121+
));
122+
123+
self::assertFalse($client->getResponse()->isSuccessful());
124+
self::assertEquals($client->getResponse()->getStatusCode(), 404);
125+
}
126+
127+
public function testTenantsFromDifferentOrganizationsCantPreviewArticlesOfEachother()
128+
{
129+
$route = $this->createRoute();
130+
$this->ensureArticleIsNotAccessible();
131+
132+
$client = static::createClient([], ['HTTP_Authorization' => null]);
133+
$crawler = $client->request('GET', $this->router->generate(
134+
'swp_article_preview',
135+
['routeId' => $route['id'], 'slug' => 'art1-not-published', 'auth_token' => base64_encode('test_token:')]
136+
));
137+
138+
self::assertTrue($client->getResponse()->isSuccessful());
139+
self::assertGreaterThan(0, $crawler->filter('html:contains("Slug: art1-not-published")')->count());
140+
self::assertGreaterThan(0, $crawler->filter('html:contains("Current tenant: Default tenant")')->count());
141+
142+
$client->request('GET', $this->router->generate(
143+
'swp_article_preview',
144+
['routeId' => $route['id'], 'slug' => 'art1-not-published', 'auth_token' => base64_encode('client1_token')]
145+
));
146+
147+
self::assertEquals(403, $client->getResponse()->getStatusCode());
148+
}
149+
150+
public function testOneTenantCanPreviewArticlesOfOtherTenantsUsingTokensWithinSameOrganization()
151+
{
152+
$route = $this->createRoute();
153+
$this->ensureArticleIsNotAccessible();
154+
155+
$client = static::createClient([], ['HTTP_Authorization' => null]);
156+
$crawler = $client->request('GET', $this->router->generate(
157+
'swp_article_preview',
158+
['routeId' => $route['id'], 'slug' => 'art1-not-published', 'auth_token' => base64_encode('test_token:')]
159+
));
160+
161+
self::assertTrue($client->getResponse()->isSuccessful());
162+
self::assertGreaterThan(0, $crawler->filter('html:contains("Slug: art1-not-published")')->count());
163+
self::assertGreaterThan(0, $crawler->filter('html:contains("Current tenant: Default tenant")')->count());
164+
165+
$client->request('GET', $this->router->generate(
166+
'swp_article_preview',
167+
['routeId' => $route['id'], 'slug' => 'art1-not-published', 'auth_token' => base64_encode('client2_token')]
168+
));
169+
170+
self::assertTrue($client->getResponse()->isSuccessful());
171+
self::assertGreaterThan(0, $crawler->filter('html:contains("Slug: art1-not-published")')->count());
172+
self::assertGreaterThan(0, $crawler->filter('html:contains("Current tenant: Default tenant")')->count());
173+
}
174+
175+
private function createRoute()
176+
{
177+
$client = static::createClient();
178+
$client->request('POST', $this->router->generate('swp_api_content_create_routes'), [
179+
'route' => [
180+
'name' => 'news',
181+
'type' => 'collection',
182+
'content' => null,
183+
'templateName' => 'news.html.twig',
184+
'articlesTemplateName' => 'article.html.twig',
185+
],
186+
]);
187+
188+
self::assertEquals(201, $client->getResponse()->getStatusCode());
189+
190+
return json_decode($client->getResponse()->getContent(), true);
191+
}
192+
193+
private function ensureArticleIsNotAccessible()
194+
{
195+
$client = static::createClient();
196+
$client->request('GET', '/news/art1-not-published');
197+
198+
self::assertFalse($client->getResponse()->isSuccessful());
199+
self::assertEquals(404, $client->getResponse()->getStatusCode());
200+
}
201+
202+
private function createRouteWithoutTemplate()
203+
{
204+
$client = static::createClient();
205+
$client->request('POST', $this->router->generate('swp_api_content_create_routes'), [
206+
'route' => [
207+
'name' => 'news',
208+
'type' => 'collection',
209+
'content' => null,
210+
'templateName' => 'news.html.twig',
211+
],
212+
]);
213+
214+
self::assertEquals(201, $client->getResponse()->getStatusCode());
215+
216+
return json_decode($client->getResponse()->getContent(), true);
217+
}
218+
}

0 commit comments

Comments
 (0)