feat(blog): add city_id query parameter to blog detail endpoint for domain scoping
This commit is contained in:
+9
-2
@@ -90,7 +90,14 @@ Get a single blog post by slug.
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `slug` | string | URL slug (e.g. `ashnayi-ba-bimari-diabat`) |
|
||||
| `slug` | string | URL slug (e.g. `ashnayi-ba-bimari-diabat`) or UUID |
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `city_id` | int | ❌ | Domain scope of the caller. Same rule as `GET /api/v1/blogs?city_id=…`: the post is returned only when it belongs to this city **or** is nationwide (`city IS NULL`). A post owned by another city — including a post assigned to the root record `نوبت 724` (id `600`) — returns **404**. Omit on the main domain (`nobat724.com`) to read any post. |
|
||||
|
||||
> بدون این پارامتر، پستی که از لیستِ یک دامنه فیلتر شده بود همچنان با URL مستقیم روی همان دامنه ۲۰۰ میگرفت و یک محتوا روی چند دامنه تکرار میشد. سایت عمومی (`nobat724_front`) روی دامنههای شهری همیشه `city_id` را میفرستد.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -115,7 +122,7 @@ Get a single blog post by slug.
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | Blog not found or not published |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Blog not found, not published, or owned by another city (when `city_id` is sent) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -115,6 +115,13 @@ class BlogController extends BaseController
|
||||
description: 'Blog slug or UUID',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'city_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Domain scope of the caller. A post that belongs to another city is 404 here, exactly as it is absent from GET /api/v1/blogs?city_id=…. Nationwide posts (city IS NULL) are always returned. Omit on the main domain to read any post.',
|
||||
schema: new OA\Schema(type: 'integer')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
@@ -133,20 +140,36 @@ class BlogController extends BaseController
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 404, description: 'Blog post not found or not published'),
|
||||
new OA\Response(response: 404, description: 'Blog post not found, not published, or owned by another city'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/blog/{slug}', methods: ['GET'])]
|
||||
public function detail(string $slug): JsonResponse
|
||||
public function detail(string $slug, Request $request): JsonResponse
|
||||
{
|
||||
$blog = $this->blogRepo->findBySlug($slug) ?? $this->blogRepo->findByUuid($slug);
|
||||
if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
|
||||
}
|
||||
|
||||
// یک پستِ شهریافته فقط روی دامنهٔ خودش وجود دارد. بدون این شرط، پستی که از
|
||||
// لیستِ دامنه حذف شده بود همچنان با URL مستقیم روی هر دامنهای ۲۰۰ میگرفت و
|
||||
// همان محتوا روی چند دامنه تکرار میشد. پست سراسری (city IS NULL) استثناست.
|
||||
$cityId = $request->query->get('city_id');
|
||||
if ($cityId !== null && $cityId !== '' && !$this->isVisibleOnCity($blog, (int) $cityId)) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $blog->toArray()]);
|
||||
}
|
||||
|
||||
/** آینهٔ BlogRepository::applyCityFilter — پست همان شهر یا پست سراسری. */
|
||||
private function isVisibleOnCity(Blog $blog, int $cityId): bool
|
||||
{
|
||||
$owner = $blog->getCity();
|
||||
|
||||
return $owner === null || $owner->getId() === $cityId;
|
||||
}
|
||||
|
||||
// ── Admin CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -108,6 +108,57 @@ class BlogCityScopeTest extends ApiTestCase
|
||||
$this->assertNull($natDetail['city']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail endpoint must scope exactly like the list: a post owned by
|
||||
* another city does not exist on this domain. Without this, a post filtered
|
||||
* out of /api/v1/blogs?city_id=… was still served with a direct URL, so the
|
||||
* same article appeared on every city domain.
|
||||
*/
|
||||
public function testDetailIsNotFoundOnAnotherCityDomain(): void
|
||||
{
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$tabriz = $this->makeCity('تبریز');
|
||||
$post = $this->makePost('یاسوجی ' . bin2hex(random_bytes(3)), $yasuj);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('GET', '/api/v1/blog/' . $post->getSlug() . '?city_id=' . $tabriz->getId());
|
||||
$this->assertSame(404, $this->responseCode(), 'another city\'s post must not be readable');
|
||||
}
|
||||
|
||||
public function testDetailIsFoundOnItsOwnCityDomain(): void
|
||||
{
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$post = $this->makePost('یاسوجی ' . bin2hex(random_bytes(3)), $yasuj);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('GET', '/api/v1/blog/' . $post->getUuid() . '?city_id=' . $yasuj->getId());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNationwideDetailIsReadableFromEveryCityDomain(): void
|
||||
{
|
||||
$tabriz = $this->makeCity('تبریز');
|
||||
$post = $this->makePost('سراسری ' . bin2hex(random_bytes(3)), null);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('GET', '/api/v1/blog/' . $post->getSlug() . '?city_id=' . $tabriz->getId());
|
||||
$this->assertSame(200, $this->responseCode(), 'nationwide posts stay visible on city domains');
|
||||
}
|
||||
|
||||
public function testDetailWithoutCityIdStaysUnscoped(): void
|
||||
{
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$post = $this->makePost('یاسوجی ' . bin2hex(random_bytes(3)), $yasuj);
|
||||
$this->em->flush();
|
||||
|
||||
// The main domain (and any API consumer) omits city_id and sees everything.
|
||||
$this->client->request('GET', '/api/v1/blog/' . $post->getSlug());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$this->client->request('GET', '/api/v1/blog/' . $post->getSlug() . '?city_id=');
|
||||
$this->assertSame(200, $this->responseCode(), 'an empty city_id must not scope the request');
|
||||
}
|
||||
|
||||
public function testAdminCanCreatePostWithAndWithoutCity(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
Reference in New Issue
Block a user