diff --git a/docs/api/blog.md b/docs/api/blog.md index f12278cf..3535d82c 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -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) | --- diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index a8793605..75918bd0 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -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( diff --git a/tests/Blog/BlogCityScopeTest.php b/tests/Blog/BlogCityScopeTest.php index 75e83438..4a361186 100644 --- a/tests/Blog/BlogCityScopeTest.php +++ b/tests/Blog/BlogCityScopeTest.php @@ -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']);