Merge pull request #7314 from pixelfed/staging

Staging - Fixing language, Captcha Agnostic, and bugs
pull/7362/head^2
dansup 1 week ago committed by GitHub
commit 68c8dbb1dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,38 @@
#######################################
# Captcha
#######################################
# Master switch. Must be true for any captcha to appear.
CAPTCHA_ENABLED=false
# Active provider: hcaptcha (default), turnstile, or cap.
CAPTCHA_DRIVER=hcaptcha
# Per-surface toggles (each also requires CAPTCHA_ENABLED=true)
CAPTCHA_ENABLED_ON_LOGIN=true
CAPTCHA_ENABLED_ON_REGISTER=true
CAPTCHA_ENABLED_ON_CURATED_REGISTER=true
CAPTCHA_ENABLED_ON_FORGOT_EMAIL=true
CAPTCHA_ENABLED_ON_FORGOT_PASSWORD=true
CAPTCHA_ENABLED_ON_PASSWORD_RESET=true
# --- hCaptcha (driver: hcaptcha) ---
CAPTCHA_H_SITEKEY=
CAPTCHA_H_SECRET=
CAPTCHA_H_TIMEOUT=5
CAPTCHA_H_FAIL_OPEN=false
# --- Cloudflare Turnstile (driver: turnstile) ---
CAPTCHA_TURNSTILE_SITEKEY=
CAPTCHA_TURNSTILE_SECRET=
CAPTCHA_TURNSTILE_TIMEOUT=5
CAPTCHA_TURNSTILE_FAIL_OPEN=false
# --- Cap (driver: cap) ---
# Base URL WITHOUT the site key
CAPTCHA_CAP_ENDPOINT=https://example.com
CAPTCHA_CAP_SITEKEY=
CAPTCHA_CAP_SECRET=
CAPTCHA_CAP_TIMEOUT=5
CAPTCHA_CAP_FAIL_OPEN=false
CAPTCHA_CAP_TOKEN_FIELD=cap-captcha-response
# @cap.js/widget version from jsDelivr; leave "latest" to track newest stable
CAPTCHA_CAP_WIDGET_VERSION=latest

@ -6,6 +6,7 @@ APP_NAME="Pixelfed"
APP_ENV="production"
APP_KEY=
APP_DEBUG="false"
APP_LOCALE="en-US"
# Instance Configuration
OPEN_REGISTRATION="false"

@ -2,6 +2,7 @@ APP_NAME="Pixelfed"
APP_ENV="production"
APP_KEY=
APP_DEBUG="false"
APP_LOCALE="en-US"
# Instance Configuration
OPEN_REGISTRATION="false"

@ -2,6 +2,7 @@ APP_NAME="Pixelfed Test"
APP_ENV=testing
APP_KEY=base64:lwX95GbNWX3XsucdMe0XwtOKECta3h/B+p9NbH2jd0E=
APP_DEBUG=true
APP_LOCALE="en-US"
APP_URL=https://pixelfed.test
APP_DOMAIN="pixelfed.test"

@ -0,0 +1,5 @@
; vue-blurhash@0.1.4 declares a peer dependency on blurhash@^1.1.3, but the
; project uses blurhash@^2.x. The two are compatible in practice, so allow
; npm (v7+) to install despite the peer mismatch instead of failing.
; TODO - Remove after the new WebUI is release.
legacy-peer-deps=true

@ -0,0 +1,65 @@
<?php
namespace App\Console\Commands\Admin;
use App\Services\ConfigCacheService;
use Illuminate\Console\Command;
use function Laravel\Prompts\info;
use function Laravel\Prompts\warning;
/**
* Enables captcha and turns on the per-surface toggles.
*
* Because captcha settings are stored in the config-cache DB table (which
* overrides env/config-file values), enabling captcha via .env alone has no
* effect on an instance that already has rows. This command writes the correct
* rows so the change takes effect immediately.
*/
final class CaptchaEnableCommand extends Command
{
protected $signature = 'captcha:enable
{--surfaces=* : Limit to specific surfaces (login, register, forgot_password, password_reset, forgot_email, curated_register). Defaults to all.}
{--all-surfaces : Enable every surface (default when no --surfaces given)}';
protected $description = 'Enable captcha and its per-surface toggles in the config cache';
private const SURFACES = [
'login',
'register',
'forgot_password',
'password_reset',
'forgot_email',
'curated_register',
];
public function handle(): int
{
$driver = config_cache('captcha.driver') ?: config('captcha.driver', 'hcaptcha');
if (! app('captcha.manager')->driver($driver)->isConfigured()) {
warning("The active captcha driver [{$driver}] is not fully configured.");
warning('Set its credentials in the admin panel or .env before enabling, or the widget will not verify.');
}
ConfigCacheService::put('captcha.enabled', true);
info('captcha.enabled => true');
$requested = (array) $this->option('surfaces');
$surfaces = empty($requested) ? self::SURFACES : $requested;
foreach ($surfaces as $surface) {
if (! in_array($surface, self::SURFACES, true)) {
warning("Skipping unknown surface: {$surface}");
continue;
}
ConfigCacheService::put('captcha.active.'.$surface, true);
info("captcha.active.{$surface} => true");
}
info('Done. Active driver: '.$driver);
return self::SUCCESS;
}
}

@ -61,8 +61,14 @@ class ExportLanguages extends Command
$exportDir = resource_path('assets/js/i18n/');
$exportDirAlt = public_path('_lang/');
// Remove orphaned exports whose locale no longer maps to a lang/
// folder (e.g. left over after a language folder is deleted/renamed).
$this->purgeOrphanedJsonFiles($exportDir, $langs);
$this->purgeOrphanedJsonFiles($exportDirAlt, $langs);
foreach ($langs as $lang) {
$strings = \Lang::get('web', [], $lang);
$strings = $this->stripEmptyStrings($strings);
$json = json_encode($strings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$path = "{$exportDir}{$lang}.json";
file_put_contents($path, $json);
@ -70,6 +76,116 @@ class ExportLanguages extends Command
file_put_contents($pathAlt, $json);
}
$this->writeLocalesManifest($langs);
return Command::SUCCESS;
}
/**
* Write a static locales manifest (lang/locales.json) that is the single
* source of truth for the available UI languages. Generated here so it is
* always regenerated alongside the exported strings, avoiding a runtime
* cache that can go stale (see Localization::languages()).
*
* Sorted by English display name so consumers render an alphabetical list.
*
* @param array<int, string> $langs Valid locale codes (lang/ folders).
*/
/**
* Display-name overrides for locale codes that ICU cannot resolve
* correctly. Crowdin uses some non-standard codes (e.g. custom
* languages) that would otherwise render as the wrong language or as
* the raw code via locale_get_display_name().
*
* @var array<string, array{name: string, nativeName: string}>
*/
protected const LOCALE_OVERRIDES = [
// Crowdin custom "Pirate English". Uses the BCP-47 private-use form
// en-x-pirate (the old en-PT code collided with English (Portugal)).
'en-x-pirate' => ['name' => 'Pirate (English)', 'nativeName' => 'Pirate (English)'],
// Klingon. 'tlh' is valid BCP-47 (ICU renders "Klingon"), but ICU has
// no native-name form, so pin both for a consistent label. Mapped from
// Crowdin's tlh-AA (AA is a fake region) via crowdin.yml.
'tlh' => ['name' => 'Klingon', 'nativeName' => 'tlhIngan Hol'],
];
protected function writeLocalesManifest(array $langs): void
{
$locales = array_map(function ($code) {
if (isset(self::LOCALE_OVERRIDES[$code])) {
return array_merge(['code' => $code], self::LOCALE_OVERRIDES[$code]);
}
return [
'code' => $code,
'name' => locale_get_display_name($code, 'en'),
'nativeName' => locale_get_display_name($code, $code),
];
}, $langs);
usort($locales, function ($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
$json = json_encode($locales, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Server-side source of truth (used by Localization::languages()).
file_put_contents(lang_path('locales.json'), $json);
// Public copy so the SPA can fetch the same ordered list.
$publicManifest = public_path('_lang/locales.json');
file_put_contents($publicManifest, $json);
@chmod($publicManifest, 0644);
}
/**
* Delete .json exports in the given directory that don't correspond to
* a current language folder, leaving valid locale files untouched.
*
* @param array<int, string> $langs Valid locale names (lang/ folders).
*/
protected function purgeOrphanedJsonFiles(string $dir, array $langs): void
{
if (! is_dir($dir)) {
return;
}
$valid = array_flip($langs);
// Not a locale export, but written by writeLocalesManifest().
$keep = ['locales' => true];
foreach (glob(rtrim($dir, '/').'/*.json') as $file) {
$locale = basename($file, '.json');
if (! isset($valid[$locale]) && ! isset($keep[$locale])) {
@unlink($file);
}
}
}
/**
* Recursively remove empty string values so untranslated Crowdin
* placeholders don't override the UI's fallback (English) strings.
*/
protected function stripEmptyStrings(array $strings): array
{
$result = [];
foreach ($strings as $key => $value) {
if (is_array($value)) {
$filtered = $this->stripEmptyStrings($value);
if (! empty($filtered)) {
$result[$key] = $filtered;
}
} elseif (is_string($value)) {
if (trim($value) !== '') {
$result[$key] = $value;
}
} else {
$result[$key] = $value;
}
}
return $result;
}
}

@ -0,0 +1,47 @@
<?php
namespace App\Contracts;
/**
* Contract every captcha provider (hCaptcha, Turnstile, Cap, ...) must implement
* so the rest of the application can stay provider-agnostic.
*/
interface CaptchaDriver
{
/**
* The machine name of the driver (e.g. "hcaptcha", "turnstile", "cap").
*/
public function name(): string;
/**
* Whether the driver has the credentials/config it needs to operate.
*/
public function isConfigured(): bool;
/**
* The name of the request field that carries the response token for this
* provider (e.g. "h-captcha-response", "cf-turnstile-response", "cap-token").
*/
public function responseField(): string;
/**
* Verify a submitted request against the provider.
*
* Implementations should pull the response token out of the given input
* array using responseField().
*/
public function verify(array $input): bool;
/**
* Render the widget markup to embed in a form. Optional HTML attributes
* (e.g. ['data-theme' => 'dark']) may be passed through to the widget.
*/
public function render(array $attributes = []): string;
/**
* Any <script>/<link> tags the widget needs. Returned separately so callers
* can place them in <head> or before </body> as appropriate. May be empty
* when render() already includes everything.
*/
public function scripts(): string;
}

@ -0,0 +1,27 @@
<?php
namespace App\Facades;
use App\Contracts\CaptchaDriver;
use App\Services\Captcha\CaptchaManager;
use Illuminate\Support\Facades\Facade;
/**
* Provider-agnostic captcha facade.
*
* @method static CaptchaDriver active()
* @method static bool enabled()
* @method static bool activeOn(string $surface)
* @method static array available()
* @method static array rules()
* @method static CaptchaDriver driver(string|null $driver = null)
*
* @see CaptchaManager
*/
class Captcha extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'captcha.manager';
}
}

@ -101,7 +101,8 @@ trait AdminDirectoryController
'media_types' => [
'required',
function ($attribute, $value, $fail) {
if (! in_array('image/jpeg', $value->toArray()) || ! in_array('image/png', $value->toArray())) {
$types = is_array($value) ? $value : collect($value)->toArray();
if (! in_array('image/jpeg', $types) || ! in_array('image/png', $types)) {
$fail('You must enable image/jpeg and image/png support.');
}
},
@ -269,7 +270,8 @@ trait AdminDirectoryController
'media_types' => [
'required',
function ($attribute, $value, $fail) {
if (! in_array('image/jpeg', $value->toArray()) || ! in_array('image/png', $value->toArray())) {
$types = is_array($value) ? $value : collect($value)->toArray();
if (! in_array('image/jpeg', $types) || ! in_array('image/png', $types)) {
$fail('You must enable image/jpeg and image/png support.');
}
},

@ -679,10 +679,13 @@ trait AdminSettingsController
'allow_post_embeds' => 'required',
'allow_profile_embeds' => 'required',
'captcha_enabled' => 'required',
'captcha_driver' => 'nullable|in:hcaptcha,turnstile,cap',
'captcha_on_login' => 'required_if_accepted:captcha_enabled',
'captcha_on_register' => 'required_if_accepted:captcha_enabled',
'captcha_secret' => 'required_if_accepted:captcha_enabled',
'captcha_sitekey' => 'required_if_accepted:captcha_enabled',
// Provider credentials are optional here (masked values are sent on
// re-save); the save logic below only writes fresh, non-masked values.
'captcha_hcaptcha_secret' => 'nullable|string',
'captcha_hcaptcha_sitekey' => 'nullable|string',
'custom_emoji_enabled' => 'required',
]);
@ -696,17 +699,51 @@ trait AdminSettingsController
ConfigCacheService::put('federation.custom_emoji.enabled', $request->boolean('custom_emoji_enabled'));
$captcha = $request->boolean('captcha_enabled');
if ($captcha) {
$secret = $request->input('captcha_secret');
$sitekey = $request->input('captcha_sitekey');
if (config_cache('captcha.secret') != $secret && strpos($secret, '*') === false) {
ConfigCacheService::put('captcha.secret', $secret);
// Persist the selected provider (defaults to hcaptcha).
$driver = $request->input('captcha_driver', 'hcaptcha');
if (! in_array($driver, ['hcaptcha', 'turnstile', 'cap'], true)) {
$driver = 'hcaptcha';
}
if (config_cache('captcha.sitekey') != $sitekey && strpos($sitekey, '*') === false) {
ConfigCacheService::put('captcha.sitekey', $sitekey);
ConfigCacheService::put('captcha.driver', $driver);
// Only overwrite a secret/credential when a fresh (non-masked,
// non-empty) value is submitted. Masked values contain '*'.
$putIfChanged = function (string $key, ?string $value): void {
if ($value === null || $value === '' || str_contains($value, '*')) {
return;
}
if (config_cache($key) != $value) {
ConfigCacheService::put($key, $value);
}
};
// hCaptcha credentials. Persist to the canonical captcha.hcaptcha.*
// keys. CaptchaServiceProvider hydrates the top-level captcha.secret
// / captcha.sitekey that the buzz/laravel-h-captcha package reads.
$putIfChanged('captcha.hcaptcha.secret', $request->input('captcha_hcaptcha_secret'));
$putIfChanged('captcha.hcaptcha.sitekey', $request->input('captcha_hcaptcha_sitekey'));
// Turnstile credentials (sitekey is public, store as-is when present)
$putIfChanged('captcha.turnstile.secret', $request->input('captcha_turnstile_secret'));
if ($request->filled('captcha_turnstile_sitekey')) {
ConfigCacheService::put('captcha.turnstile.sitekey', $request->input('captcha_turnstile_sitekey'));
}
// Cap credentials (endpoint + sitekey are public, store as-is)
$putIfChanged('captcha.cap.secret', $request->input('captcha_cap_secret'));
if ($request->filled('captcha_cap_endpoint')) {
ConfigCacheService::put('captcha.cap.endpoint', $request->input('captcha_cap_endpoint'));
}
if ($request->filled('captcha_cap_sitekey')) {
ConfigCacheService::put('captcha.cap.sitekey', $request->input('captcha_cap_sitekey'));
}
ConfigCacheService::put('captcha.active.login', $request->boolean('captcha_on_login'));
ConfigCacheService::put('captcha.active.register', $request->boolean('captcha_on_register'));
ConfigCacheService::put('captcha.triggers.login.enabled', $request->boolean('captcha_on_login'));
ConfigCacheService::put('captcha.active.forgot_password', $request->boolean('captcha_on_forgot_password'));
ConfigCacheService::put('captcha.active.password_reset', $request->boolean('captcha_on_password_reset'));
ConfigCacheService::put('captcha.active.forgot_email', $request->boolean('captcha_on_forgot_email'));
ConfigCacheService::put('captcha.active.curated_register', $request->boolean('captcha_on_curated_register'));
ConfigCacheService::put('captcha.enabled', true);
} else {
ConfigCacheService::put('captcha.enabled', false);
@ -720,10 +757,20 @@ trait AdminSettingsController
'allow_post_embeds' => $request->boolean('allow_post_embeds'),
'allow_profile_embeds' => $request->boolean('allow_profile_embeds'),
'captcha_enabled' => $request->boolean('captcha_enabled'),
'captcha_driver' => $request->input('captcha_driver', 'hcaptcha'),
'captcha_on_login' => $request->boolean('captcha_on_login'),
'captcha_on_register' => $request->boolean('captcha_on_register'),
'captcha_secret' => $request->input('captcha_secret'),
'captcha_sitekey' => $request->input('captcha_sitekey'),
'captcha_on_forgot_password' => $request->boolean('captcha_on_forgot_password'),
'captcha_on_password_reset' => $request->boolean('captcha_on_password_reset'),
'captcha_on_forgot_email' => $request->boolean('captcha_on_forgot_email'),
'captcha_on_curated_register' => $request->boolean('captcha_on_curated_register'),
'captcha_hcaptcha_secret' => $request->input('captcha_hcaptcha_secret'),
'captcha_hcaptcha_sitekey' => $request->input('captcha_hcaptcha_sitekey'),
'captcha_turnstile_secret' => $request->input('captcha_turnstile_secret'),
'captcha_turnstile_sitekey' => $request->input('captcha_turnstile_sitekey'),
'captcha_cap_endpoint' => $request->input('captcha_cap_endpoint'),
'captcha_cap_sitekey' => $request->input('captcha_cap_sitekey'),
'captcha_cap_secret' => $request->input('captcha_cap_secret'),
'custom_emoji_enabled' => $request->boolean('custom_emoji_enabled'),
];
Cache::forget('api:v1:instance-data:rules');

@ -802,7 +802,7 @@ class ApiV1Controller extends Controller
'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
'limit' => 'nullable|integer|min:1|max:40',
'limit' => 'nullable|integer|min:1|max:100',
'only_reposts' => 'nullable',
]);
@ -827,7 +827,7 @@ class ApiV1Controller extends Controller
}
}
$limit = min((int) $request->input('limit', 20), 40);
$limit = min((int) $request->input('limit', 20), 100);
$profileId = (int) $profile['id'];
$viewerId = (int) $user->profile_id;

@ -80,8 +80,8 @@ class AppRegisterController extends Controller
'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|unique:app_registers,email',
];
if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) {
$rules['h-captcha-response'] = 'required|captcha';
if (app('captcha.manager')->activeOn('register')) {
$rules[app('captcha.manager')->active()->responseField()] = 'required|captcha_verify';
}
$this->validate($request, $rules);
@ -188,8 +188,8 @@ class AppRegisterController extends Controller
'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|exists:app_registers,email',
];
if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) {
$rules['h-captcha-response'] = 'required|captcha';
if (app('captcha.manager')->activeOn('register')) {
$rules[app('captcha.manager')->active()->responseField()] = 'required|captcha_verify';
}
$this->validate($request, $rules);

@ -60,20 +60,18 @@ class ForgotPasswordController extends Controller
usleep(random_int(100000, 3000000));
if ((bool) config_cache('captcha.enabled')) {
$rules = [
'email' => 'required|email',
'h-captcha-response' => 'required|captcha',
];
} else {
$rules = [
'email' => 'required|email',
];
$rules = [
'email' => 'required|email',
];
$messages = [];
if (app('captcha.manager')->activeOn('forgot_password')) {
$captchaField = app('captcha.manager')->active()->responseField();
$rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField] = 'Failed to validate the captcha.';
}
$request->validate($rules, [
'h-captcha-response' => 'Failed to validate the captcha.',
]);
$request->validate($rules, $messages);
}
/**

@ -553,21 +553,10 @@ class LoginController extends Controller
$messages = [];
if (
(bool) config_cache('captcha.enabled') &&
(bool) config_cache('captcha.active.login') ||
(
(bool) config_cache('captcha.triggers.login.enabled') &&
request()->session()->has('login_attempts') &&
request()->session()->get('login_attempts') >=
config('captcha.triggers.login.attempts')
)
) {
$rules['h-captcha-response'] =
'required|filled|captcha|min:5';
$messages['h-captcha-response.required'] =
'The captcha must be filled';
if (app('captcha.manager')->activeOn('login')) {
$field = app('captcha.manager')->active()->responseField();
$rules[$field] = 'required|filled|captcha_verify';
$messages[$field.'.required'] = 'The captcha must be filled';
}
$request->validate($rules, $messages);
@ -597,22 +586,6 @@ class LoginController extends Controller
protected function sendFailedLoginResponse(
Request $request
): void {
if (config('captcha.triggers.login.enabled')) {
if ($request->session()->has('login_attempts')) {
$ct = $request->session()->get('login_attempts');
$request->session()->put(
'login_attempts',
$ct + 1
);
} else {
$request->session()->put(
'login_attempts',
1
);
}
}
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
]);

@ -98,8 +98,8 @@ class RegisterController extends Controller
'password' => 'required|string|min:'.config('pixelfed.min_password_length').'|confirmed',
];
if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) {
$rules['h-captcha-response'] = 'required|captcha';
if (app('captcha.manager')->activeOn('register')) {
$rules[app('captcha.manager')->active()->responseField()] = 'required|captcha_verify';
}
return Validator::make($data, $rules);

@ -55,20 +55,17 @@ class ResetPasswordController extends Controller
{
usleep(random_int(100000, 3000000));
if ((bool) config_cache('captcha.enabled')) {
return [
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', 'max:72', Rules\Password::defaults()],
'h-captcha-response' => ['required', 'filled', 'captcha'],
];
}
return [
$rules = [
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', 'max:72', Rules\Password::defaults()],
];
if (app('captcha.manager')->activeOn('password_reset')) {
$rules[app('captcha.manager')->active()->responseField()] = ['required', 'filled', 'captcha_verify'];
}
return $rules;
}
/**
@ -76,11 +73,13 @@ class ResetPasswordController extends Controller
*/
protected function validationErrorMessages(): array
{
$field = app('captcha.manager')->active()->responseField();
return [
'password.max' => 'Passwords should not exceed 72 characters.',
'h-captcha-response.required' => 'Failed to validate the captcha.',
'h-captcha-response.filled' => 'Failed to validate the captcha.',
'h-captcha-response.captcha' => 'Failed to validate the captcha.',
$field.'.required' => 'Failed to validate the captcha.',
$field.'.filled' => 'Failed to validate the captcha.',
$field.'.captcha_verify' => 'Failed to validate the captcha.',
];
}

@ -17,6 +17,17 @@ use Illuminate\Support\Str;
class CuratedRegisterController extends Controller
{
/**
* Whether a captcha should be enforced on the curated registration flow.
*
* True when the curated-registration-specific flag is on, or when the
* platform captcha is enabled with its "curated_register" surface active.
*/
protected function curatedCaptchaEnabled(): bool
{
return app('captcha.manager')->activeOn('curated_register');
}
public function preCheck($allowWhenDisabled = false): void
{
if (! $allowWhenDisabled) {
@ -69,9 +80,9 @@ class CuratedRegisterController extends Controller
);
$crid = $request->session()->get('cur-reg-con.cr-id');
$arid = $request->session()->get('cur-reg-con.ac-id');
$showCaptcha = config('instance.curated_registration.captcha_enabled');
$showCaptcha = $this->curatedCaptchaEnabled();
if ($attempts = $request->session()->get('cur-reg-con-attempt')) {
$showCaptcha = $attempts && $attempts >= 2;
$showCaptcha = $showCaptcha && $attempts >= 2;
} else {
$showCaptcha = false;
}
@ -98,9 +109,10 @@ class CuratedRegisterController extends Controller
'crid' => 'required|integer|min:1',
'acid' => 'required|integer|min:1',
];
if (config('instance.curated_registration.captcha_enabled') && $attempts >= 3) {
$rules['h-captcha-response'] = 'required|captcha';
$messages['h-captcha-response.required'] = 'The captcha must be filled';
if ($this->curatedCaptchaEnabled() && $attempts >= 3) {
$captchaField = app('captcha.manager')->active()->responseField();
$rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
}
$this->validate($request, $rules, $messages);
$crid = $request->session()->get('cur-reg-con.cr-id');
@ -141,9 +153,10 @@ class CuratedRegisterController extends Controller
'response' => 'required_if:action,message|string|min:20|max:1000',
];
$messages = [];
if (config('instance.curated_registration.captcha_enabled')) {
$rules['h-captcha-response'] = 'required|captcha';
$messages['h-captcha-response.required'] = 'The captcha must be filled';
if ($this->curatedCaptchaEnabled()) {
$captchaField = app('captcha.manager')->active()->responseField();
$rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
}
$this->validate($request, $rules, $messages);
@ -219,9 +232,10 @@ class CuratedRegisterController extends Controller
$messages = [];
if (config('instance.curated_registration.captcha_enabled')) {
$rules['h-captcha-response'] = 'required|captcha';
$messages['h-captcha-response.required'] = 'The captcha must be filled';
if ($this->curatedCaptchaEnabled()) {
$captchaField = app('captcha.manager')->active()->responseField();
$rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
}
$this->validate($request, $rules, $messages);
@ -279,9 +293,10 @@ class CuratedRegisterController extends Controller
'code' => 'required',
];
$messages = [];
if (config('instance.curated_registration.captcha_enabled')) {
$rules['h-captcha-response'] = 'required|captcha';
$messages['h-captcha-response.required'] = 'The captcha must be filled';
if ($this->curatedCaptchaEnabled()) {
$captchaField = app('captcha.manager')->active()->responseField();
$rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'The captcha must be filled';
}
$this->validate($request, $rules, $messages);

@ -42,7 +42,7 @@ trait HomeSettings
'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'),
'website' => 'nullable|url',
'language' => 'nullable|string|min:2|max:5',
'language' => 'nullable|string|min:2|max:12',
'pronouns' => 'nullable|array|max:4',
]);

@ -61,7 +61,12 @@ class SiteController extends Controller
public function about()
{
return Cache::remember('site.about_v2', now()->addMinutes(15), function () {
// Scope the cache key by locale: the rendered view contains many
// translated site.* strings, so a single shared key would let one
// locale's render be served to visitors of other locales.
$cacheKey = 'site.about_v2:'.app()->getLocale();
return Cache::remember($cacheKey, now()->addMinutes(15), function () {
$user_count = number_format(User::count());
$post_count = number_format(StatusService::totalLocalStatuses());
$rules = config_cache('app.rules') ? json_decode(config_cache('app.rules'), true) : null;
@ -77,7 +82,11 @@ class SiteController extends Controller
public function communityGuidelines(Request $request)
{
return Cache::remember('site:help:community-guidelines', now()->addDays(120), function () {
// Scope by locale: the rendered layout contains translated strings,
// so a shared key would leak one locale's render to other locales.
$cacheKey = 'site:help:community-guidelines:'.app()->getLocale();
return Cache::remember($cacheKey, now()->addMinutes(15), function () {
$slug = '/site/kb/community-guidelines';
$page = Page::whereSlug($slug)->whereActive(true)->first();

@ -76,7 +76,7 @@ class SpaController extends Controller
abort_unless($request->user(), 404);
$this->validate($request, [
'v' => 'required|in:0.1,0.2',
'l' => 'required|alpha_dash|max:5',
'l' => 'required|alpha_dash|max:12',
]);
$lang = $request->input('l');

@ -36,9 +36,10 @@ class UserEmailForgotController extends Controller
'username.exists' => 'This username is no longer active or does not exist!',
];
if ((bool) config_cache('captcha.enabled')) {
$rules['h-captcha-response'] = 'required|captcha';
$messages['h-captcha-response.required'] = 'You need to complete the captcha!';
if (app('captcha.manager')->activeOn('forgot_email')) {
$captchaField = app('captcha.manager')->active()->responseField();
$rules[$captchaField] = 'required|captcha_verify';
$messages[$captchaField.'.required'] = 'You need to complete the captcha!';
}
$randomDelay = random_int(500000, 2000000);

@ -37,6 +37,7 @@ use App\Observers\UserObserver;
use App\Policies\CustomFilterPolicy;
use App\Services\AccountService;
use App\Services\UserOidcService;
use App\Util\Localization\EmptyStrippingFileLoader;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Cache\RateLimiting\Limit;
@ -230,5 +231,15 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(UserOidcService::class, function () {
return UserOidcService::build();
});
// Swap the translation loader so empty (untranslated) strings are
// dropped at load time. This lets Laravel fall back to the fallback
// locale for partially-translated locales instead of rendering blanks.
$this->app->extend('translation.loader', function ($loader, $app) {
return new EmptyStrippingFileLoader(
$app['files'],
$app['path.lang']
);
});
}
}

@ -0,0 +1,56 @@
<?php
namespace App\Providers;
use App\Services\Captcha\CaptchaManager;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
class CaptchaServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton('captcha.manager', fn ($app) => new CaptchaManager($app));
$this->app->alias('captcha.manager', CaptchaManager::class);
}
public function boot(): void
{
$this->registerValidationRule();
$this->registerBladeDirectives();
}
/**
* Driver-agnostic validation rule.
*
* Usage: 'some_field' => 'captcha_verify'
*
* It ignores $value (each provider uses a different field name) and instead
* validates the whole request against the active driver's own response field.
*/
private function registerValidationRule(): void
{
Validator::extend('captcha_verify', function ($attribute, $value, $parameters, $validator) {
/** @var CaptchaManager $manager */
$manager = app('captcha.manager');
return $manager->active()->verify($validator->getData());
}, 'The captcha verification failed. Please try again.');
}
private function registerBladeDirectives(): void
{
// @captcha or @captcha(['data-theme' => 'dark']) -> renders active widget
Blade::directive('captcha', function ($expression) {
$args = trim((string) $expression) === '' ? '[]' : $expression;
return "<?php echo app('captcha.manager')->active()->render($args); ?>";
});
// @captchaScripts -> any <script>/<link> the active widget needs
Blade::directive('captchaScripts', function () {
return "<?php echo app('captcha.manager')->active()->scripts(); ?>";
});
}
}

@ -131,14 +131,40 @@ class AdminSettingsService
'allow_post_embeds' => (bool) config_cache('instance.embed.post'),
'allow_profile_embeds' => (bool) config_cache('instance.embed.profile'),
'captcha_enabled' => (bool) config_cache('captcha.enabled'),
'captcha_driver' => config_cache('captcha.driver') ?: config('captcha.driver', 'hcaptcha'),
'captcha_on_login' => (bool) config_cache('captcha.active.login'),
'captcha_on_register' => (bool) config_cache('captcha.active.register'),
'captcha_secret' => Str::mask(config_cache('captcha.secret'), '*', 4, -4),
'captcha_sitekey' => Str::mask(config_cache('captcha.sitekey'), '*', 4, -4),
'captcha_on_forgot_password' => (bool) config_cache('captcha.active.forgot_password'),
'captcha_on_password_reset' => (bool) config_cache('captcha.active.password_reset'),
'captcha_on_forgot_email' => (bool) config_cache('captcha.active.forgot_email'),
'captcha_on_curated_register' => (bool) config_cache('captcha.active.curated_register'),
'captcha_hcaptcha_secret' => self::maskSecret(config_cache('captcha.hcaptcha.secret')),
'captcha_hcaptcha_sitekey' => config_cache('captcha.hcaptcha.sitekey'),
'captcha_turnstile_secret' => self::maskSecret(config_cache('captcha.turnstile.secret')),
'captcha_turnstile_sitekey' => config_cache('captcha.turnstile.sitekey'),
'captcha_cap_endpoint' => config_cache('captcha.cap.endpoint'),
'captcha_cap_sitekey' => config_cache('captcha.cap.sitekey'),
'captcha_cap_secret' => self::maskSecret(config_cache('captcha.cap.secret')),
'custom_emoji_enabled' => (bool) config_cache('federation.custom_emoji.enabled'),
];
}
/**
* Mask a secret value for display, tolerating null/empty values.
*/
protected static function maskSecret($value): ?string
{
if (empty($value)) {
return $value === null ? null : (string) $value;
}
if (strlen((string) $value) < 8) {
return str_repeat('*', strlen((string) $value));
}
return Str::mask((string) $value, '*', 4, -4);
}
public static function getStorage()
{
$cloud_storage = (bool) config_cache('pixelfed.cloud_storage');
@ -179,7 +205,6 @@ class AdminSettingsService
$res = [
'enabled' => (bool) config_cache('instance.curated_registration.enabled'),
'resend_confirmation_limit' => config_cache('instance.curated_registration.resend_confirmation_limit'),
'captcha_enabled' => config_cache('instance.curated_registration.captcha_enabled'),
'state' => config_cache('instance.curated_registration.state'),
'notify' => config_cache('instance.curated_registration.notify'),
];

@ -0,0 +1,121 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Cap driver (self-hosted proof-of-work CAPTCHA).
*
* Verifies tokens against the Cap instance's /siteverify endpoint and renders
* the @cap.js/widget from the jsDelivr CDN.
*
* The full API endpoint the widget and verifier talk to is composed from a base
* URL (captcha.cap.endpoint) plus the site key (captcha.cap.sitekey):
*
* https://cap.example.com + 3c87a0e810 => https://cap.example.com/3c87a0e810/
*
* @see https://capjs.js.org/
*/
class CapDriver implements CaptchaDriver
{
/**
* Default @cap.js/widget version served from the CDN. "latest" tracks the
* newest stable release; override via captcha.cap.widget_version.
*/
private const DEFAULT_WIDGET_VERSION = 'latest';
public function name(): string
{
return 'cap';
}
public function isConfigured(): bool
{
return ! empty(config_cache('captcha.cap.endpoint'))
&& ! empty(config_cache('captcha.cap.sitekey'))
&& ! empty(config_cache('captcha.cap.secret'));
}
public function responseField(): string
{
return (string) config('captcha.cap.token_field', 'cap-token');
}
/**
* Compose the full Cap API endpoint: "{base}/{sitekey}/".
*
* The base URL is the instance origin without the site key. The site key is
* appended as a path segment with a trailing slash (required by Cap).
*/
public function apiEndpoint(): string
{
$base = rtrim(trim((string) config_cache('captcha.cap.endpoint')), '/');
$sitekey = trim((string) config_cache('captcha.cap.sitekey'), '/ ');
if ($base === '' || $sitekey === '') {
return '';
}
return $base.'/'.$sitekey.'/';
}
public function verify(array $input): bool
{
$token = $input[$this->responseField()] ?? null;
if (empty($token)) {
return false;
}
$endpoint = $this->apiEndpoint();
if ($endpoint === '') {
return false;
}
try {
$response = Http::asJson()
->timeout((int) config('captcha.cap.timeout', 5))
->post($endpoint.'siteverify', [
'secret' => config_cache('captcha.cap.secret'),
'response' => $token,
]);
} catch (\Throwable $e) {
Log::warning('[captcha:cap] verify request failed: '.$e->getMessage());
return (bool) config('captcha.cap.fail_open', false);
}
if ($response->failed()) {
return (bool) config('captcha.cap.fail_open', false);
}
return (bool) $response->json('success', false);
}
public function render(array $attributes = []): string
{
$endpoint = e($this->apiEndpoint());
$field = e($this->responseField());
$attrs = '';
foreach ($attributes as $key => $value) {
$attrs .= ' '.e($key).'="'.e($value).'"';
}
return '<cap-widget data-cap-api-endpoint="'.$endpoint.'"'
.' data-cap-hidden-field-name="'.$field.'"'.$attrs.'></cap-widget>';
}
public function scripts(): string
{
// Load the widget from the jsDelivr CDN. Defaults to the "latest"
// stable release; pin a specific version via captcha.cap.widget_version.
$version = trim((string) config('captcha.cap.widget_version')) ?: self::DEFAULT_WIDGET_VERSION;
$src = 'https://cdn.jsdelivr.net/npm/@cap.js/widget@'.$version;
return '<script src="'.e($src).'"></script>';
}
}

@ -0,0 +1,103 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Support\Manager;
/**
* Resolves the active captcha provider based on the "captcha.driver" config
* value and proxies the provider-agnostic operations to it.
*
* @method string name()
* @method bool isConfigured()
* @method string responseField()
* @method bool verify(array $input)
* @method string render(array $attributes = [])
* @method string scripts()
*/
class CaptchaManager extends Manager
{
/**
* The default driver name, resolved from config. Falls back to hcaptcha to
* preserve existing behavior for instances that never set captcha.driver.
*/
public function getDefaultDriver(): string
{
$driver = config_cache('captcha.driver') ?: config('captcha.driver');
return $driver ?: 'hcaptcha';
}
public function createHcaptchaDriver(): CaptchaDriver
{
return new HCaptchaDriver;
}
public function createTurnstileDriver(): CaptchaDriver
{
return new TurnstileDriver;
}
public function createCapDriver(): CaptchaDriver
{
return new CapDriver;
}
/**
* The active driver instance.
*/
public function active(): CaptchaDriver
{
return $this->driver();
}
/**
* Whether captcha is globally enabled for this instance.
*/
public function enabled(): bool
{
return (bool) config_cache('captcha.enabled');
}
/**
* Whether captcha should be enforced on a given surface.
*
* Requires the global toggle plus the per-surface "active" flag. Supported
* surfaces: login, register, forgot_password, password_reset,
* forgot_email, curated_register.
*/
public function activeOn(string $surface): bool
{
if (! $this->enabled()) {
return false;
}
return (bool) config_cache('captcha.active.'.$surface);
}
/**
* List of supported driver machine names.
*
* @return array<int, string>
*/
public function available(): array
{
return ['hcaptcha', 'turnstile', 'cap'];
}
/**
* Validation rules for the active driver, keyed by its response field.
*
* Merge the result into a controller's rule set to enforce captcha with
* whatever provider is currently selected.
*
* @return array<string, string>
*/
public function rules(): array
{
return [
$this->active()->responseField() => 'required|captcha_verify',
];
}
}

@ -0,0 +1,96 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* hCaptcha driver.
*
* Verifies tokens against api.hcaptcha.com/siteverify and renders the widget
* script from js.hcaptcha.com.
*
* @see https://docs.hcaptcha.com/
*/
class HCaptchaDriver implements CaptchaDriver
{
private const VERIFY_URL = 'https://api.hcaptcha.com/siteverify';
private const SCRIPT_URL = 'https://js.hcaptcha.com/1/api.js';
public function name(): string
{
return 'hcaptcha';
}
public function isConfigured(): bool
{
$secret = config_cache('captcha.hcaptcha.secret');
$sitekey = config_cache('captcha.hcaptcha.sitekey');
return ! empty($secret)
&& ! empty($sitekey)
&& $secret !== 'default_secret'
&& $sitekey !== 'default_sitekey';
}
public function responseField(): string
{
return 'h-captcha-response';
}
public function verify(array $input): bool
{
$token = $input[$this->responseField()] ?? null;
if (empty($token)) {
return false;
}
try {
$response = Http::asForm()
->timeout((int) config('captcha.hcaptcha.timeout', 5))
->post(self::VERIFY_URL, [
'secret' => config_cache('captcha.hcaptcha.secret'),
'response' => $token,
]);
} catch (\Throwable $e) {
Log::warning('[captcha:hcaptcha] verify request failed: '.$e->getMessage());
return (bool) config('captcha.hcaptcha.fail_open', false);
}
if ($response->failed()) {
return (bool) config('captcha.hcaptcha.fail_open', false);
}
return (bool) $response->json('success', false);
}
public function render(array $attributes = []): string
{
$sitekey = e((string) config_cache('captcha.hcaptcha.sitekey'));
$attrs = '';
foreach ($attributes as $key => $value) {
$attrs .= ' '.e($key).'="'.e($value).'"';
}
return '<div class="h-captcha" data-sitekey="'.$sitekey.'"'.$attrs.'></div>';
}
public function scripts(): string
{
$src = self::SCRIPT_URL;
// Localize the widget when a locale is configured.
$lang = config('captcha.hcaptcha.lang');
if (! empty($lang)) {
$src .= '?hl='.urlencode((string) $lang);
}
return '<script src="'.e($src).'" async defer></script>';
}
}

@ -0,0 +1,81 @@
<?php
namespace App\Services\Captcha;
use App\Contracts\CaptchaDriver;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Cloudflare Turnstile driver.
*
* @see https://developers.cloudflare.com/turnstile/
*/
class TurnstileDriver implements CaptchaDriver
{
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
private const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
public function name(): string
{
return 'turnstile';
}
public function isConfigured(): bool
{
return ! empty(config_cache('captcha.turnstile.secret'))
&& ! empty(config_cache('captcha.turnstile.sitekey'));
}
public function responseField(): string
{
return 'cf-turnstile-response';
}
public function verify(array $input): bool
{
$token = $input[$this->responseField()] ?? null;
if (empty($token)) {
return false;
}
try {
$response = Http::asForm()
->timeout((int) config('captcha.turnstile.timeout', 5))
->post(self::VERIFY_URL, [
'secret' => config_cache('captcha.turnstile.secret'),
'response' => $token,
]);
} catch (\Throwable $e) {
Log::warning('[captcha:turnstile] verify request failed: '.$e->getMessage());
return (bool) config('captcha.turnstile.fail_open', false);
}
if ($response->failed()) {
return (bool) config('captcha.turnstile.fail_open', false);
}
return (bool) $response->json('success', false);
}
public function render(array $attributes = []): string
{
$sitekey = e((string) config_cache('captcha.turnstile.sitekey'));
$attrs = '';
foreach ($attributes as $key => $value) {
$attrs .= ' '.e($key).'="'.e($value).'"';
}
return '<div class="cf-turnstile" data-sitekey="'.$sitekey.'"'.$attrs.'></div>';
}
public function scripts(): string
{
return '<link rel="preconnect" href="https://challenges.cloudflare.com" crossorigin>'
.'<script src="'.self::SCRIPT_URL.'" async defer></script>';
}
}

@ -16,8 +16,9 @@ class ConfigCacheService
'filesystems.disks.s3.secret',
'filesystems.disks.spaces.key',
'filesystems.disks.spaces.secret',
'captcha.secret',
'captcha.sitekey',
'captcha.hcaptcha.secret',
'captcha.turnstile.secret',
'captcha.cap.secret',
];
public static function get($key)
@ -103,12 +104,20 @@ class ConfigCacheService
'instance.embed.post',
'captcha.enabled',
'captcha.secret',
'captcha.sitekey',
'captcha.driver',
'captcha.hcaptcha.secret',
'captcha.hcaptcha.sitekey',
'captcha.turnstile.secret',
'captcha.turnstile.sitekey',
'captcha.cap.endpoint',
'captcha.cap.sitekey',
'captcha.cap.secret',
'captcha.active.login',
'captcha.active.register',
'captcha.triggers.login.enabled',
'captcha.triggers.login.attempts',
'captcha.active.forgot_password',
'captcha.active.password_reset',
'captcha.active.forgot_email',
'captcha.active.curated_register',
'federation.custom_emoji.enabled',
'pixelfed.optimize_image',

@ -26,13 +26,20 @@ class SnowflakeService
public static function next()
{
$seq = Cache::get('snowflake:seq');
if (! $seq) {
/*
* Atomically obtain the next sequence value. Cache::increment()
* returns the post-increment value, so each call gets a distinct seq.
* A previous version read the value with Cache::get() and only
* incremented the store, leaving the local $seq stale — the first two
* calls both used seq=1, so two IDs generated in the same millisecond
* with the same datacenter/worker collided (UNIQUE violation).
*/
$seq = Cache::increment('snowflake:seq');
if (! is_int($seq)) {
// Cache miss or non-numeric store value: (re)seed the counter.
Cache::put('snowflake:seq', 1);
$seq = 1;
} else {
Cache::increment('snowflake:seq');
}
if ($seq >= 4095) {

@ -162,9 +162,18 @@ abstract class Regex
// look-ahead capture here and don't append $after when we return.
$tmp['valid_mention_preceding_chars'] = '([^a-zA-Z0-9_!#\$%&*@\/]|^|(?:^|[^a-z0-9_+~.-])RT:?)';
$re['valid_mentions_or_lists'] = '/'.$tmp['valid_mention_preceding_chars'].'(['.$tmp['at_signs'].'])([\p{L}0-9_\-.]{1,20})((\/[a-z][a-z0-9_\-]{0,24})?(?=(.*|$))(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/iu';
$re['valid_reply'] = '/^(?:['.$tmp['spaces'].'])*['.$tmp['at_signs'].']([a-z0-9_\-.]{1,20})(?=(.*|$))/iu';
// Local part (the username before any @domain) capped at 64. Local
// Pixelfed usernames max at 30 (RegisterController: max:30), but a
// REMOTE handle's username comes from other software and can be longer;
// the profiles.username column stores the full "@user@domain" as a
// VARCHAR(255). A cap that is too small does not fail cleanly, it
// matches the first N chars and drops the "@domain" suffix, turning a
// remote mention into a broken local one (#7204). 64 comfortably covers
// remote usernames while still bounding the pattern. The trailing
// "@domain" group below is matched separately and is not length-capped.
$re['valid_mentions_or_lists'] = '/'.$tmp['valid_mention_preceding_chars'].'(['.$tmp['at_signs'].'])([\p{L}0-9_\-.]{1,64})((\/[a-z][a-z0-9_\-]{0,24})?(?=(.*|$))(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/iu';
$re['valid_reply'] = '/^(?:['.$tmp['spaces'].'])*['.$tmp['at_signs'].']([a-z0-9_\-.]{1,64})(?=(.*|$))/iu';
$re['end_mention_match'] = '/\A(?:['.$tmp['at_signs'].']|['.$tmp['latin_accents'].']|:\/\/)/iu';
// URL related hash regex collection

@ -0,0 +1,56 @@
<?php
namespace App\Util\Localization;
use Illuminate\Translation\FileLoader;
/**
* Translation loader that removes empty string values after loading.
*
* Crowdin exports untranslated keys as empty strings ('') rather than
* omitting them. Laravel's translator only falls back to the fallback
* locale when a key is entirely missing, not when it resolves to an empty
* string, so partially-translated locales would render blank labels.
*
* Stripping empty values here makes those keys "missing", which restores
* the expected fallback to the fallback locale (en-US).
*/
class EmptyStrippingFileLoader extends FileLoader
{
/**
* Load the messages for the given locale/group, minus empty strings.
*
* @param string $locale
* @param string $group
* @param string|null $namespace
* @return array
*/
public function load($locale, $group, $namespace = null)
{
$messages = parent::load($locale, $group, $namespace);
return $this->stripEmptyStrings($messages);
}
/**
* Recursively remove empty (or whitespace-only) string values.
*/
protected function stripEmptyStrings(array $messages): array
{
$result = [];
foreach ($messages as $key => $value) {
if (is_array($value)) {
$result[$key] = $this->stripEmptyStrings($value);
} elseif (is_string($value)) {
if (trim($value) !== '') {
$result[$key] = $value;
}
} else {
$result[$key] = $value;
}
}
return $result;
}
}

@ -3,16 +3,142 @@
namespace App\Util\Localization;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
class Localization
{
/**
* Legacy two-letter (and legacy region) language codes mapped to the
* current locale-coded folder names under lang/.
* Added September 2026 - DELETE AFTER COMMUNICATION WITH ADMINS
*
* @var array<string, string>
*/
const LEGACY_LOCALE_MAP = [
'af' => 'af-ZA',
'ar' => 'ar-SA',
'bn' => 'bn-BD',
'bs' => 'bs-BA',
'ca' => 'ca-ES',
'cs' => 'cs-CZ',
'cy' => 'cy-GB',
'da' => 'da-DK',
'de' => 'de-DE',
'el' => 'el-GR',
'en' => 'en-US',
'eo' => 'eo-UY',
'es' => 'es-ES',
'eu' => 'eu-ES',
'fa' => 'fa-IR',
'fi' => 'fi-FI',
'fr' => 'fr-FR',
'gd' => 'gd-GB',
'gl' => 'gl-ES',
'he' => 'he-IL',
'hi' => 'hi-IN',
'hr' => 'hr-HR',
'hu' => 'hu-HU',
'id' => 'id-ID',
'it' => 'it-IT',
'ja' => 'ja-JP',
'ko' => 'ko-KR',
'me' => 'me-ME',
'mk' => 'mk-MK',
'ms' => 'ms-MY',
'nl' => 'nl-NL',
'no' => 'no-NO',
'oc' => 'oc-FR',
'pl' => 'pl-PL',
'pt' => 'pt-PT',
'ro' => 'ro-RO',
'ru' => 'ru-RU',
'sk' => 'sk-SK',
'sr' => 'sr-CS',
'sv' => 'sv-SE',
'th' => 'th-TH',
'tr' => 'tr-TR',
'uk' => 'uk-UA',
'vi' => 'vi-VN',
'zh-cn' => 'zh-CN',
'zh-tw' => 'zh-TW',
];
/**
* Normalize a configured locale to a current locale-coded value.
*
* Empty input returns "en-US"; a known legacy short code is mapped;
* anything else (already-current or custom codes) is returned unchanged so
* the framework's own fallback_locale still applies. Always returns a
* plain string, so it is safe to use in config values that get cached.
*/
public static function normalizeLocale(?string $locale): string
{
$locale = is_string($locale) ? trim($locale) : '';
if ($locale === '') {
return 'en-US';
}
return self::LEGACY_LOCALE_MAP[strtolower($locale)] ?? $locale;
}
/**
* List of available UI language codes.
*
* Reads the static manifest generated by `php artisan i18n:export`
* (lang/locales.json) so the list is deterministic and never served from
* a runtime cache that can go stale. Falls back to scanning the lang/
* directory if the manifest is missing.
*
* @return array<int, string>
*/
public static function languages()
{
return Cache::remember('core:localization:languages', now()->addDays(1), function () {
$dir = lang_path();
return static::localesFromManifest() ?? static::localesFromScan();
}
/**
* Full locale metadata (code, name, nativeName) from the manifest,
* sorted by display name. Empty array if the manifest is missing.
*
* @return array<int, array{code: string, name: string, nativeName: string}>
*/
public static function locales(): array
{
$path = lang_path('locales.json');
if (! is_file($path)) {
return [];
}
$data = json_decode((string) file_get_contents($path), true);
return is_array($data) ? $data : [];
}
/**
* @return array<int, string>|null
*/
protected static function localesFromManifest(): ?array
{
$locales = static::locales();
if (empty($locales)) {
return null;
}
return array_values(array_filter(array_map(
fn ($l) => $l['code'] ?? null,
$locales
)));
}
/**
* @return array<int, string>
*/
protected static function localesFromScan(): array
{
$dir = lang_path();
return Arr::flatten(array_diff(scandir($dir), ['..', '.', 'vendor', '.DS_Store']));
});
return Arr::flatten(array_diff(scandir($dir), ['..', '.', 'vendor', '.DS_Store', 'locales.json']));
}
}

@ -1,11 +1,13 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\CaptchaServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\PassportServiceProvider;
return [
AppServiceProvider::class,
CaptchaServiceProvider::class,
HorizonServiceProvider::class,
PassportServiceProvider::class,
];

@ -16,7 +16,6 @@
"ext-redis": "*",
"bacon/bacon-qr-code": "^3.0",
"brick/math": "^0.14",
"buzz/laravel-h-captcha": "^1.0.4",
"guzzlehttp/guzzle": "^7.10",
"intervention/image-driver-vips": "^4.1",
"intervention/image-laravel": "^4.1",
@ -111,7 +110,14 @@
"test:filter": "./vendor/bin/pest --compact --filter",
"test:quick": "./vendor/bin/pest --compact",
"lint": "./vendor/bin/pint",
"lint:test": "./vendor/bin/pint --test"
"lint:test": "./vendor/bin/pint --test",
"translate": [
"@php artisan i18n:export"
],
"build": [
"npm ci",
"npm run production"
]
},
"config": {

62
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f850111c7826d7c8383045e89f52f480",
"content-hash": "d6359826d0784c33bb38684d5f85a05b",
"packages": [
{
"name": "aws/aws-crt-php",
@ -272,66 +272,6 @@
],
"time": "2026-02-10T14:33:43+00:00"
},
{
"name": "buzz/laravel-h-captcha",
"version": "v1.0.7",
"source": {
"type": "git",
"url": "https://github.com/thinhbuzz/laravel-h-captcha.git",
"reference": "94f1e092411ef25e326f524a2f63512ee94a8dc5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thinhbuzz/laravel-h-captcha/zipball/94f1e092411ef25e326f524a2f63512ee94a8dc5",
"reference": "94f1e092411ef25e326f524a2f63512ee94a8dc5",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "6.*|7.*",
"illuminate/support": "5.*|6.*|7.*|8.*|9.*|10.*|11.*|12.*|^13.0",
"php": ">=5.4.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Captcha": "Buzz\\LaravelHCaptcha\\CaptchaFacade"
},
"providers": [
"Buzz\\LaravelHCaptcha\\CaptchaServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Buzz\\LaravelHCaptcha\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "ThinhBuzz",
"email": "mr.thinhbuzz@gmail.com",
"homepage": "https://www.facebook.com/thinh.buzz"
}
],
"description": "hCaptcha for Laravel",
"homepage": "https://github.com/thinhbuzz/laravel-h-captcha",
"keywords": [
"captcha",
"h-captcha",
"hcaptcha",
"laravel"
],
"support": {
"issues": "https://github.com/thinhbuzz/laravel-h-captcha/issues",
"source": "https://github.com/thinhbuzz/laravel-h-captcha/tree/v1.0.7"
},
"time": "2026-08-06T14:58:56+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
"version": "3.2.1",

@ -1,6 +1,7 @@
<?php
use App\Util\Lexer\PrettyNumber;
use App\Util\Localization\Localization;
use Illuminate\Support\Facades\Facade;
return [
@ -74,7 +75,7 @@ return [
|
*/
'locale' => env('APP_LOCALE', 'en'),
'locale' => Localization::normalizeLocale(env('APP_LOCALE', 'en-US')),
/*
|--------------------------------------------------------------------------
@ -87,7 +88,7 @@ return [
|
*/
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'fallback_locale' => Localization::normalizeLocale(env('APP_FALLBACK_LOCALE', 'en-US')),
/*
|--------------------------------------------------------------------------
@ -162,3 +163,73 @@ return [
])->toArray(),
];
/*
| Legacy two-letter language codes mapped to the current
| locale-coded folder names under lang/. Replace 'de' with 'de-DE'.
|
| Added Sep 2026 - DELETE AFTER COMMUNICATION WITH ADMINS
*/
$pixelfedLegacyLocaleMap = [
'af' => 'af-ZA',
'ar' => 'ar-SA',
'bn' => 'bn-BD',
'bs' => 'bs-BA',
'ca' => 'ca-ES',
'cs' => 'cs-CZ',
'cy' => 'cy-GB',
'da' => 'da-DK',
'de' => 'de-DE',
'el' => 'el-GR',
'en' => 'en-US',
'eo' => 'eo-UY',
'es' => 'es-ES',
'eu' => 'eu-ES',
'fa' => 'fa-IR',
'fi' => 'fi-FI',
'fr' => 'fr-FR',
'gd' => 'gd-GB',
'gl' => 'gl-ES',
'he' => 'he-IL',
'hi' => 'hi-IN',
'hr' => 'hr-HR',
'hu' => 'hu-HU',
'id' => 'id-ID',
'it' => 'it-IT',
'ja' => 'ja-JP',
'ko' => 'ko-KR',
'me' => 'me-ME',
'mk' => 'mk-MK',
'ms' => 'ms-MY',
'nl' => 'nl-NL',
'no' => 'no-NO',
'oc' => 'oc-FR',
'pl' => 'pl-PL',
'pt' => 'pt-PT',
'ro' => 'ro-RO',
'ru' => 'ru-RU',
'sk' => 'sk-SK',
'sr' => 'sr-CS',
'sv' => 'sv-SE',
'th' => 'th-TH',
'tr' => 'tr-TR',
'uk' => 'uk-UA',
'vi' => 'vi-VN',
'zh-cn' => 'zh-CN',
'zh-tw' => 'zh-TW',
];
if (! function_exists('pixelfed_normalize_locale')) {
function pixelfed_normalize_locale(array $map, ?string $locale): string
{
$locale = is_string($locale) ? trim($locale) : '';
if ($locale === '') {
return 'en-US';
}
$lower = strtolower($locale);
return $map[$lower] ?? $locale;
}
}

@ -1,29 +1,77 @@
<?php
use Buzz\LaravelHCaptcha\HttpClient;
return [
/*
|--------------------------------------------------------------------------
| Global toggle
|--------------------------------------------------------------------------
| Whether any captcha is enabled at all. Kept for backward compatibility.
*/
'enabled' => env('CAPTCHA_ENABLED', false),
'secret' => env('CAPTCHA_SECRET', 'default_secret'),
'sitekey' => env('CAPTCHA_SITEKEY', 'default_sitekey'),
'http_client' => HttpClient::class,
'options' => [
'multiple' => false,
'lang' => app()->getLocale(),
/*
|--------------------------------------------------------------------------
| Active driver
|--------------------------------------------------------------------------
| Which provider to use: "hcaptcha", "turnstile", or "cap". Admin-selectable.
| Defaults to hcaptcha so existing instances keep their current behavior.
*/
'driver' => env('CAPTCHA_DRIVER', 'hcaptcha'),
/*
|--------------------------------------------------------------------------
| hCaptcha
|--------------------------------------------------------------------------
*/
'hcaptcha' => [
'secret' => env('CAPTCHA_H_SECRET', 'default_secret'),
'sitekey' => env('CAPTCHA_H_SITEKEY', 'default_sitekey'),
'timeout' => (int) env('CAPTCHA_H_TIMEOUT', 5),
'fail_open' => (bool) env('CAPTCHA_H_FAIL_OPEN', false),
'lang' => env('CAPTCHA_H_LANG'), // Optional widget locale (e.g. "fr"). Null uses hCaptcha auto-detection.
],
'attributes' => [
'theme' => 'light',
/*
|--------------------------------------------------------------------------
| Cloudflare Turnstile
|--------------------------------------------------------------------------
*/
'turnstile' => [
'sitekey' => env('CAPTCHA_TURNSTILE_SITEKEY'),
'secret' => env('CAPTCHA_TURNSTILE_SECRET'),
'timeout' => (int) env('CAPTCHA_TURNSTILE_TIMEOUT', 5),
'fail_open' => (bool) env('CAPTCHA_TURNSTILE_FAIL_OPEN', false),
],
'active' => [
'login' => env('CAPTCHA_ENABLED_ON_LOGIN', false),
'register' => env('CAPTCHA_ENABLED_ON_REGISTER', false),
/*
|--------------------------------------------------------------------------
| Cap
|--------------------------------------------------------------------------
| The endpoint is the instance base URL WITHOUT the site key, e.g.
| https://cap.example.com. The site key/secret are separate values;
*/
'cap' => [
'endpoint' => env('CAPTCHA_CAP_ENDPOINT'),
'sitekey' => env('CAPTCHA_CAP_SITEKEY'),
'secret' => env('CAPTCHA_CAP_SECRET'),
'token_field' => env('CAPTCHA_CAP_TOKEN_FIELD', 'cap-token'),
'timeout' => (int) env('CAPTCHA_CAP_TIMEOUT', 5),
'fail_open' => (bool) env('CAPTCHA_CAP_FAIL_OPEN', false),
'widget_version' => env('CAPTCHA_CAP_WIDGET_VERSION') ?: 'latest',
],
'triggers' => [
'login' => [
'enabled' => env('CAPTCHA_TRIGGERS_LOGIN_ENABLED', false),
'attempts' => env('CAPTCHA_TRIGGERS_LOGIN_ATTEMPTS', 2),
],
/*
|--------------------------------------------------------------------------
| Where captcha is active
|--------------------------------------------------------------------------
| Per-surface toggles. Each requires the global "enabled" flag to also be on.
*/
'active' => [
'login' => env('CAPTCHA_ENABLED_ON_LOGIN', true),
'register' => env('CAPTCHA_ENABLED_ON_REGISTER', true),
'curated_register' => env('CAPTCHA_ENABLED_ON_CURATED_REGISTER', true),
'forgot_email' => env('CAPTCHA_ENABLED_ON_FORGOT_EMAIL', true),
'forgot_password' => env('CAPTCHA_ENABLED_ON_FORGOT_PASSWORD', true),
'password_reset' => env('CAPTCHA_ENABLED_ON_PASSWORD_RESET', true),
],
];

@ -151,8 +151,6 @@ return [
'resend_confirmation_limit' => env('INSTANCE_CUR_REG_RESEND_LIMIT', 5),
'captcha_enabled' => env('INSTANCE_CUR_REG_CAPTCHA', env('CAPTCHA_ENABLED', false)),
'state' => [
'fallback_on_closed_reg' => true,
'only_enabled_on_closed_reg' => env('INSTANCE_CUR_REG_STATE_ONLY_ON_CLOSED', true),

@ -3,96 +3,77 @@ api_token_env: CROWDIN_PERSONAL_TOKEN
base_path: '.'
preserve_hierarchy: true
commit_message: '[ci skip]'
# Map Crowdin's non-standard locale codes to clean folder names. Avoids the
# en-PT ↔ English (Portugal) BCP-47 collision and the fake tlh-AA region.
# Applied per file entry below via the *locale_mapping anchor.
# en-PT (Pirate English) -> en-x-pirate (BCP-47 private-use subtag)
# tlh-AA (Klingon) -> tlh (tlh is valid BCP-47; AA is a fake region)
files:
- source: /lang/en/auth.php
translation: /lang/%two_letters_code%/auth.php
- source: /lang/en-US/auth.php
translation: /lang/%locale%/auth.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/exception.php
translation: /lang/%two_letters_code%/exception.php
languages_mapping: &locale_mapping
locale:
en-PT: en-x-pirate
tlh-AA: tlh
- source: /lang/en-US/exception.php
translation: /lang/%locale%/exception.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/helpcenter.php
translation: /lang/%two_letters_code%/helpcenter.php
languages_mapping: *locale_mapping
- source: /lang/en-US/helpcenter.php
translation: /lang/%locale%/helpcenter.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/navmenu.php
translation: /lang/%two_letters_code%/navmenu.php
languages_mapping: *locale_mapping
- source: /lang/en-US/navmenu.php
translation: /lang/%locale%/navmenu.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/notification.php
translation: /lang/%two_letters_code%/notification.php
languages_mapping: *locale_mapping
- source: /lang/en-US/notification.php
translation: /lang/%locale%/notification.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
# - source: /lang/en/pagination.php
# translation: /lang/%two_letters_code%/pagination.php
# languages_mapping:
# two_letters_code:
# zh-CN: zh-cn
# zh-TW: zh-tw
- source: /lang/en/passwords.php
translation: /lang/%two_letters_code%/passwords.php
languages_mapping: *locale_mapping
# - source: /lang/en-US/pagination.php
# translation: /lang/%locale%/pagination.php
# skip_untranslated_strings: true
# languages_mapping: *locale_mapping
- source: /lang/en-US/passwords.php
translation: /lang/%locale%/passwords.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/profile.php
translation: /lang/%two_letters_code%/profile.php
languages_mapping: *locale_mapping
- source: /lang/en-US/profile.php
translation: /lang/%locale%/profile.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/settings.php
translation: /lang/%two_letters_code%/settings.php
languages_mapping: *locale_mapping
- source: /lang/en-US/settings.php
translation: /lang/%locale%/settings.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/site.php
translation: /lang/%two_letters_code%/site.php
languages_mapping: *locale_mapping
- source: /lang/en-US/site.php
translation: /lang/%locale%/site.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
- source: /lang/en/timeline.php
translation: /lang/%two_letters_code%/timeline.php
languages_mapping: *locale_mapping
- source: /lang/en-US/timeline.php
translation: /lang/%locale%/timeline.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
# - source: /lang/en/validation.php
# translation: /lang/%two_letters_code%/validation.php
languages_mapping: *locale_mapping
# - source: /lang/en-US/validation.php
# translation: /lang/%locale%/validation.php
# skip_untranslated_strings: true
# languages_mapping:
# two_letters_code:
# zh-CN: zh-cn
# zh-TW: zh-tw
- source: /lang/en/web.php
translation: /lang/%two_letters_code%/web.php
# languages_mapping: *locale_mapping
- source: /lang/en-US/web.php
translation: /lang/%locale%/web.php
skip_untranslated_strings: true
languages_mapping:
two_letters_code:
zh-CN: zh-cn
zh-TW: zh-tw
languages_mapping: *locale_mapping

@ -0,0 +1,411 @@
<?php
return [
'common' => [
'comment' => '',
'commented' => '',
'comments' => '',
'like' => '',
'liked' => '',
'likes' => '',
'share' => '',
'shared' => '',
'shares' => '',
'unshare' => '',
'bookmark' => '',
'cancel' => '',
'copyLink' => '',
'delete' => '',
'error' => '',
'errorMsg' => '',
'oops' => '',
'other' => '',
'readMore' => '',
'success' => '',
'proceed' => '',
'next' => '',
'close' => '',
'clickHere' => '',
'sensitive' => '',
'sensitiveContent' => '',
'sensitiveContentWarning' => '',
'javascript' => '',
'loading' => '',
'continue' => '',
],
'site' => [
'terms' => '',
'privacy' => '',
],
'navmenu' => [
'search' => '',
'admin' => '',
// Timelines
'homeFeed' => '',
'localFeed' => '',
'globalFeed' => '',
// Core features
'discover' => '',
'directMessages' => '',
'notifications' => '',
'groups' => '',
'stories' => '',
// Self links
'profile' => '',
'drive' => '',
'settings' => '',
'appearance' => '',
'compose' => '',
'logout' => '',
'createStory' => '',
// Nav footer
'about' => '',
'help' => '',
'language' => '',
'privacy' => '',
'terms' => '',
'legalNotice' => '',
'mobileApps' => '',
// Temporary links
'backToPreviousDesign' => '',
],
'directMessages' => [
'inbox' => '',
'sent' => '',
'requests' => '',
],
'notifications' => [
'title' => '',
'liked' => '',
'commented' => '',
'reacted' => '',
'shared' => '',
'tagged' => '',
'updatedA' => '',
'sentA' => '',
'followed' => '',
'mentioned' => '',
'you' => '',
'yourApplication' => '',
'applicationApproved' => '',
'applicationRejected' => '',
'dm' => '',
'groupPost' => '',
'modlog' => '',
'post' => '',
'story' => '',
'noneFound' => '',
'youRecent' => '',
'hasUnlisted' => '',
'cannotDisplay' => '',
'followRequest' => '',
'filteringResults' => '',
'mentions' => '',
'mentionsDescription' => '',
'likes' => '',
'likesDescription' => '',
'followers' => '',
'followersDescription' => '',
'reblogs' => '',
'reblogsDescription' => '',
'dms' => '',
'dmsDescription' => '',
'accept' => '',
'reject' => '',
],
'post' => [
'shareToFollowers' => '',
'shareToOther' => '',
'noLikes' => '',
'uploading' => '',
],
'profile' => [
'posts' => '',
'followers' => '',
'following' => '',
'admin' => '',
'collections' => '',
'follow' => '',
'unfollow' => '',
'editProfile' => '',
'followRequested' => '',
'joined' => '',
'emptyCollections' => '',
'emptyPosts' => '',
'blocking' => '',
'sponsor' => '',
'followYou' => '',
'archives' => '',
'bookmarks' => '',
'likes' => '',
'muted' => '',
'blocked' => '',
'myPortifolio' => '',
'private' => '',
'public' => '',
'draft' => '',
'emptyLikes' => '',
'emptyBookmarks' => '',
'emptyArchives' => '',
'untitled' => '',
'noDescription' => '',
],
'menu' => [
'viewPost' => '',
'viewProfile' => '',
'moderationTools' => '',
'report' => '',
'archive' => '',
'unarchive' => '',
'embed' => '',
'selectOneOption' => '',
'unlistFromTimelines' => '',
'addCW' => '',
'removeCW' => '',
'markAsSpammer' => '',
'markAsSpammerText' => '',
'spam' => '',
'sensitive' => '',
'abusive' => '',
'underageAccount' => '',
'copyrightInfringement' => '',
'impersonation' => '',
'scamOrFraud' => '',
'confirmReport' => '',
'confirmReportText' => '',
'reportSent' => '',
'reportSentText' => '',
'reportSentError' => '',
'modAddCWConfirm' => '',
'modCWSuccess' => '',
'modRemoveCWConfirm' => '',
'modRemoveCWSuccess' => '',
'modUnlistConfirm' => '',
'modUnlistSuccess' => '',
'modMarkAsSpammerConfirm' => '',
'modMarkAsSpammerSuccess' => '',
'toFollowers' => '',
'showCaption' => '',
'showLikes' => '',
'compactMode' => '',
'embedConfirmText' => '',
'deletePostConfirm' => '',
'archivePostConfirm' => '',
'unarchivePostConfirm' => '',
'pin' => '',
'unpin' => '',
'pinPostConfirm' => '',
'unpinPostConfirm' => '',
],
'story' => [
'add' => '',
'myStory' => '',
'viewMyStory' => '',
'goBack' => '',
'delete' => '',
'crop' => '',
'error' => '',
'cropping' => '',
'storyDuration' => '',
'seconds' => '',
'processing' => '',
'shareWithFollowers' => '',
'cancel' => '',
'viewedBy' => '',
'next' => '',
'zoom' => '',
'options' => '',
'allowReplies' => '',
'allowReactions' => '',
'limit' => '',
'reactionSent' => '',
'replySent' => '',
'expiresIn' => '',
'viewers' => '',
'report' => '',
'close' => '',
'myStories' => '',
'seeAll' => '',
],
'timeline' => [
'peopleYouMayKnow' => '',
'onboarding' => [
'welcome' => '',
'thisIsYourHomeFeed' => '',
'letUsHelpYouFind' => '',
'refreshFeed' => '',
],
],
'hashtags' => [
'emptyFeed' => '',
],
'report' => [
'report' => '',
'selectReason' => '',
'reported' => '',
'sendingReport' => '',
'thanksMsg' => '',
'contactAdminMsg' => '',
],
'appearance' => [
'theme' => '',
'profileLayout' => '',
'compactPreviews' => '',
'loadComments' => '',
'hideStats' => '',
'auto' => '',
'lightMode' => '',
'darkMode' => '',
'grid' => '',
'masonry' => '',
'feed' => '',
],
'settings' => [
'filters' => [
'title' => '',
'manage_your_custom_filters' => '',
'customize_your_experience' => '',
'add_new_filter' => '',
'limit_message' => '',
'learn_more_help_center' => '',
'no_filters' => '',
'no_filters_message' => '',
'create_first_filter' => '',
'no_matching_filters' => '',
'no_matching_filters_message' => '',
'create_new_filter' => '',
'filter_title' => '',
'edit_filter' => '',
'advance_mode' => '',
'simple_mode' => '',
'keywords' => '',
'legend' => '',
'whole_word' => '',
'partial_word' => '',
'duplicate_not_allowed' => '',
'filter_action' => '',
'hide_media_blur' => '',
'show_warning' => '',
'hide_content_completely' => '',
'apply_filters_to' => '',
'home_timeline' => '',
'notifications' => '',
'public_timeline' => '',
'hashtags' => '',
'groups' => '',
'conversations' => '',
'duration' => '',
'forever' => '',
'30_minutes' => '',
'1_hour' => '',
'6_hours' => '',
'12_hours' => '',
'1_day' => '',
'1_week' => '',
'cutom' => '',
'enter_duration_in_seconds' => '',
'save_changes' => '',
'create_filter' => '',
'name_your_filter' => '',
'give_your_filter_a_name' => '',
'my_filter_name' => '',
'filter_duration' => '',
'add_filter_keywords' => '',
'add_word_or_phrase' => '',
'whole_word_match' => '',
'partial_word_match' => '',
'add_another_keyword' => '',
'please_remove_duplicate_keywords' => '',
'choose_filter_action' => '',
'choose_filter_action_description' => '',
'hide_completely' => '',
'choose_where_to_apply' => '',
'choose_where_to_apply_description' => '',
'review_your_filter' => '',
'review_your_filter_description' => '',
'no_keywords_specified' => '',
'action' => '',
'expires' => '',
'never_expires' => '',
'titleAdvance' => '',
'context' => '',
'review' => '',
'add_keyword' => '',
'enter_filter_title' => '',
],
],
'powered_by_pixelfed' => '',
'landing' => [
'login' => '',
'signup' => '',
'about' => '',
'directory' => '',
'explore' => '',
'decentralized_by_pixelfed' => '',
'posts' => '',
'active_users' => '',
'total_users' => '',
'managed_by' => '',
'server_rules' => '',
'supported_features' => '',
'features' => [
'photo_posts' => '',
'photo_albums' => '',
'photo_filters' => '',
'collections' => '',
'comments' => '',
'hashtags' => '',
'likes' => '',
'notifications' => '',
'shares' => '',
'share_up_to_n_photos' => '',
'share_up_to_n_photos_videos' => '',
'file_size' => '',
'federation' => '',
'mobile_app' => '',
'stories' => '',
'videos' => '',
],
'discover_accounts' => '',
'nothing_to_show' => '',
'explore_trending' => '',
],
];

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save