You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/app/Services/ConfigCacheService.php

385 lines
19 KiB
PHP

<?php
namespace App\Services;
use App\Models\ConfigCache as ConfigCacheModel;
use App\Services\Config\EnvConfigValidator;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ConfigCacheService
{
const CACHE_KEY = 'config_cache:_v0-key:';
// Cache key holding the last-reconciled change-hash of governed env values.
const MARKER_KEY = 'config-cache:sync-hash';
// Lock key preventing concurrent reconciles.
const LOCK_KEY = 'config-cache:sync';
// Lock TTL in seconds.
const LOCK_TTL = 15;
// 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',
];
// the rules are type checks - string, url, boolean, enum "in", integer, json
const KEYS = [
// filesystems.php — s3 disk
'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'],
'filesystems.disks.s3.url' => ['list' => 'ENVCONFIG', 'env' => 'AWS_URL', 'rule' => 'url'],
'filesystems.disks.s3.endpoint' => ['list' => 'ENVCONFIG', 'env' => 'AWS_ENDPOINT', 'rule' => 'url'],
'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'],
'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'],
'filesystems.disks.spaces.endpoint' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_ENDPOINT', 'rule' => 'url'],
'filesystems.disks.spaces.visibility' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_VISIBILITY', 'rule' => 'in:public,private'],
'filesystems.disks.spaces.use_path_style_endpoint' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_USE_PATH_STYLE_ENDPOINT', 'rule' => 'boolean'],
// app.php
'app.name' => ['list' => 'ENVCONFIG', 'env' => 'APP_NAME', 'rule' => 'string'],
'app.short_description' => ['list' => 'ENVCONFIG', 'env' => 'PF_SHORT_DESCRIPTION', 'rule' => 'string'],
'app.description' => ['list' => 'ENVCONFIG', 'env' => 'PF_DESCRIPTION', 'rule' => 'string'],
'app.rules' => ['list' => 'ENVCONFIG', 'env' => 'PF_RULES', 'rule' => 'json'],
// pixelfed.php
'pixelfed.max_photo_size' => ['list' => 'ENVCONFIG', 'env' => 'MAX_PHOTO_SIZE', 'rule' => 'integer|min:100'],
'pixelfed.max_album_length' => ['list' => 'ENVCONFIG', 'env' => 'MAX_ALBUM_LENGTH', 'rule' => 'integer|min:1|max:20'],
'pixelfed.image_quality' => ['list' => 'ENVCONFIG', 'env' => 'IMAGE_QUALITY', 'rule' => 'integer|min:1|max:100'],
'pixelfed.media_types' => ['list' => 'ENVCONFIG', 'env' => 'MEDIA_TYPES', 'rule' => 'string'],
'pixelfed.open_registration' => ['list' => 'ENVCONFIG', 'env' => 'OPEN_REGISTRATION', 'rule' => 'boolean'],
'pixelfed.oauth_enabled' => ['list' => 'ENVCONFIG', 'env' => 'OAUTH_ENABLED', 'rule' => 'boolean'],
'pixelfed.import.instagram.enabled' => ['list' => 'ENVCONFIG', 'env' => 'IMPORT_INSTAGRAM', 'rule' => 'boolean'],
'pixelfed.bouncer.enabled' => ['list' => 'ENVCONFIG', 'env' => 'PF_BOUNCER_ENABLED', 'rule' => 'boolean'],
'pixelfed.enforce_email_verification' => ['list' => 'ENVCONFIG', 'env' => 'ENFORCE_EMAIL_VERIFICATION', 'rule' => 'boolean'],
'pixelfed.max_account_size' => ['list' => 'ENVCONFIG', 'env' => 'MAX_ACCOUNT_SIZE', 'rule' => 'integer|min:50000'],
'pixelfed.enforce_account_limit' => ['list' => 'ENVCONFIG', 'env' => 'LIMIT_ACCOUNT_SIZE', 'rule' => 'boolean'],
'pixelfed.cloud_storage' => ['list' => 'ENVCONFIG', 'env' => 'PF_ENABLE_CLOUD', 'rule' => 'boolean'],
'pixelfed.max_caption_length' => ['list' => 'ENVCONFIG', 'env' => 'MAX_CAPTION_LENGTH', 'rule' => 'integer'],
'pixelfed.max_bio_length' => ['list' => 'ENVCONFIG', 'env' => 'MAX_BIO_LENGTH', 'rule' => 'integer'],
'pixelfed.max_name_length' => ['list' => 'ENVCONFIG', 'env' => 'MAX_NAME_LENGTH', 'rule' => 'integer'],
'pixelfed.min_password_length' => ['list' => 'ENVCONFIG', 'env' => 'MIN_PASSWORD_LENGTH', 'rule' => 'integer|min:6'],
'pixelfed.max_avatar_size' => ['list' => 'ENVCONFIG', 'env' => 'MAX_AVATAR_SIZE', 'rule' => 'integer'],
'pixelfed.max_altext_length' => ['list' => 'ENVCONFIG', 'env' => 'PF_MEDIA_MAX_ALTTEXT_LENGTH', 'rule' => 'integer'],
'pixelfed.allow_app_registration' => ['list' => 'ENVCONFIG', 'env' => 'PF_ALLOW_APP_REGISTRATION', 'rule' => 'boolean'],
'pixelfed.app_registration_rate_limit_attempts' => ['list' => 'ENVCONFIG', 'env' => 'PF_IAR_RL_ATTEMPTS', 'rule' => 'integer'],
'pixelfed.app_registration_rate_limit_decay' => ['list' => 'ENVCONFIG', 'env' => 'PF_IAR_RL_DECAY', 'rule' => 'integer'],
'pixelfed.app_registration_confirm_rate_limit_attempts' => ['list' => 'ENVCONFIG', 'env' => 'PF_IARC_RL_ATTEMPTS', 'rule' => 'integer'],
'pixelfed.app_registration_confirm_rate_limit_decay' => ['list' => 'ENVCONFIG', 'env' => 'PF_IARC_RL_DECAY', 'rule' => 'integer'],
'pixelfed.optimize_image' => ['list' => 'ENVCONFIG', 'env' => 'PF_OPTIMIZE_IMAGES', 'rule' => 'boolean'],
'pixelfed.optimize_video' => ['list' => 'ENVCONFIG', 'env' => 'PF_OPTIMIZE_VIDEOS', 'rule' => 'boolean'],
'pixelfed.max_collection_length' => ['list' => 'ENVCONFIG', 'env' => 'PF_MAX_COLLECTION_LENGTH', 'rule' => 'integer'],
// federation.php
'federation.activitypub.enabled' => ['list' => 'ENVCONFIG', 'env' => 'ACTIVITY_PUB', 'rule' => 'boolean'],
'federation.activitypub.authorized_fetch' => ['list' => 'ENVCONFIG', 'env' => 'AUTHORIZED_FETCH', 'rule' => 'boolean'],
'federation.migration' => ['list' => 'ENVCONFIG', 'env' => 'PF_ACCT_MIGRATION_ENABLED', 'rule' => 'boolean'],
'federation.custom_emoji.enabled' => ['list' => 'ENVCONFIG', 'env' => 'CUSTOM_EMOJI', 'rule' => 'boolean'],
// instance.php
'instance.stories.enabled' => ['list' => 'ENVCONFIG', 'env' => 'STORIES_ENABLED', 'rule' => 'boolean'],
'instance.avatar.local_to_cloud' => ['list' => 'ENVCONFIG', 'env' => 'PF_LOCAL_AVATAR_TO_CLOUD', 'rule' => 'boolean'],
'instance.has_legal_notice' => ['list' => 'ENVCONFIG', 'env' => 'INSTANCE_LEGAL_NOTICE', 'rule' => 'boolean'],
'instance.landing.show_directory' => ['list' => 'ENVCONFIG', 'env' => 'INSTANCE_LANDING_SHOW_DIRECTORY', 'rule' => 'boolean'],
'instance.landing.show_explore' => ['list' => 'ENVCONFIG', 'env' => 'INSTANCE_LANDING_SHOW_EXPLORE', 'rule' => 'boolean'],
'instance.curated_registration.enabled' => ['list' => 'ENVCONFIG', 'env' => 'INSTANCE_CUR_REG', 'rule' => 'boolean'],
'instance.embed.profile' => ['list' => 'ENVCONFIG', 'env' => 'INSTANCE_PROFILE_EMBEDS', 'rule' => 'boolean'],
'instance.embed.post' => ['list' => 'ENVCONFIG', 'env' => 'INSTANCE_POST_EMBEDS', 'rule' => 'boolean'],
'instance.user_filters.max_user_blocks' => ['list' => 'ENVCONFIG', 'env' => 'PF_MAX_USER_BLOCKS', 'rule' => 'integer|min:0|max:5000'],
'instance.user_filters.max_user_mutes' => ['list' => 'ENVCONFIG', 'env' => 'PF_MAX_USER_MUTES', 'rule' => 'integer|min:0|max:5000'],
'instance.user_filters.max_domain_blocks' => ['list' => 'ENVCONFIG', 'env' => 'PF_MAX_DOMAIN_BLOCKS', 'rule' => 'integer|min:0|max:5000'],
'instance.admin.pid' => ['list' => 'ENVCONFIG', 'env' => 'PF_ADMIN_PID', 'rule' => 'integer'],
'instance.banner.blurhash' => ['list' => 'ENVCONFIG', 'env' => 'INSTANCE_BANNER_BLURHASH', 'rule' => 'string'],
// media.php
'media.delete_local_after_cloud' => ['list' => 'ENVCONFIG', 'env' => 'MEDIA_DELETE_LOCAL_AFTER_CLOUD', 'rule' => 'boolean'],
// 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'],
'captcha.hcaptcha.sitekey' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_H_SITEKEY', 'rule' => 'string'],
'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'],
'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'],
'captcha.active.password_reset' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_PASSWORD_RESET', 'rule' => 'boolean'],
'captcha.active.forgot_email' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_FORGOT_EMAIL', 'rule' => 'boolean'],
'captcha.active.curated_register' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_CURATED_REGISTER', 'rule' => 'boolean'],
// ADMINONLY (no env var)
'app.banner_image' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'about.title' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'uikit.custom.css' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'uikit.custom.js' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'uikit.show_custom.css' => ['list' => 'ADMINONLY', 'rule' => 'boolean'],
'uikit.show_custom.js' => ['list' => 'ADMINONLY', 'rule' => 'boolean'],
'account.autofollow' => ['list' => 'ADMINONLY', 'rule' => 'boolean'],
'account.autofollow_usernames' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'config.discover.features' => ['list' => 'ADMINONLY', 'rule' => 'json'],
'pixelfed.directory' => ['list' => 'ADMINONLY', 'rule' => 'boolean'],
'pixelfed.directory.submission-key' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'pixelfed.directory.submission-ts' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'pixelfed.directory.has_submitted' => ['list' => 'ADMINONLY', 'rule' => 'boolean'],
'pixelfed.directory.latest_response' => ['list' => 'ADMINONLY', 'rule' => 'string'],
'pixelfed.directory.is_synced' => ['list' => 'ADMINONLY', 'rule' => 'boolean'],
'pixelfed.directory.testimonials' => ['list' => 'ADMINONLY', 'rule' => 'json'],
'instance.stats.total_local_posts' => ['list' => 'ADMINONLY', 'rule' => 'integer'],
'autospam.nlp.enabled' => ['list' => 'ADMINONLY', 'rule' => 'boolean'],
];
public static function isCached(string $key): bool
{
return isset(self::KEYS[$key]);
}
public static function listOf(string $key): ?string
{
return self::KEYS[$key]['list'] ?? null;
}
public static function envVarFor(string $key): ?string
{
return self::KEYS[$key]['env'] ?? null;
}
public static function isProtected(string $key): bool
{
return in_array($key, self::PROTECTED_KEYS, true);
}
public static function ruleFor(string $key): ?string
{
return self::KEYS[$key]['rule'] ?? null;
}
public static function keysInList(string $list): array
{
return array_keys(array_filter(self::KEYS, fn ($m) => ($m['list'] ?? null) === $list));
}
public static function adminVisibleKeys(): array
{
return array_keys(self::KEYS);
}
// True while the key's env var is present and valid: env wins the read and
// the key is locked from admin edits.
public static function isLocked(string $key): bool
{
$envVar = self::envVarFor($key);
if ($envVar === null || ! self::envIsSet($envVar)) {
return false;
}
return EnvConfigValidator::isValidEnvValue($key);
}
// An empty string counts as unset.
public static function envIsSet(string $envVar): bool
{
$v = Env::get($envVar);
return $v !== null && $v !== '';
}
public static function get($key)
{
if (! self::isCached($key) || self::isLocked($key)) {
return config($key);
}
return self::readFromDb($key);
}
protected static function readFromDb($key)
{
try {
return Cache::remember(self::CACHE_KEY.$key, now()->addHours(12), function () use ($key) {
$protect = self::isProtected($key);
$v = config($key);
$row = ConfigCacheModel::where('k', $key)->first();
if ($row) {
$stored = $protect ? decrypt($row->v) : $row->v;
return $stored !== null ? self::castFromDb($key, $stored) : config($key);
}
// Don't seed a row for an empty/null value.
if ($v === null || $v === '') {
return $v;
}
self::putRaw($key, $v);
return $v;
});
} catch (Exception|QueryException) {
return config($key);
}
}
// Low-level write guard. Env-locked keys are not writable (env wins), so the
// write is skipped and the current value returned. This is a safety net;
public static function put($key, $val)
{
if (! self::isCached($key)) {
return config($key);
}
if (self::isLocked($key)) {
return self::get($key);
}
return self::putRaw($key, $val);
}
// Writes without the lock check. The sync uses this; app code calls put().
public static function putRaw($key, $val)
{
$row = ConfigCacheModel::firstOrNew(['k' => $key]);
$row->v = self::isProtected($key) ? encrypt($val) : $val;
$row->save();
Cache::put(self::CACHE_KEY.$key, $val, now()->addHours(12));
return self::get($key);
}
// The `v` column is text, so cast the value back to its declared type.
protected static function castFromDb($key, $value)
{
$type = explode('|', (string) self::ruleFor($key))[0];
return match ($type) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
'integer' => (int) $value,
'json' => is_string($value) ? (json_decode($value, true) ?? $value) : $value,
default => $value,
};
}
// Deletes the row and its cache entry.
public static function forget($key): void
{
ConfigCacheModel::whereK($key)->delete();
Cache::forget(self::CACHE_KEY.$key);
}
// Drops every key's cache entry (rows untouched); returns the count.
public static function flushAll(): int
{
$keys = self::adminVisibleKeys();
foreach ($keys as $key) {
Cache::forget(self::CACHE_KEY.$key);
}
return count($keys);
}
// Whether the .env → config_cache reconcile is enabled.
public static function syncEnabled(): bool
{
return (bool) Env::get('PIXELFED_CONFIG_CACHE_SYNC', true);
}
// Reconcile .env into config_cache for env-bound keys. Hash-gated (skips when
// governed values are unchanged, unless $force) and lock-guarded against
// concurrent runs. Writes the change-hash marker on completion.
public static function sync(bool $force = false): void
{
$hash = self::configHash();
if (! $force && Cache::get(self::MARKER_KEY) === $hash) {
Log::info('config-cache sync skipped: hash unchanged '.$hash);
return;
}
$lock = Cache::lock(self::LOCK_KEY, self::LOCK_TTL);
if (! $lock->get()) {
return;
}
try {
self::refreshEnv();
self::flushAll();
Cache::forever(self::MARKER_KEY, $hash);
} finally {
$lock->release();
}
}
// Reconcile each ENVCONFIG key's row against its authoritative env/config
// value: seed unlocked keys, prune stale locked rows when empty, overwrite
// drift when locked.
public static function refreshEnv(): void
{
foreach (self::keysInList('ENVCONFIG') as $key) {
$row = ConfigCacheModel::where('k', $key)->first();
$value = config($key);
$empty = $value === null || $value === '';
if (! self::isLocked($key)) {
if ($row === null && ! $empty) {
self::putRaw($key, $value);
}
continue;
}
if ($empty) {
if ($row !== null) {
self::forget($key);
}
// != on purpose: `v` is a text column, so a stored
// "5" must count as equal to an int 5 due to loose typing.
} elseif ($row === null || $row->v != $value) {
self::putRaw($key, $value);
}
}
}
// Stable sha256 over every governed ENVCONFIG key's effective config value.
public static function configHash(): string
{
$keys = self::keysInList('ENVCONFIG');
sort($keys);
$payload = [];
foreach ($keys as $k) {
$payload[$k] = config($k);
}
return hash('sha256', json_encode($payload));
}
}