mirror of https://github.com/pixelfed/pixelfed
Merge pull request #7314 from pixelfed/staging
Staging - Fixing language, Captcha Agnostic, and bugspull/7362/head^2
commit
68c8dbb1dc
@ -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
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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(); ?>";
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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>';
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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),
|
||||
],
|
||||
];
|
||||
|
||||
@ -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…
Reference in New Issue