From 57538258132b5a68f6c017e981df0722c529ccaf Mon Sep 17 00:00:00 2001 From: blaipr Date: Tue, 18 Aug 2026 22:37:59 +0200 Subject: [PATCH] fix: answer a created public link with the hash that is its URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `account/viewLink/{hash}` is the URL a public link hands out, and the service mints the hash — so an API caller creating a link got back only what they had sent, with `hash: null`, and could not hand out what they had just made. The way round it was to fetch the link again with `publicLink/view`, which answers with `data` as well — the sealed vault — so working around the omission gave out more than reporting the hash does. It costs nothing: the controller already reads the stored link back for the expiry and the view limit it had been misreporting, so this is one more field off an object already in hand. `Account\CreateController` and `AuthToken\CreateController` both read their record back for the same reason. `data` stays out. Returning the whole stored model would have been the shorter change and would have handed the sealed payload to a token scoped only to PUBLICLINK_CREATE, so the test asserts `data` is null alongside asserting the hash is present, and the response cannot quietly widen later. --- .../PublicLink/CreateController.php | 6 ++++ .../Controllers/PublicLinkControllerTest.php | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Infrastructure/Adapter/In/Api/Controllers/PublicLink/CreateController.php b/src/Infrastructure/Adapter/In/Api/Controllers/PublicLink/CreateController.php index 8c1f236cf..e4a0cfe4a 100644 --- a/src/Infrastructure/Adapter/In/Api/Controllers/PublicLink/CreateController.php +++ b/src/Infrastructure/Adapter/In/Api/Controllers/PublicLink/CreateController.php @@ -39,6 +39,12 @@ public function createAction(): ApiResponse $linkData = $linkData->mutate( [ 'id' => $id, + // The hash is the link. `account/viewLink/{hash}` is the URL that gets handed out, + // and the service mints it, so a caller who had only what they sent could not + // hand out what they had just created — they had to fetch the link back to find + // out. That fetch answers with `data` as well, the sealed vault, so the way round + // the omission gave out more than this does. + 'hash' => $stored->getHash(), 'dateExpire' => $stored->getDateExpire(), 'maxCountViews' => $stored->getMaxCountViews(), ] diff --git a/tests/Integration/Infrastructure/Adapter/In/Api/Controllers/PublicLinkControllerTest.php b/tests/Integration/Infrastructure/Adapter/In/Api/Controllers/PublicLinkControllerTest.php index 7e97964d5..8dda09060 100644 --- a/tests/Integration/Infrastructure/Adapter/In/Api/Controllers/PublicLinkControllerTest.php +++ b/tests/Integration/Infrastructure/Adapter/In/Api/Controllers/PublicLinkControllerTest.php @@ -105,6 +105,36 @@ public function testTheExpiryAndViewLimitComeFromTheConfigurationNotTheCaller(): ); } + /** + * Creating a link answers with the link. + * + * `account/viewLink/{hash}` is the URL that gets handed out, and the service mints the hash, + * so a caller who had only what they sent back could not hand out what they had just made. + * The way round it was to fetch the link again — which answers with `data`, the sealed vault, + * so working around the omission gave out more than reporting the hash does. + * + * Both halves are asserted: the hash is there and is the one stored, and `data` is still not. + */ + public function testCreatingALinkAnswersWithTheHashThatIsTheUrl(): void + { + $r = $this->createLink(); + + $this->assertSame(201, $r->status); + $this->assertNotEmpty($r->body->data->hash, 'without the hash the caller cannot build the URL'); + + $statement = getDbHandler()->getConnection() + ->prepare('SELECT `hash` FROM `PublicLink` WHERE `id` = :id'); + $statement->execute(['id' => $r->body->itemId]); + + $this->assertSame( + $statement->fetchColumn(), + $r->body->data->hash, + 'the hash reported must be the one the link is stored under' + ); + + $this->assertNull($r->body->data->data, 'creating a link must not hand back the sealed vault'); + } + /** * The expiry and the view limit as the row holds them. *