move things around

configcache-clean-rebased
Your Name 5 days ago
parent 138a59f085
commit d54c627189

@ -15,37 +15,40 @@ use Illuminate\Support\Str;
class ConfigCache extends Controller
{
// GET config — bulk read; ?keys[] filters, unknown keys reject with 422.
// GET config — bulk read; requires an explicit ?keys[] filter. Unknown keys
// reject with 422. There is no fetch-everything default.
public function index(Request $request): JsonResponse
{
$this->authorizeAdmin($request, 'admin:read');
$requested = $request->input('keys');
if ($requested !== null) {
if (! is_array($requested)) {
return response()->json([
'message' => 'The keys parameter must be an array.',
], 422);
}
if ($requested === null) {
return response()->json([
'message' => 'The keys parameter is required and must be a non-empty array.',
], 422);
}
$unknown = array_values(array_filter(
$requested,
fn ($key) => ! is_string($key) || ! ConfigCacheService::isCached($key)
));
if (! is_array($requested) || empty($requested)) {
return response()->json([
'message' => 'The keys parameter is required and must be a non-empty array.',
], 422);
}
if (! empty($unknown)) {
return response()->json([
'message' => 'One or more requested keys are unknown or uncached.',
'unknown_keys' => $unknown,
], 422);
}
$unknown = array_values(array_filter(
$requested,
fn ($key) => ! is_string($key) || ! ConfigCacheService::isCached($key)
));
$keys = array_values(array_unique($requested));
} else {
$keys = ConfigCacheService::adminVisibleKeys();
if (! empty($unknown)) {
return response()->json([
'message' => 'One or more requested keys are unknown or uncached.',
'unknown_keys' => $unknown,
], 422);
}
$keys = array_values(array_unique($requested));
return response()->json([
'data' => array_map(fn ($key) => $this->itemFor($key), $keys),
]);
@ -87,7 +90,10 @@ class ConfigCache extends Controller
]);
}
// POST config — bulk write, all-or-nothing. Payload: { config: { key: value } }.
// POST config — bulk write, partial success. Payload: { config: { key: value } }.
// Valid keys are persisted even when others fail; per-key failures are
// reported in `errors`. Returns 422 only when the payload is malformed or
// no submitted key was writable.
public function store(Request $request): JsonResponse
{
$this->authorizeAdmin($request, 'admin:write');
@ -103,7 +109,7 @@ class ConfigCache extends Controller
$errors = [];
$permitted = $this->collectWritable($config, $errors);
if (! empty($errors)) {
if (empty($permitted) && ! empty($errors)) {
return response()->json([
'message' => 'The submitted configuration is invalid.',
'errors' => $errors,
@ -112,6 +118,7 @@ class ConfigCache extends Controller
return response()->json([
'changed' => array_values($this->persist($permitted)),
'errors' => empty($errors) ? (object) [] : $errors,
]);
}

@ -13,10 +13,20 @@ class ConfigCacheService
{
const CACHE_KEY = 'config_cache:_v0-key:';
// Keys whose value is a secret and must be encrypted at rest and masked
// when read back through the admin API / debug page.
const PROTECTED_KEYS = [
'filesystems.disks.s3.secret',
'filesystems.disks.spaces.secret',
'captcha.hcaptcha.secret',
'captcha.turnstile.secret',
'captcha.cap.secret',
];
const KEYS = [
// filesystems.php — s3 disk
'filesystems.disks.s3.key' => ['list' => 'ENVCONFIG', 'env' => 'AWS_ACCESS_KEY_ID', 'rule' => 'string', 'secret' => true],
'filesystems.disks.s3.secret' => ['list' => 'ENVCONFIG', 'env' => 'AWS_SECRET_ACCESS_KEY', 'rule' => 'string', 'secret' => true],
'filesystems.disks.s3.key' => ['list' => 'ENVCONFIG', 'env' => 'AWS_ACCESS_KEY_ID', 'rule' => 'string'],
'filesystems.disks.s3.secret' => ['list' => 'ENVCONFIG', 'env' => 'AWS_SECRET_ACCESS_KEY', 'rule' => 'string'],
'filesystems.disks.s3.region' => ['list' => 'ENVCONFIG', 'env' => 'AWS_DEFAULT_REGION', 'rule' => 'string'],
'filesystems.disks.s3.bucket' => ['list' => 'ENVCONFIG', 'env' => 'AWS_BUCKET', 'rule' => 'string'],
'filesystems.disks.s3.visibility' => ['list' => 'ENVCONFIG', 'env' => 'AWS_VISIBILITY', 'rule' => 'in:public,private'],
@ -25,8 +35,8 @@ class ConfigCacheService
'filesystems.disks.s3.use_path_style_endpoint' => ['list' => 'ENVCONFIG', 'env' => 'AWS_USE_PATH_STYLE_ENDPOINT', 'rule' => 'boolean'],
// filesystems.php — spaces disk
'filesystems.disks.spaces.key' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_KEY', 'rule' => 'string', 'secret' => true],
'filesystems.disks.spaces.secret' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_SECRET', 'rule' => 'string', 'secret' => true],
'filesystems.disks.spaces.key' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_KEY', 'rule' => 'string'],
'filesystems.disks.spaces.secret' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_SECRET', 'rule' => 'string'],
'filesystems.disks.spaces.region' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_REGION', 'rule' => 'string'],
'filesystems.disks.spaces.bucket' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_BUCKET', 'rule' => 'string'],
'filesystems.disks.spaces.url' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_URL', 'rule' => 'url'],
@ -95,13 +105,13 @@ class ConfigCacheService
// captcha.php
'captcha.enabled' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED', 'rule' => 'boolean'],
'captcha.driver' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_DRIVER', 'rule' => 'in:hcaptcha,turnstile,cap'],
'captcha.hcaptcha.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_H_SECRET', 'rule' => 'string', 'secret' => true],
'captcha.hcaptcha.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_H_SECRET', 'rule' => 'string'],
'captcha.hcaptcha.sitekey' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_H_SITEKEY', 'rule' => 'string'],
'captcha.turnstile.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_TURNSTILE_SECRET', 'rule' => 'string', 'secret' => true],
'captcha.turnstile.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_TURNSTILE_SECRET', 'rule' => 'string'],
'captcha.turnstile.sitekey' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_TURNSTILE_SITEKEY', 'rule' => 'string'],
'captcha.cap.endpoint' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_ENDPOINT', 'rule' => 'url'],
'captcha.cap.sitekey' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_SITEKEY', 'rule' => 'string'],
'captcha.cap.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_SECRET', 'rule' => 'string', 'secret' => true],
'captcha.cap.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_SECRET', 'rule' => 'string'],
'captcha.active.login' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_LOGIN', 'rule' => 'boolean'],
'captcha.active.register' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_REGISTER', 'rule' => 'boolean'],
'captcha.active.forgot_password' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_FORGOT_PASSWORD', 'rule' => 'boolean'],
@ -147,7 +157,7 @@ class ConfigCacheService
public static function isProtected(string $key): bool
{
return (self::KEYS[$key]['secret'] ?? false) === true;
return in_array($key, self::PROTECTED_KEYS, true);
}
public static function ruleFor(string $key): ?string

@ -66,6 +66,9 @@ return Application::configure(basePath: dirname(__DIR__))
Route::middleware('api')
->group(base_path('routes/api.php'));
Route::middleware('api')
->group(base_path('routes/v2026.php'));
},
channels: __DIR__.'/../routes/channels.php',
commands: __DIR__.'/../routes/console.php',

@ -8,7 +8,6 @@ use App\Http\Controllers\Api\ApiV2Controller;
use App\Http\Controllers\Api\V1\Admin\DomainBlocksController;
use App\Http\Controllers\Api\V1\DomainBlockController;
use App\Http\Controllers\Api\V1\TagsController;
use App\Http\Controllers\Api\v2026\Admin\ConfigCache as AdminConfigCacheController;
use App\Http\Controllers\ApiController;
use App\Http\Controllers\AppRegisterController;
use App\Http\Controllers\CollectionController;
@ -128,17 +127,6 @@ Route::prefix('api/v0/groups')->middleware($middleware)->group(function () {
Route::get('{id}', [GroupController::class, 'getGroup']);
});
Route::prefix('api')->group(function () use ($middleware) {
Route::prefix('v2026')->group(function () use ($middleware) {
Route::prefix('admin')->group(function () use ($middleware) {
Route::get('config', [AdminConfigCacheController::class, 'index'])->middleware($middleware);
Route::post('config', [AdminConfigCacheController::class, 'store'])->middleware($middleware);
Route::get('config/{key}', [AdminConfigCacheController::class, 'show'])->where('key', '.*')->middleware($middleware);
Route::post('config/{key}', [AdminConfigCacheController::class, 'update'])->where('key', '.*')->middleware($middleware);
});
});
});
Route::prefix('api')->group(function () use ($middleware) {
Route::prefix('v1')->group(function () use ($middleware) {

@ -0,0 +1,17 @@
<?php
use App\Http\Controllers\Api\v2026\Admin\ConfigCache as AdminConfigCacheController;
use Illuminate\Support\Facades\Route;
$middleware = ['auth:sanctum,api'];
Route::prefix('api')->group(function () use ($middleware) {
Route::prefix('v2026')->group(function () use ($middleware) {
Route::prefix('admin')->group(function () use ($middleware) {
Route::get('config', [AdminConfigCacheController::class, 'index'])->middleware($middleware);
Route::post('config', [AdminConfigCacheController::class, 'store'])->middleware($middleware);
Route::get('config/{key}', [AdminConfigCacheController::class, 'show'])->where('key', '.*')->middleware($middleware);
Route::post('config/{key}', [AdminConfigCacheController::class, 'update'])->where('key', '.*')->middleware($middleware);
});
});
});

@ -169,18 +169,19 @@ test('GET single for an unknown/uncached key returns 404, not a silent fallback
|--------------------------------------------------------------------------
*/
test('GET bulk with no filter returns every admin-visible key (5.3)', function () {
test('GET bulk with no filter is rejected 422: keys are required (5.3)', function () {
apiAdmin(['admin:read']);
$expectedCount = count(ConfigCacheService::adminVisibleKeys());
$this->getJson('/api/v2026/admin/config')
->assertStatus(422)
->assertJsonStructure(['message']);
});
$response = $this->getJson('/api/v2026/admin/config')
->assertOk()
->assertJsonCount($expectedCount, 'data');
test('GET bulk with an empty keys array is rejected 422 (5.3b)', function () {
apiAdmin(['admin:read']);
$keys = collect($response->json('data'))->pluck('key');
expect($keys)->toContain(API_ADMINONLY_KEY);
expect($keys)->toContain(API_ENVBOUND_KEY);
$this->getJson('/api/v2026/admin/config?keys[]=')
->assertStatus(422);
});
test('GET bulk with a ?keys[]= filter returns exactly the requested keys (5.4)', function () {
@ -344,7 +345,7 @@ test('POST single with a value failing its rule is rejected 422 (6.1)', function
|--------------------------------------------------------------------------
*/
test('POST bulk is all-or-nothing: one invalid entry rejects the whole batch, nothing persists (6.2)', function () {
test('POST bulk is partial success: valid entries persist, invalid entries are reported (6.2)', function () {
apiAdmin(['admin:write']);
Config::set(API_ADMINONLY_KEY, '/* default css */');
apiForget(API_ADMINONLY_KEY);
@ -358,12 +359,30 @@ test('POST bulk is all-or-nothing: one invalid entry rejects the whole batch, no
API_ADMINONLY_KEY => '.valid { color: green; }', // valid
API_ENVBOUND_KEY => 'eu-west-9', // invalid: env-locked
],
])
->assertOk()
->assertJsonStructure(['changed', 'errors' => [API_ENVBOUND_KEY]]);
// The VALID entry IS written; the invalid one is not.
expect(ConfigCacheModel::where('k', API_ADMINONLY_KEY)->exists())->toBeTrue();
expect(ConfigCacheModel::where('k', API_ENVBOUND_KEY)->exists())->toBeFalse();
});
test('POST bulk with every entry invalid rejects 422 and persists nothing (6.2b)', function () {
apiAdmin(['admin:write']);
apiSetProcessEnv(API_ENVBOUND_VAR, 'us-east-1');
Config::set(API_ENVBOUND_KEY, 'us-east-1');
$this->postJson('/api/v2026/admin/config', [
'config' => [
API_ENVBOUND_KEY => 'eu-west-9', // invalid: env-locked
'this.key.is.not.cached' => 'x', // invalid: unknown
],
])
->assertStatus(422)
->assertJsonStructure(['errors' => [API_ENVBOUND_KEY]]);
->assertJsonStructure(['errors' => [API_ENVBOUND_KEY, 'this.key.is.not.cached']]);
// The VALID entry must NOT have been written (all-or-nothing).
expect(ConfigCacheModel::where('k', API_ADMINONLY_KEY)->exists())->toBeFalse();
expect(ConfigCacheModel::where('k', API_ENVBOUND_KEY)->exists())->toBeFalse();
});

Loading…
Cancel
Save