generate json schemas

Signed-off-by: cinereal <cinereal@riseup.net>
pull/6555/head
cinereal 4 months ago
parent 421babacae
commit 956e93b899
No known key found for this signature in database

@ -33,6 +33,16 @@ jobs:
cp .env.testing .env
php artisan key:generate
- name: Check config schema is up to date
run: |
php artisan config:schema --pretty --output=/tmp/schema-check.json
if ! diff -q pixelfed-config.schema.json /tmp/schema-check.json > /dev/null 2>&1; then
echo "::error::pixelfed-config.schema.json is out of date."
echo "Run 'php artisan config:schema --pretty --output=pixelfed-config.schema.json' and commit the result."
diff pixelfed-config.schema.json /tmp/schema-check.json
exit 1
fi
- name: Run tests
run: php artisan test
continue-on-error: false

@ -0,0 +1,305 @@
# Configuration Schema Generation
Pixelfed ships an Artisan command that emits a machine-readable
[JSON Schema (Draft 2020-12)](https://json-schema.org/specification) document
covering every configurable option in the application.
## Running the command
```bash
php artisan config:schema
```
By default the schema is written to stdout. Useful flags:
| Flag | Description |
|---|---|
| `--pretty` | Pretty-print the JSON output |
| `--output=<path>` | Write to a file instead of stdout |
| `--filter=all` | Include every option (default) |
| `--filter=cached` | Only options editable at runtime via the admin panel |
| `--filter=env` | Only options settable via environment variables |
### Examples
```bash
# Pretty-print to terminal
php artisan config:schema --pretty
# Write to a file
php artisan config:schema --pretty --output=pixelfed-config.schema.json
# Only the env-var-backed options
php artisan config:schema --filter=env --output=pixelfed-env-options.schema.json
# Only runtime-editable options
php artisan config:schema --filter=cached --output=pixelfed-admin-options.schema.json
```
## Pre-generated schema
`pixelfed-config.schema.json` in the repository root is committed and kept in
sync by CI. Regenerate it after any change to the sources described below:
```bash
php artisan config:schema --pretty --output=pixelfed-config.schema.json
```
CI will fail if the committed file is stale, catching drift before merge.
---
## How the schema is generated
The command merges three sources automatically. **No manual list of options
is maintained in the command itself.**
### Source 1 — Config file scanning (env var, default, type)
`GenerateConfigSchema` spawns a clean PHP subprocess (without the Laravel
bootstrap) that evaluates each file listed in `CONFIG_FILES` against stubbed
versions of `env()`, `storage_path()`, `config()`, and similar helpers.
The `env()` stub returns a sentinel object rather than a real value. After
evaluation the resulting array is walked recursively to build:
```
dot.notation.key → { env_var, default_value, type_cast }
```
Type casts such as `(int) env(...)` are detected by rewriting the source
before evaluation (`(int) env(` → `env_cast('int',`), preserving cast
information through the eval.
Running out-of-process is necessary because `env()` is already defined by
Laravel in the parent process and PHP does not permit function redefinition.
### Source 2 — `ConfigCacheService` (cached + sensitive flags)
`app/Services/ConfigCacheService.php` is parsed statically with a regex to
extract:
- **`$allowed`** — dot-notation keys editable at runtime via the admin panel
`x-config-cache: true`
- **`PROTECTED_KEYS`** — keys whose values are encrypted at rest
`x-sensitive: true`
### Source 3 — `config/schema-meta.php` (human-authored annotations)
`config/schema-meta.php` is a plain PHP `return [...]` file — the same format
as the other files in `config/` — that supplies the annotations that cannot be
derived from code. Because it is ordinary PHP it supports `//` comments and
requires no additional dependencies.
```php
'pixelfed.max_photo_size' => [
'description' => 'Maximum allowed size for a single photo upload, in kilobytes.',
'group' => 'media',
'minimum' => 1,
],
```
Supported fields:
| Field | Purpose |
|---|---|
| `description` | Human-readable explanation of the option |
| `group` | `x-group` category; overrides auto-inference when needed |
| `type` | JSON Schema type; only needed for DB-only keys (see [Known limitations](#known-limitations)) |
| `enum` | Array of allowed values |
| `minimum` / `maximum` | Numeric bounds |
| `format` | JSON Schema format hint, e.g. `"uri"` or `"email"` |
An entry in `schema-meta.php` is **optional**. New options that appear in
`config/*.php` or `ConfigCacheService::$allowed` will appear in the schema
with correct type, default, env var, and inferred group even without one.
Adding an entry enriches the output with a description and constraints.
---
## Schema structure
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Pixelfed Instance Configuration",
"type": "object",
"properties": {
"pixelfed.image_quality": {
"description": "JPEG/WebP compression quality applied when optimizing images (1100).",
"type": "integer",
"default": 80,
"minimum": 1,
"maximum": 100,
"x-env": "IMAGE_QUALITY",
"x-config-cache": true,
"x-group": "media"
}
}
}
```
### Standard JSON Schema keywords
| Keyword | Purpose |
|---|---|
| `type` | JSON type (`boolean`, `integer`, `string`, `number`, `null`, or an array) |
| `default` | Value used when the option is not set |
| `description` | Human-readable description |
| `enum` | Allowed values |
| `minimum` / `maximum` | Numeric bounds |
| `format` | Format hint, e.g. `uri` or `email` |
### Extension keywords (`x-*`)
| Keyword | Type | Meaning |
|---|---|---|
| `x-env` | `string` | Environment variable name. Absent for DB-only options (no `env()` call). |
| `x-config-cache` | `boolean` | `true` = in `ConfigCacheService::$allowed`; editable at runtime without redeployment. |
| `x-group` | `string` | UI grouping category — see [Groups](#groups). |
| `x-sensitive` | `boolean` | `true` = secret value. Must not be logged, displayed in plaintext, or committed to version control. |
### Groups
| Group | Contents |
|---|---|
| `application` | App URL, environment, debug mode |
| `branding` | Instance name, descriptions, banner, custom CSS/JS |
| `registration` | Open/curated/closed registration, email verification |
| `media` | Upload size limits, image quality, optimization, HLS/P2P |
| `content` | Caption, bio, name, password, alt-text length limits |
| `accounts` | Storage quotas, user limits, autofollow, block/mute limits |
| `api` | OAuth, app registration, rate limits |
| `features` | Stories, Instagram import, Bouncer, embeds, legal notice |
| `discovery` | Landing page directory/explore, public timelines |
| `federation` | ActivityPub, authorized fetch, account migration, custom emoji |
| `captcha` | hCaptcha on login/register, trigger thresholds |
| `storage` | Cloud storage toggle, S3 / DigitalOcean Spaces credentials |
| `notifications` | Contact form, report emails |
| `general` | Options not matched by any of the above |
---
## Keeping the schema up to date
### Adding a new configurable option
1. Add `env('MY_VAR', $default)` to the appropriate `config/*.php` file.
2. If runtime-editable, add the dot-notation key to `ConfigCacheService::$allowed`.
3. If sensitive, add it to `ConfigCacheService::PROTECTED_KEYS`.
4. Optionally add an entry to `config/schema-meta.php` with a `description`
and any constraint keywords.
5. Regenerate the committed schema:
```bash
php artisan config:schema --pretty --output=pixelfed-config.schema.json
```
### Adding a new config file
Append the filename (without `.php`) to `CONFIG_FILES` in
`app/Console/Commands/GenerateConfigSchema.php`:
```php
private const CONFIG_FILES = [
'app', 'pixelfed', 'federation', 'instance',
'captcha', 'media', 'filesystems', 'autospam',
'my_new_file', // ← add here
];
```
Then regenerate the committed schema. If you forget, CI will catch it.
### Editing `config/schema-meta.php`
The file is a plain PHP array. Use `//` comments freely to annotate entries
or group them. The file must remain a pure data return (no side effects) since
it is loaded with `require`. PHP syntax errors will cause `config:schema` to
fail, and the CI check will surface this immediately.
---
## Known limitations
The following edge cases in config file patterns are handled imperfectly.
They are rare in practice; this section documents the workarounds.
### 1. New config files must be registered manually
The extractor only scans files listed in `CONFIG_FILES`. If a new `config/`
file is added to the project, its options will not appear in the schema until
the filename is appended to that constant.
**Workaround:** Add the filename to `CONFIG_FILES` and regenerate.
### 2. DB-only keys default to type `"string"`
Keys that appear in `ConfigCacheService::$allowed` but have no `env()` call
in any scanned config file (e.g. `account.autofollow`, `uikit.show_custom.css`)
have no default value to infer a type from, so the generator falls back to
`"string"`.
**Workaround:** Add a `type` entry in `config/schema-meta.php`:
```php
'account.autofollow' => [
'description' => '...',
'type' => 'boolean',
],
```
Existing DB-only keys already have type overrides where needed.
### 3. Commented-out keys in `ConfigCacheService::$allowed` are included
The static regex that extracts `$allowed` matches any quoted string in the
array body, including entries inside `// comments`. Currently
`// 'system.user_mode'` is the only such entry. This causes a harmless extra
property in the schema with no description or env var.
**Workaround:** Either remove the comment or add a suppression entry in
`schema-meta.php`. No action is required for it to be low-impact.
### 4. `env()` inside string concatenation loses the env var
```php
// config/filesystems.php
'url' => env('APP_URL') . '/storage',
```
The sentinel's `__toString()` returns `''`, so the result is the string
`'/storage'` — a literal, not a sentinel. The key will not appear as
env-backed (`x-env` will be absent).
**Workaround:** Add an entry in `schema-meta.php` if the key needs to appear
in the schema. This pattern is uncommon in Pixelfed's config files and the
affected keys are not in `ConfigCacheService::$allowed`.
### 5. Nested `env()` calls produce a `null` default
```php
// config/instance.php
'captcha_enabled' => env('INSTANCE_CUR_REG_CAPTCHA', env('CAPTCHA_ENABLED', false)),
```
The inner `env()` returns a sentinel, which becomes the `default` argument of
the outer call. The generator detects this and records `null` for the default
rather than the inner env var's value.
**Impact:** The outer env var (`INSTANCE_CUR_REG_CAPTCHA`) is correctly
recorded; only the fallback default is lost. The type is inferred as
`["string", "null"]` instead of `"boolean"`. Add a `type` override in
`schema-meta.php` if precision matters.
### 6. Ternary conditions on `env()` pick only one branch
```php
// config/instance.php
'cached' => env('PF_NETWORK_TIMELINE') ? env('INSTANCE_NETWORK_TIMELINE_CACHED', false) : false,
```
The sentinel is truthy, so the extractor always follows the `true` branch and
records `INSTANCE_NETWORK_TIMELINE_CACHED`. The outer env var
`PF_NETWORK_TIMELINE` is not recorded for this key.
**Impact:** The resulting schema entry is accurate for the common case (the
feature is enabled). The outer guard variable is only visible via `.env.example`.

@ -0,0 +1,471 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class GenerateConfigSchema extends Command
{
protected $signature = 'config:schema
{--output= : Write schema to file instead of stdout}
{--pretty : Pretty-print JSON output}
{--filter=all : Which options to include: all, cached, env}';
protected $description = 'Generate a JSON Schema document describing all configurable options';
/**
* Config files (relative to config_path()) whose env() calls we scan.
* Add a filename here when a new config file with relevant env() calls is introduced.
*/
private const CONFIG_FILES = [
'app',
'pixelfed',
'federation',
'instance',
'captcha',
'media',
'filesystems',
'autospam',
];
public function handle(): int
{
// 1. Extract {dot-key => [env, default, cast]} from config files via subprocess
$envMappings = $this->extractEnvMappings();
// 2. Read ConfigCacheService source for the allowed + protected key lists
$cachedKeys = $this->loadCachedKeys();
$sensitiveKeys = $this->loadSensitiveKeys();
// 3. Human-authored overlay: descriptions, groups, constraints
$meta = $this->loadMetaOverlay();
// 4. Union of all keys we care about
$allKeys = array_values(array_unique(array_merge(
array_keys($envMappings),
$cachedKeys,
)));
sort($allKeys);
// 5. Apply filter
$filter = $this->option('filter');
if ($filter === 'cached') {
$allKeys = array_values(array_filter($allKeys, fn ($k) => in_array($k, $cachedKeys)));
} elseif ($filter === 'env') {
$allKeys = array_values(array_filter($allKeys, fn ($k) => isset($envMappings[$k])));
}
// 6. Build schema properties
$properties = [];
foreach ($allKeys as $key) {
$mapping = $envMappings[$key] ?? null;
$overlay = $meta[$key] ?? [];
$prop = [
'description' => $overlay['description'] ?? '',
'type' => $overlay['type'] ?? $this->inferType($mapping),
];
// default — prefer extracted value; overlay can override via 'type' only
$default = $mapping['default'] ?? null;
if ($default !== null) {
$prop['default'] = $default;
}
// Extra JSON Schema constraint keywords from overlay
foreach (['enum', 'minimum', 'maximum', 'format'] as $kw) {
if (isset($overlay[$kw])) {
$prop[$kw] = $overlay[$kw];
}
}
// Extension annotations
if ($mapping && isset($mapping['env'])) {
$prop['x-env'] = $mapping['env'];
}
$prop['x-config-cache'] = in_array($key, $cachedKeys);
$prop['x-group'] = $overlay['group'] ?? $this->inferGroup($key);
if (in_array($key, $sensitiveKeys)) {
$prop['x-sensitive'] = true;
}
$properties[$key] = $prop;
}
$schema = [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'title' => 'Pixelfed Instance Configuration',
'description' =>
'JSON Schema for all configurable Pixelfed options. '
.'x-env: environment variable name. '
.'x-config-cache: true when editable at runtime via the admin panel. '
.'x-sensitive: true when the value is a secret and must not be logged or displayed.',
'type' => 'object',
'properties' => $properties,
];
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
if ($this->option('pretty')) {
$flags |= JSON_PRETTY_PRINT;
}
$json = json_encode($schema, $flags);
$output = $this->option('output');
if ($output) {
file_put_contents($output, $json.PHP_EOL);
$this->info("Schema written to {$output}");
} else {
$this->line($json);
}
return self::SUCCESS;
}
// ── Source data loaders ───────────────────────────────────────────────────
/**
* Spawn a clean PHP subprocess (no Laravel bootstrap) that stubs env() and
* evaluates each config file, then returns a JSON map of
* { "dot.key" => { "env": "VAR", "default": value, "cast": "int"|null } }.
*
* Running out-of-process is necessary because env() is already defined by
* Laravel in the parent process and cannot be redefined.
*/
private function extractEnvMappings(): array
{
$script = $this->buildExtractorScript();
$tmpFile = tempnam(sys_get_temp_dir(), 'pf_schema_extract_');
file_put_contents($tmpFile, $script);
$php = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php';
$cmd = escapeshellarg($php)
.' '.escapeshellarg($tmpFile)
.' '.escapeshellarg(config_path())
.' '.escapeshellarg(json_encode(self::CONFIG_FILES));
exec($cmd, $lines, $rc);
unlink($tmpFile);
if ($rc !== 0) {
$this->warn('Config extractor subprocess failed; env mappings may be incomplete.');
return [];
}
return json_decode(implode('', $lines), true) ?? [];
}
/**
* Parse ConfigCacheService::get() source to extract the $allowed array.
* This is intentionally a simple regex rather than a full PHP parse — the
* format of the array is stable and any false-positives would just add extra
* entries that get filtered out downstream.
*/
private function loadCachedKeys(): array
{
$source = file_get_contents(app_path('Services/ConfigCacheService.php'));
if (! preg_match('/\$allowed\s*=\s*\[(.*?)\];/s', $source, $m)) {
$this->warn('Could not locate $allowed array in ConfigCacheService.');
return [];
}
preg_match_all("/'([a-z][a-z0-9._\-]+)'/", $m[1], $keys);
return $keys[1];
}
/**
* Parse ConfigCacheService::PROTECTED_KEYS to find sensitive keys.
*/
private function loadSensitiveKeys(): array
{
$source = file_get_contents(app_path('Services/ConfigCacheService.php'));
if (! preg_match('/PROTECTED_KEYS\s*=\s*\[(.*?)\];/s', $source, $m)) {
return [];
}
preg_match_all("/'([^']+)'/", $m[1], $keys);
return $keys[1];
}
/**
* Load config/schema-meta.php — human-authored overlay that supplies
* descriptions, x-group, and JSON Schema constraint keywords (minimum,
* maximum, enum, format) that cannot be derived automatically.
*
* The file is a plain PHP array (same format as the other config/ files),
* which means it supports // comments and requires no extra dependencies.
*/
private function loadMetaOverlay(): array
{
$path = config_path('schema-meta.php');
if (! file_exists($path)) {
return [];
}
return require $path;
}
// ── Inference helpers ─────────────────────────────────────────────────────
private function inferType(?array $mapping): string|array
{
if (! $mapping) {
return 'string';
}
// Explicit PHP cast beats default-value inference
$cast = $mapping['cast'] ?? null;
if ($cast === 'int') return 'integer';
if ($cast === 'bool') return 'boolean';
if ($cast === 'float') return 'number';
if ($cast === 'string') return 'string';
$default = $mapping['default'];
if (is_bool($default)) return 'boolean';
if (is_int($default)) return 'integer';
if (is_float($default)) return 'number';
if ($default === null) return ['string', 'null'];
return 'string';
}
/**
* Infer an x-group from the dot-notation key when schema-meta.json has no
* explicit group for the key. Rules are checked longest-prefix first so
* that more-specific prefixes win.
*/
private function inferGroup(string $key): string
{
static $rules = null;
if ($rules === null) {
// Sorted longest-first so specific prefixes beat short ones
$raw = [
'instance.curated_registration.' => 'registration',
'instance.user_filters.' => 'accounts',
'instance.landing.' => 'discovery',
'instance.discover.' => 'discovery',
'instance.timeline.' => 'discovery',
'instance.hide_nsfw_on_public_feeds' => 'discovery',
'instance.stories.' => 'features',
'instance.contact.' => 'notifications',
'instance.reports.' => 'notifications',
'instance.notifications.' => 'notifications',
'instance.embed.' => 'features',
'instance.avatar.' => 'features',
'instance.restrict.' => 'features',
'instance.label.' => 'features',
'instance.parental_controls.' => 'features',
'instance.custom_filters.' => 'features',
'instance.oauth.' => 'api',
'instance.admin.' => 'branding',
'instance.banner.' => 'branding',
'instance.page.' => 'branding',
'instance.enable_cc' => 'application',
'instance.force_https_urls' => 'application',
'instance.total_count_estimate' => 'application',
'instance.software-update.' => 'application',
'instance.admin_invites.' => 'features',
'instance.allow_new' => 'features',
'instance.description' => 'branding',
'instance.username.' => 'accounts',
'pixelfed.directory.' => 'application',
'pixelfed.directory' => 'application',
'system.' => 'application',
'pixelfed.open_registration' => 'registration',
'pixelfed.enforce_email' => 'registration',
'pixelfed.max_photo' => 'media',
'pixelfed.max_album' => 'media',
'pixelfed.image_quality' => 'media',
'pixelfed.media_type' => 'media',
'pixelfed.optimize_' => 'media',
'pixelfed.max_avatar' => 'media',
'pixelfed.max_altext' => 'media',
'pixelfed.max_collection' => 'media',
'pixelfed.media_fast' => 'media',
'pixelfed.max_caption' => 'content',
'pixelfed.max_bio' => 'content',
'pixelfed.max_name' => 'content',
'pixelfed.min_password' => 'content',
'pixelfed.max_account' => 'accounts',
'pixelfed.enforce_account' => 'accounts',
'pixelfed.max_users' => 'accounts',
'pixelfed.enforce_max' => 'accounts',
'pixelfed.account_delet' => 'accounts',
'pixelfed.user_invites.' => 'accounts',
'pixelfed.cloud_storage' => 'storage',
'pixelfed.import.' => 'features',
'pixelfed.bouncer.' => 'features',
'pixelfed.oauth' => 'api',
'pixelfed.allow_app' => 'api',
'pixelfed.app_registration' => 'api',
'pixelfed.domain.' => 'application',
'federation.' => 'federation',
'filesystems.' => 'storage',
'captcha.' => 'captcha',
'autospam.' => 'features',
'media.' => 'media',
'account.' => 'accounts',
'uikit.' => 'branding',
'about.' => 'branding',
'app.name' => 'branding',
'app.short_description' => 'branding',
'app.description' => 'branding',
'app.banner_image' => 'branding',
'app.rules' => 'branding',
'app.' => 'application',
'instance.' => 'general',
];
// Sort by key length descending so longest (most specific) prefix wins
uksort($raw, fn ($a, $b) => strlen($b) - strlen($a));
$rules = $raw;
}
foreach ($rules as $prefix => $group) {
if ($key === $prefix || str_starts_with($key, $prefix)) {
return $group;
}
}
return 'general';
}
// ── Subprocess extractor script ───────────────────────────────────────────
/**
* Returns the source of a standalone PHP script that:
* - stubs all Laravel helpers (env, storage_path, config, app, …)
* - preprocesses type-cast patterns like (int) env(…) → env_cast('int', …)
* - evals each target config file
* - walks the resulting array to collect {dot-key → {env, default, cast}}
* - writes JSON to stdout
*
* argv[1] = config directory path
* argv[2] = JSON-encoded array of config file prefixes (without .php)
*/
private function buildExtractorScript(): string
{
// Single-quoted heredoc: no PHP interpolation, so the embedded $variables
// belong to the subprocess script, not to this method.
return <<<'EXTRACTOR'
<?php
error_reporting(0);
$configDir = $argv[1] ?? __DIR__;
$targetFiles = json_decode($argv[2] ?? '[]', true);
// ── Sentinel ──────────────────────────────────────────────────────────────────
// Represents an env() call in a config file. Truthy (so ternaries on env()
// behave as if the variable is set), and has a safe __toString() so it
// survives string-concatenation contexts without crashing.
class EnvSentinel
{
public string $envKey;
public mixed $default;
public ?string $cast;
public function __construct(string $k, mixed $d = null, ?string $c = null)
{
$this->envKey = $k;
$this->default = $d instanceof self ? null : $d; // nested env() → null default
$this->cast = $c;
}
public function __toString(): string { return ''; }
}
// ── Stubs ─────────────────────────────────────────────────────────────────────
function env(string $key, mixed $default = null): EnvSentinel
{
return new EnvSentinel($key, $default);
}
function env_cast(string $cast, string $key, mixed $default = null): EnvSentinel
{
return new EnvSentinel($key, $default, $cast);
}
function storage_path(string $path = ''): string { return '/tmp/'.$path; }
function database_path(string $path = ''): string { return '/tmp/'.$path; }
function resource_path(string $path = ''): string { return '/tmp/'.$path; }
function public_path(string $path = ''): string { return '/tmp/'.$path; }
function base_path(string $path = ''): string { return '/tmp/'.$path; }
function config(mixed $key = null, mixed $default = null): mixed { return $default; }
function app(mixed $abstract = null): object
{
return new class {
public function getLocale(): string { return 'en'; }
public function __get(string $n): mixed { return null; }
public function __call(string $n, array $a): mixed { return null; }
};
}
// ── Preprocessor ──────────────────────────────────────────────────────────────
// Rewrites (int) env( → env_cast('int', (and similarly for other casts)
// so type information is preserved through the eval without crashing on object casts.
function preprocessSource(string $source): string
{
$casts = ['int', 'bool', 'float', 'string'];
foreach ($casts as $cast) {
$source = preg_replace(
'/\(\s*'.$cast.'\s*\)\s*env\s*\(/',
"env_cast('".$cast."', ",
$source
);
}
// Strip opening PHP tag so the source is eval()-able
$source = preg_replace('/^\s*<\?php\s*/i', '', $source);
return $source;
}
// ── Array walker ──────────────────────────────────────────────────────────────
function walkConfig(array $arr, string $prefix, array &$results): void
{
foreach ($arr as $key => $value) {
$dotKey = $prefix.'.'.$key;
if ($value instanceof EnvSentinel) {
$results[$dotKey] = [
'env' => $value->envKey,
'default' => $value->default,
'cast' => $value->cast,
];
} elseif (is_array($value)) {
walkConfig($value, $dotKey, $results);
}
// Literal non-env values: ignored (not configurable via env)
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
$results = [];
foreach ($targetFiles as $prefix) {
$file = $configDir.'/'.$prefix.'.php';
if (! file_exists($file)) {
continue;
}
$source = preprocessSource(file_get_contents($file));
// eval() returns the value of the last expression — config files end with
// `return [...]` so this gives us the config array directly.
$config = eval($source);
if (! is_array($config)) {
continue;
}
walkConfig($config, $prefix, $results);
}
echo json_encode($results);
EXTRACTOR;
}
}

@ -0,0 +1,541 @@
<?php
/**
* Schema metadata overlay for config:schema.
*
* Keys are Laravel dot-notation config keys. Each entry may contain any of:
*
* description string Human-readable explanation of the option.
* group string x-group category for UI generation. Usually
* auto-inferred from the key prefix; only set this
* when the inference is wrong.
* type string|array JSON Schema type override. Only needed for
* DB-only cached keys (no env() call) whose type
* cannot be inferred from a default value and would
* otherwise fall back to "string".
* enum array Allowed values.
* minimum int|float Lower bound for numeric values.
* maximum int|float Upper bound for numeric values.
* format string JSON Schema format hint, e.g. "uri" or "email".
*
* Structural data (env var name, default value, x-config-cache, x-sensitive)
* is derived automatically from the config PHP files and ConfigCacheService.
* You do not need an entry here for a new option to appear in the schema.
*
* See CONFIGURATION_SCHEMA.md for the full workflow.
*/
return [
// ── Application ───────────────────────────────────────────────────────────
'app.name' => [
'description' => 'The name of your Pixelfed instance.',
'group' => 'branding',
],
'app.short_description' => [
'description' => 'A short one-line description of the instance shown on the landing page.',
'group' => 'branding',
],
'app.description' => [
'description' => 'A longer description of the instance.',
'group' => 'branding',
],
'app.url' => [
'description' => 'The canonical HTTPS URL of this instance (no trailing slash).',
'group' => 'application',
'format' => 'uri',
],
'app.env' => [
'description' => 'Application environment.',
'group' => 'application',
'enum' => ['production', 'local', 'testing'],
],
'app.debug' => [
'description' => 'Enable detailed error pages. Must be false in production.',
'group' => 'application',
],
'app.banner_image' => [
'description' => 'Path to the instance banner image shown on the landing page.',
'group' => 'branding',
],
'app.rules' => [
'description' => 'JSON-encoded array of instance rules shown during registration.',
'group' => 'branding',
],
// ── Registration ──────────────────────────────────────────────────────────
'pixelfed.open_registration' => [
'description' => 'Allow new users to register an account.',
'group' => 'registration',
],
'pixelfed.enforce_email_verification' => [
'description' => 'Require new users to verify their email address before using the site.',
'group' => 'registration',
],
'instance.curated_registration.enabled' => [
'description' => 'Enable curated (filtered) registration: users apply and admins approve.',
'group' => 'registration',
],
'instance.curated_registration.resend_confirmation_limit' => [
'description' => 'Maximum times an applicant may resend their confirmation email.',
'group' => 'registration',
'minimum' => 1,
],
'instance.curated_registration.captcha_enabled' => [
'description' => 'Require captcha during curated registration flow.',
'group' => 'registration',
],
// ── Media ─────────────────────────────────────────────────────────────────
'pixelfed.max_photo_size' => [
'description' => 'Maximum allowed size for a single photo upload, in kilobytes.',
'group' => 'media',
'minimum' => 1,
],
'pixelfed.max_album_length' => [
'description' => 'Maximum number of photos allowed in a single album post.',
'group' => 'media',
'minimum' => 1,
'maximum' => 100,
],
'pixelfed.image_quality' => [
'description' => 'JPEG/WebP compression quality applied when optimizing images (1100).',
'group' => 'media',
'minimum' => 1,
'maximum' => 100,
],
'pixelfed.media_types' => [
'description' => 'Comma-separated list of allowed upload MIME types.',
'group' => 'media',
],
'pixelfed.optimize_image' => [
'description' => 'Resize and compress image uploads on ingest.',
'group' => 'media',
],
'pixelfed.optimize_video' => [
'description' => 'Transcode and optimize video uploads on ingest.',
'group' => 'media',
],
'pixelfed.max_avatar_size' => [
'description' => 'Maximum allowed size for a profile avatar upload, in kilobytes.',
'group' => 'media',
'minimum' => 1,
],
'pixelfed.max_altext_length' => [
'description' => 'Maximum character length for image alt text.',
'group' => 'media',
'minimum' => 1,
],
'pixelfed.max_collection_length' => [
'description' => 'Maximum number of posts in a user collection.',
'group' => 'media',
'minimum' => 1,
],
'pixelfed.memory_limit' => [
'description' => 'PHP memory_limit used exclusively during image processing jobs.',
'group' => 'media',
],
'media.delete_local_after_cloud' => [
'description' => 'Delete local copies of media files after successfully uploading them to cloud storage.',
'group' => 'media',
],
'media.hls.enabled' => [
'description' => 'Enable HLS streaming support for videos (requires FFmpeg).',
'group' => 'media',
],
'media.hls.p2p' => [
'description' => 'Enable WebTorrent P2P video delivery (requires HLS).',
'group' => 'media',
],
// ── Content limits ────────────────────────────────────────────────────────
'pixelfed.max_caption_length' => [
'description' => 'Maximum character length for post captions.',
'group' => 'content',
'minimum' => 1,
],
'pixelfed.max_bio_length' => [
'description' => 'Maximum character length for user profile bios.',
'group' => 'content',
'minimum' => 1,
],
'pixelfed.max_name_length' => [
'description' => 'Maximum character length for display names.',
'group' => 'content',
'minimum' => 1,
],
'pixelfed.min_password_length' => [
'description' => 'Minimum character length for user passwords.',
'group' => 'content',
'minimum' => 6,
],
// ── Accounts ──────────────────────────────────────────────────────────────
'pixelfed.max_account_size' => [
'description' => 'Per-user storage quota in kilobytes.',
'group' => 'accounts',
'minimum' => 1,
],
'pixelfed.enforce_account_limit' => [
'description' => 'Enforce the per-user storage quota.',
'group' => 'accounts',
],
'pixelfed.max_users' => [
'description' => 'Maximum number of local user accounts allowed on this instance.',
'group' => 'accounts',
'minimum' => 1,
],
'pixelfed.enforce_max_users' => [
'description' => 'Stop accepting registrations once the user limit is reached.',
'group' => 'accounts',
],
'account.autofollow' => [
// DB-only: no env() call; type cannot be inferred from a default, so override it.
'description' => 'Automatically follow a set of accounts when a new user registers.',
'group' => 'accounts',
'type' => 'boolean',
],
'account.autofollow_usernames' => [
'description' => 'Comma-separated list of local usernames that new users will automatically follow.',
'group' => 'accounts',
],
'instance.user_filters.max_user_blocks' => [
'description' => 'Maximum number of accounts a user can block.',
'group' => 'accounts',
'minimum' => 0,
],
'instance.user_filters.max_user_mutes' => [
'description' => 'Maximum number of accounts a user can mute.',
'group' => 'accounts',
'minimum' => 0,
],
'instance.user_filters.max_domain_blocks' => [
'description' => 'Maximum number of domains a user can block.',
'group' => 'accounts',
'minimum' => 0,
],
// ── API / OAuth ───────────────────────────────────────────────────────────
'pixelfed.oauth_enabled' => [
'description' => 'Enable OAuth2 / Mastodon-compatible mobile APIs.',
'group' => 'api',
],
'pixelfed.allow_app_registration' => [
'description' => 'Allow third-party apps to register OAuth clients via the API.',
'group' => 'api',
],
'pixelfed.app_registration_rate_limit_attempts' => [
'description' => 'Maximum app-registration attempts before rate-limiting kicks in.',
'group' => 'api',
'minimum' => 1,
],
'pixelfed.app_registration_rate_limit_decay' => [
'description' => 'Rate-limit decay window for app registrations, in seconds.',
'group' => 'api',
'minimum' => 1,
],
'pixelfed.app_registration_confirm_rate_limit_attempts' => [
'description' => 'Maximum confirmation attempts before rate-limiting kicks in.',
'group' => 'api',
'minimum' => 1,
],
'pixelfed.app_registration_confirm_rate_limit_decay' => [
'description' => 'Rate-limit decay window for app registration confirmations, in seconds.',
'group' => 'api',
'minimum' => 1,
],
'instance.oauth.token_expiration' => [
'description' => 'OAuth access token lifetime in days.',
'group' => 'api',
'minimum' => 1,
],
'instance.oauth.refresh_expiration' => [
'description' => 'OAuth refresh token lifetime in days.',
'group' => 'api',
'minimum' => 1,
],
// ── Features ──────────────────────────────────────────────────────────────
'instance.stories.enabled' => [
'description' => 'Enable ephemeral Stories feature.',
'group' => 'features',
],
'pixelfed.import.instagram.enabled' => [
'description' => 'Allow users to import their Instagram archive.',
'group' => 'features',
],
'pixelfed.bouncer.enabled' => [
'description' => 'Enable the Bouncer spam/abuse detection system.',
'group' => 'features',
],
'autospam.nlp.enabled' => [
// DB-only: hardcoded false in config/autospam.php, no env() call.
'description' => 'Enable NLP-based autospam detection.',
'group' => 'features',
'type' => 'boolean',
],
'instance.embed.profile' => [
'description' => 'Allow profile pages to be embedded in external sites.',
'group' => 'features',
],
'instance.embed.post' => [
'description' => 'Allow individual posts to be embedded in external sites.',
'group' => 'features',
],
'instance.has_legal_notice' => [
'description' => 'Display a legal notice link in the footer.',
'group' => 'features',
],
'instance.avatar.local_to_cloud' => [
'description' => 'Store local user avatars on cloud/S3 storage.',
'group' => 'features',
],
'instance.restricted.enabled' => [
'description' => 'Run as a restricted (private) instance where content is not publicly visible.',
'group' => 'features',
],
// ── Discovery ─────────────────────────────────────────────────────────────
'instance.landing.show_directory' => [
'description' => 'Show the user directory section on the landing page.',
'group' => 'discovery',
],
'instance.landing.show_explore' => [
'description' => 'Show the explore/trending section on the landing page.',
'group' => 'discovery',
],
'instance.discover.public' => [
'description' => 'Make the Discover page publicly accessible to logged-out visitors.',
'group' => 'discovery',
],
'instance.timeline.local.is_public' => [
'description' => 'Make the local timeline publicly accessible to logged-out visitors.',
'group' => 'discovery',
],
'instance.show_peers' => [
'description' => 'Expose the list of federated peer instances via the API.',
'group' => 'discovery',
],
// ── Branding ──────────────────────────────────────────────────────────────
'instance.admin.pid' => [
// type override: DB-only integer that can also be null (unset).
'description' => 'Profile ID of the primary admin account shown on the landing page.',
'group' => 'branding',
'type' => ['integer', 'null'],
],
'instance.banner.blurhash' => [
'description' => 'BlurHash placeholder string for the instance banner image.',
'group' => 'branding',
],
'uikit.custom.css' => [
'description' => 'Custom CSS injected into every page.',
'group' => 'branding',
],
'uikit.custom.js' => [
'description' => 'Custom JavaScript injected into every page.',
'group' => 'branding',
],
'uikit.show_custom.css' => [
// DB-only boolean; no env() call so type cannot be inferred.
'description' => 'Enable injection of the custom CSS snippet.',
'group' => 'branding',
'type' => 'boolean',
],
'uikit.show_custom.js' => [
// DB-only boolean; no env() call so type cannot be inferred.
'description' => 'Enable injection of the custom JavaScript snippet.',
'group' => 'branding',
'type' => 'boolean',
],
// ── Federation ────────────────────────────────────────────────────────────
'federation.activitypub.enabled' => [
'description' => 'Enable ActivityPub federation with other Fediverse instances.',
'group' => 'federation',
],
'federation.activitypub.authorized_fetch' => [
'description' => 'Require HTTP Signatures on all incoming ActivityPub fetch requests (ghost mode).',
'group' => 'federation',
],
'federation.migration' => [
'description' => 'Allow users to migrate their account to/from other ActivityPub instances.',
'group' => 'federation',
],
'federation.custom_emoji.enabled' => [
'description' => 'Enable support for custom emoji from remote instances.',
'group' => 'federation',
],
'federation.network_timeline' => [
'description' => 'Enable the federated network timeline.',
'group' => 'federation',
],
'federation.activitypub.remoteFollow' => [
'description' => 'Enable the remote-follow / interact dialog for federated users.',
'group' => 'federation',
],
'federation.activitypub.delivery.timeout' => [
'description' => 'HTTP timeout in seconds for ActivityPub delivery requests.',
'group' => 'federation',
'minimum' => 1,
],
'federation.activitypub.delivery.concurrency' => [
'description' => 'Number of concurrent ActivityPub delivery workers.',
'group' => 'federation',
'minimum' => 1,
],
// ── Captcha ───────────────────────────────────────────────────────────────
'captcha.enabled' => [
'description' => 'Enable hCaptcha globally.',
'group' => 'captcha',
],
'captcha.sitekey' => [
'description' => 'hCaptcha site key (public, used in the browser widget).',
'group' => 'captcha',
],
'captcha.secret' => [
'description' => 'hCaptcha secret key (private, used for server-side verification).',
'group' => 'captcha',
],
'captcha.active.login' => [
'description' => 'Show captcha on the login form.',
'group' => 'captcha',
],
'captcha.active.register' => [
'description' => 'Show captcha on the registration form.',
'group' => 'captcha',
],
'captcha.triggers.login.enabled' => [
'description' => 'Automatically show captcha on login after N failed attempts.',
'group' => 'captcha',
],
'captcha.triggers.login.attempts' => [
'description' => 'Number of failed login attempts that trigger the captcha.',
'group' => 'captcha',
'minimum' => 1,
],
// ── Storage ───────────────────────────────────────────────────────────────
'pixelfed.cloud_storage' => [
'description' => 'Store uploaded media on cloud/S3-compatible object storage instead of locally.',
'group' => 'storage',
],
'filesystems.cloud' => [
'description' => 'Which cloud filesystem driver to use when cloud storage is enabled.',
'group' => 'storage',
'enum' => ['s3', 'spaces'],
],
'filesystems.disks.s3.key' => [
'description' => 'AWS / S3-compatible access key ID.',
'group' => 'storage',
],
'filesystems.disks.s3.secret' => [
'description' => 'AWS / S3-compatible secret access key.',
'group' => 'storage',
],
'filesystems.disks.s3.region' => [
'description' => 'AWS region (e.g. us-east-1).',
'group' => 'storage',
],
'filesystems.disks.s3.bucket' => [
'description' => 'S3 bucket name.',
'group' => 'storage',
],
'filesystems.disks.s3.visibility' => [
'description' => 'Default object visibility in S3.',
'group' => 'storage',
'enum' => ['public', 'private'],
],
'filesystems.disks.s3.url' => [
'description' => 'Custom public URL prefix for S3 objects (CDN or path rewrite).',
'group' => 'storage',
],
'filesystems.disks.s3.endpoint' => [
'description' => 'Custom S3-compatible API endpoint (e.g. for MinIO or Backblaze B2).',
'group' => 'storage',
],
'filesystems.disks.s3.use_path_style_endpoint' => [
'description' => 'Use path-style S3 URLs instead of subdomain-style (required for MinIO).',
'group' => 'storage',
],
'filesystems.disks.spaces.key' => [
'description' => 'DigitalOcean Spaces access key.',
'group' => 'storage',
],
'filesystems.disks.spaces.secret' => [
'description' => 'DigitalOcean Spaces secret key.',
'group' => 'storage',
],
'filesystems.disks.spaces.region' => [
'description' => 'DigitalOcean Spaces region.',
'group' => 'storage',
],
'filesystems.disks.spaces.bucket' => [
'description' => 'DigitalOcean Spaces bucket name.',
'group' => 'storage',
],
'filesystems.disks.spaces.endpoint' => [
'description' => 'DigitalOcean Spaces endpoint URL.',
'group' => 'storage',
],
'filesystems.disks.spaces.url' => [
'description' => 'Public CDN URL prefix for DigitalOcean Spaces objects.',
'group' => 'storage',
],
'filesystems.disks.spaces.visibility' => [
// DB-only: hardcoded 'public' in config/filesystems.php, no env() call.
'description' => 'Default object visibility for DigitalOcean Spaces.',
'group' => 'storage',
'type' => 'string',
'enum' => ['public', 'private'],
],
'filesystems.disks.spaces.use_path_style_endpoint' => [
// DB-only: not present in config/filesystems.php; registered in ConfigCacheService.
'description' => 'Use path-style URLs for DigitalOcean Spaces.',
'group' => 'storage',
'type' => 'boolean',
],
// ── Notifications ─────────────────────────────────────────────────────────
'instance.contact.enabled' => [
'description' => 'Enable the public contact form.',
'group' => 'notifications',
],
'instance.email' => [
'description' => 'Public contact email address shown in the instance metadata.',
'group' => 'notifications',
'format' => 'email',
],
'instance.reports.email.enabled' => [
'description' => 'Send email notifications to admins when a new report is filed.',
'group' => 'notifications',
],
'instance.reports.email.to' => [
'description' => 'Comma-separated email addresses that receive report notifications.',
'group' => 'notifications',
],
// ── Internal / stats ──────────────────────────────────────────────────────
'instance.stats.total_local_posts' => [
// DB-only integer maintained by the application; not user-configurable.
'description' => 'Cached count of total local posts (managed internally).',
'group' => 'application',
'type' => 'integer',
],
];

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save