feat(blog): add city_id query parameter to blog detail endpoint for domain scoping

This commit is contained in:
hamed
2026-07-24 10:01:30 +03:30
parent e766407bd1
commit c729bb13e0
3 changed files with 85 additions and 4 deletions
+9 -2
View File
@@ -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) |
---
+25 -2
View File
@@ -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(
+51
View File
@@ -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']);