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
|
### Path Parameters
|
||||||
| Param | Type | Description |
|
| 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`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
@@ -115,7 +122,7 @@ Get a single blog post by slug.
|
|||||||
### Errors
|
### Errors
|
||||||
| Code | HTTP | Description |
|
| 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',
|
description: 'Blog slug or UUID',
|
||||||
schema: new OA\Schema(type: 'string')
|
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: [
|
responses: [
|
||||||
new OA\Response(
|
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'])]
|
#[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);
|
$blog = $this->blogRepo->findBySlug($slug) ?? $this->blogRepo->findByUuid($slug);
|
||||||
if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) {
|
if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) {
|
||||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
|
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()]);
|
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 ────────────────────────────────────────────────────────────
|
// ── Admin CRUD ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
|
|||||||
@@ -108,6 +108,57 @@ class BlogCityScopeTest extends ApiTestCase
|
|||||||
$this->assertNull($natDetail['city']);
|
$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
|
public function testAdminCanCreatePostWithAndWithoutCity(): void
|
||||||
{
|
{
|
||||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||||
|
|||||||
Reference in New Issue
Block a user