Skip to content

Commit 1bf1f44

Browse files
authored
release(v3.24.0): require authentication for portal details
1 parent f2c2bc0 commit 1bf1f44

3 files changed

Lines changed: 214 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Changelog
22

3+
## Changes 07/26/2026 (v3.24.0)
4+
5+
`release(v3.24.0): require authentication for portal details`
6+
7+
**Fixed**
8+
9+
- The FileRise Pro portal-detail endpoint now requires an authenticated session before returning internal folder, client-contact, form, or upload-policy metadata.
10+
- The deliberately public portal metadata endpoint remains available for anonymous portal-login branding and continues returning only its curated public fields.
11+
- Existing administrators and dedicated non-admin portal users retain access after signing in.
12+
13+
**Upgrade notes**
14+
15+
- No configuration, portal, account, Docker volume, or stored-data migration is required.
16+
- Integrations that directly requested `/api/pro/portals/get.php` without an authenticated FileRise session must use `/api/pro/portals/publicMeta.php` for public branding fields or authenticate before requesting full portal details.
17+
18+
---
19+
320
## Changes 07/25/2026 (v3.23.1)
421

522
`release(v3.23.1): correct PHP coding-standard violations`

public/api/pro/portals/get.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
* @OA\Get(
66
* path="/api/pro/portals/get.php",
77
* summary="Get portal by slug",
8-
* description="Returns portal metadata (public).",
8+
* description="Returns portal metadata for an authenticated portal user.",
99
* operationId="proPortalsGet",
1010
* tags={"Pro"},
11+
* security={{"cookieAuth": {}}},
1112
* @OA\Parameter(name="slug", in="query", required=true, @OA\Schema(type="string"), example="client-portal"),
1213
* @OA\Response(response=200, description="Portal payload"),
14+
* @OA\Response(response=401, description="Unauthorized"),
15+
* @OA\Response(response=405, description="Method not allowed"),
1316
* @OA\Response(response=404, description="Portal not found")
1417
* )
1518
*/
@@ -20,6 +23,9 @@
2023
require_once PROJECT_ROOT . '/src/FileRise/Domain/ProPortalsApiService.php';
2124

2225
try {
26+
fr_pro_guard_method('GET');
27+
fr_pro_guard_auth(false, false);
28+
2329
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
2430
fr_pro_emit_result(\FileRise\Domain\ProPortalsApiService::getPortal($slug));
2531
} catch (Throwable $e) {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
$baseDir = dirname(__DIR__, 2);
6+
7+
if (getenv('FR_PORTAL_GET_AUTH_CHILD') === '1') {
8+
$sessionDir = (string)getenv('FR_PORTAL_GET_AUTH_SESSION_DIR');
9+
if ($sessionDir !== '') {
10+
session_save_path($sessionDir);
11+
}
12+
13+
if (getenv('FR_PORTAL_GET_AUTH_MODE') === 'authenticated') {
14+
session_start();
15+
$_SESSION['authenticated'] = true;
16+
$_SESSION['username'] = 'portal_user';
17+
$_SESSION['role'] = '0';
18+
$_SESSION['isAdmin'] = false;
19+
session_write_close();
20+
}
21+
22+
$_SERVER['REQUEST_METHOD'] = 'GET';
23+
$_SERVER['HTTP_HOST'] = 'localhost';
24+
$_GET = ['slug' => 'client-portal'];
25+
26+
register_shutdown_function(static function (): void {
27+
echo "\n__STATUS__=" . http_response_code();
28+
});
29+
30+
require $baseDir . '/public/api/pro/portals/get.php';
31+
exit(0);
32+
}
33+
34+
$tmpBase = $baseDir . '/tests/.tmp_portal_get_auth_' . bin2hex(random_bytes(4));
35+
$usersDir = $tmpBase . '/users/';
36+
$uploadDir = $tmpBase . '/uploads/';
37+
$metaDir = $tmpBase . '/metadata/';
38+
$sessionDir = $tmpBase . '/sessions/';
39+
$proDir = $usersDir . 'pro/';
40+
41+
function portalGetAuthRmTree(string $dir): void
42+
{
43+
if (!file_exists($dir) && !is_link($dir)) {
44+
return;
45+
}
46+
if (is_link($dir) || is_file($dir)) {
47+
@unlink($dir);
48+
return;
49+
}
50+
$items = scandir($dir);
51+
if ($items === false) {
52+
return;
53+
}
54+
foreach ($items as $item) {
55+
if ($item === '.' || $item === '..') {
56+
continue;
57+
}
58+
portalGetAuthRmTree($dir . DIRECTORY_SEPARATOR . $item);
59+
}
60+
@rmdir($dir);
61+
}
62+
63+
/**
64+
* @return array{status:int,payload:array<string,mixed>,error?:string}
65+
*/
66+
function portalGetAuthRunCase(
67+
string $mode,
68+
string $usersDir,
69+
string $uploadDir,
70+
string $metaDir,
71+
string $sessionDir
72+
): array {
73+
$command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__FILE__);
74+
$env = array_merge($_ENV, [
75+
'FR_PORTAL_GET_AUTH_CHILD' => '1',
76+
'FR_PORTAL_GET_AUTH_MODE' => $mode,
77+
'FR_PORTAL_GET_AUTH_SESSION_DIR' => $sessionDir,
78+
'FR_TEST_USERS_DIR' => $usersDir,
79+
'FR_TEST_UPLOAD_DIR' => $uploadDir,
80+
'FR_TEST_META_DIR' => $metaDir,
81+
'PERSISTENT_TOKENS_KEY' => 'test_persistent_tokens_key_32bytes!',
82+
]);
83+
$process = proc_open(
84+
$command,
85+
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
86+
$pipes,
87+
dirname(__DIR__, 2),
88+
$env
89+
);
90+
if (!is_resource($process)) {
91+
return ['status' => 0, 'payload' => [], 'error' => 'failed to start child process'];
92+
}
93+
94+
$stdout = (string)stream_get_contents($pipes[1]);
95+
$stderr = (string)stream_get_contents($pipes[2]);
96+
fclose($pipes[1]);
97+
fclose($pipes[2]);
98+
$exitCode = proc_close($process);
99+
100+
if (!preg_match('/\n__STATUS__=(\d+)\s*$/', $stdout, $match)) {
101+
return [
102+
'status' => 0,
103+
'payload' => [],
104+
'error' => 'missing status code: ' . trim($stdout . ' ' . $stderr),
105+
];
106+
}
107+
108+
$json = substr($stdout, 0, (int)strrpos($stdout, "\n__STATUS__="));
109+
$payload = json_decode(trim($json), true);
110+
if ($exitCode !== 0 || !is_array($payload)) {
111+
return [
112+
'status' => (int)$match[1],
113+
'payload' => [],
114+
'error' => 'invalid child response: ' . trim($stdout . ' ' . $stderr),
115+
];
116+
}
117+
118+
return ['status' => (int)$match[1], 'payload' => $payload];
119+
}
120+
121+
$errors = [];
122+
123+
try {
124+
foreach ([$usersDir, $uploadDir, $metaDir, $sessionDir, $proDir] as $dir) {
125+
@mkdir($dir, 0775, true);
126+
}
127+
128+
file_put_contents($usersDir . 'users.txt', 'portal_user:unused:0' . PHP_EOL, LOCK_EX);
129+
file_put_contents($proDir . 'bootstrap_pro.php', "<?php\ndefine('FR_PRO_ACTIVE', true);\n", LOCK_EX);
130+
file_put_contents(
131+
$proDir . 'ProPortals.php',
132+
<<<'PHP'
133+
<?php
134+
final class ProPortals
135+
{
136+
public function __construct(string $baseDir)
137+
{
138+
}
139+
140+
public function listPortals(): array
141+
{
142+
return [
143+
'client-portal' => [
144+
'label' => 'Client Portal',
145+
'folder' => 'confidential/client-acme-contracts',
146+
'clientEmail' => 'legal-contact@client-acme.example',
147+
'uploadOnly' => true,
148+
'allowDownload' => false,
149+
'uploadMaxSizeMb' => 25,
150+
'uploadExtWhitelist' => 'pdf,docx',
151+
'uploadMaxPerDay' => 10,
152+
],
153+
];
154+
}
155+
}
156+
PHP,
157+
LOCK_EX
158+
);
159+
160+
$anonymous = portalGetAuthRunCase('anonymous', $usersDir, $uploadDir, $metaDir, $sessionDir);
161+
if (($anonymous['status'] ?? 0) !== 401) {
162+
$errors[] = 'anonymous request should return HTTP 401';
163+
}
164+
if (($anonymous['payload']['error'] ?? '') !== 'Unauthorized') {
165+
$errors[] = 'anonymous request should return Unauthorized JSON';
166+
}
167+
if (str_contains(json_encode($anonymous['payload']), 'client-acme-contracts')) {
168+
$errors[] = 'anonymous response disclosed the internal portal folder';
169+
}
170+
if (str_contains(json_encode($anonymous['payload']), 'legal-contact@client-acme.example')) {
171+
$errors[] = 'anonymous response disclosed the client email';
172+
}
173+
174+
$authenticated = portalGetAuthRunCase('authenticated', $usersDir, $uploadDir, $metaDir, $sessionDir);
175+
if (($authenticated['status'] ?? 0) !== 200 || empty($authenticated['payload']['success'])) {
176+
$errors[] = 'authenticated non-admin portal user should retain access';
177+
}
178+
if (($authenticated['payload']['portal']['folder'] ?? '') !== 'confidential/client-acme-contracts') {
179+
$errors[] = 'authenticated portal response should retain the configured folder';
180+
}
181+
} finally {
182+
portalGetAuthRmTree($tmpBase);
183+
}
184+
185+
if ($errors) {
186+
fwrite(STDERR, "Portal get auth regression failures:\n- " . implode("\n- ", $errors) . "\n");
187+
exit(1);
188+
}
189+
190+
echo "PASS portal get auth regressions\n";

0 commit comments

Comments
 (0)