Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions database/migrations/2026_10_23_000001_create_captcha_settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

use Spatie\LaravelSettings\Migrations\SettingsMigration;

return new class extends SettingsMigration
{
/**
* Run the migrations.
*/
public function up(): void
{
rescue(fn () => $this->migrator->add('captcha.provider', 'none'));
rescue(fn () => $this->migrator->add('captcha.site_key'));
rescue(fn () => $this->migrator->addEncrypted('captcha.secret_key'));
rescue(fn () => $this->migrator->add('captcha.altcha_complexity', 50000));
}
};
1 change: 1 addition & 0 deletions public/build/assets/altcha-DuVHT5LC.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions public/build/assets/cachet-DHCAi8ZN.js

This file was deleted.

5 changes: 5 additions & 0 deletions public/build/assets/cachet-tmFzj8iP.js

Large diffs are not rendered by default.

7 changes: 0 additions & 7 deletions public/build/assets/metrics-0rXi1BiZ.js

This file was deleted.

7 changes: 7 additions & 0 deletions public/build/assets/metrics-BtN9hG-b.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions public/build/assets/theme-CVytCKmM.css

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions public/build/assets/theme-DuFjw7tD.css

This file was deleted.

20 changes: 15 additions & 5 deletions public/build/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"resources/css/cachet.css": {
"file": "assets/cachet-Bx3l3HHD.css",
"file": "assets/cachet-BNNjAaz0.css",
"name": "cachet",
"names": [
"cachet.css"
Expand All @@ -9,25 +9,35 @@
"isEntry": true
},
"resources/css/dashboard/theme.css": {
"file": "assets/theme-DuFjw7tD.css",
"file": "assets/theme-CVytCKmM.css",
"name": "theme",
"names": [
"theme.css"
],
"src": "resources/css/dashboard/theme.css",
"isEntry": true
},
"resources/js/altcha.js": {
"file": "assets/altcha-DuVHT5LC.js",
"name": "altcha",
"src": "resources/js/altcha.js",
"isDynamicEntry": true,
"imports": [
"resources/js/cachet.js"
]
},
"resources/js/cachet.js": {
"file": "assets/cachet-DHCAi8ZN.js",
"file": "assets/cachet-tmFzj8iP.js",
"name": "cachet",
"src": "resources/js/cachet.js",
"isEntry": true,
"dynamicImports": [
"resources/js/metrics.js"
"resources/js/metrics.js",
"resources/js/altcha.js"
]
},
"resources/js/metrics.js": {
"file": "assets/metrics-0rXi1BiZ.js",
"file": "assets/metrics-BtN9hG-b.js",
"name": "metrics",
"src": "resources/js/metrics.js",
"isDynamicEntry": true
Expand Down
155 changes: 155 additions & 0 deletions resources/js/altcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import Alpine from 'alpinejs'

const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])

/**
* Plain JavaScript SHA-256, used when Web Crypto is unavailable (pages served over plain HTTP).
*/
export function sha256Fallback(text) {
const bytes = new TextEncoder().encode(text)
const length = Math.ceil((bytes.length + 9) / 64) * 64
const data = new Uint8Array(length)
data.set(bytes)
data[bytes.length] = 0x80

const view = new DataView(data.buffer)
view.setUint32(length - 8, Math.floor((bytes.length * 8) / 0x100000000))
view.setUint32(length - 4, (bytes.length * 8) >>> 0)

const hash = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
])
const w = new Uint32Array(64)
const rotr = (x, n) => (x >>> n) | (x << (32 - n))

for (let offset = 0; offset < length; offset += 64) {
for (let i = 0; i < 16; i++) {
w[i] = view.getUint32(offset + i * 4)
}

for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)
w[i] = w[i - 16] + s0 + w[i - 7] + s1
}

let [a, b, c, d, e, f, g, h] = hash

for (let i = 0; i < 64; i++) {
const t1 = h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]
const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))
h = g
g = f
f = e
e = (d + t1) >>> 0
d = c
c = b
b = a
a = (t1 + t2) >>> 0
}

hash[0] += a
hash[1] += b
hash[2] += c
hash[3] += d
hash[4] += e
hash[5] += f
hash[6] += g
hash[7] += h
}

return Array.from(hash, (word) => word.toString(16).padStart(8, '0')).join('')
}

async function sha256(text) {
if (!globalThis.crypto?.subtle) {
return sha256Fallback(text)
}

const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))

return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
}

/**
* Solves an ALTCHA proof-of-work challenge and keeps the form's submit buttons
* disabled until a solution is ready.
*/
Alpine.data('cachetAltcha', (challengeUrl) => ({
state: 'verifying',
payload: '',

init() {
const form = this.$el.closest('form')

form?.addEventListener('submit', (event) => {
if (this.state !== 'verified') {
event.preventDefault()
}
})

this.$watch('state', () => this.toggleButtons(form))
this.toggleButtons(form)
this.run()
},

toggleButtons(form) {
form?.querySelectorAll('[type="submit"]').forEach((button) => {
button.disabled = this.state !== 'verified'
})
},

async run() {
this.state = 'verifying'

try {
const expires = await this.solve()

this.state = 'verified'

// Fetch a fresh challenge shortly before this one expires.
setTimeout(() => this.run(), Math.max(expires * 1000 - Date.now() - 15000, 15000))
} catch {
this.state = 'error'
}
},

async solve() {
const response = await fetch(challengeUrl, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
})

if (!response.ok) {
throw new Error('Unable to load the challenge.')
}

const challenge = await response.json()

for (let number = 0; number <= challenge.maxnumber; number++) {
if ((await sha256(challenge.salt + number)) === challenge.challenge) {
this.payload = btoa(
JSON.stringify({
algorithm: challenge.algorithm,
challenge: challenge.challenge,
number,
salt: challenge.salt,
signature: challenge.signature,
}),
)

return Number(new URLSearchParams(challenge.salt.split('?')[1]).get('expires'))
}
}

throw new Error('Unable to solve the challenge.')
},
}))
4 changes: 4 additions & 0 deletions resources/js/cachet.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ async function startAlpine() {
await import('./metrics.js')
}

if (document.querySelector('[data-captcha="altcha"]')) {
await import('./altcha.js')
}

Alpine.start()
}

Expand Down
1 change: 1 addition & 0 deletions resources/lang/en/navigation.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
'label' => 'Settings',
'items' => [
'manage_cachet' => 'Manage Cachet',
'manage_captcha' => 'Manage Captcha',
'manage_customization' => 'Manage Customization',
'manage_localization' => 'Manage Localization',
'manage_notifications' => 'Manage Notifications',
Expand Down
25 changes: 25 additions & 0 deletions resources/lang/en/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@
'show_timezone_helper' => 'Show the active timezone next to dates on the status page.',
],
],
'manage_captcha' => [
'section_title' => 'Captcha',
'section_description' => 'Protect the public subscribe form from bots and from being used to send confirmation emails to addresses that didn\'t ask for them.',
'provider_label' => 'Provider',
'providers' => [
'none' => 'None',
'altcha' => 'ALTCHA (self-hosted)',
'turnstile' => 'Cloudflare Turnstile',
'hcaptcha' => 'hCaptcha',
'recaptcha' => 'Google reCAPTCHA v2',
],
'provider_helpers' => [
'none' => 'The subscribe form is only rate limited.',
'altcha' => 'Visitors\' browsers solve a small proof-of-work puzzle. No external service, cookies or keys are needed.',
'turnstile' => 'Visitors\' browsers load a script from Cloudflare, and Cachet must be able to reach challenges.cloudflare.com to verify responses.',
'hcaptcha' => 'Visitors\' browsers load a script from hCaptcha, and Cachet must be able to reach api.hcaptcha.com to verify responses.',
'recaptcha' => 'Visitors\' browsers load a script from Google, and Cachet must be able to reach www.google.com to verify responses.',
],
'site_key_label' => 'Site key',
'secret_key_label' => 'Secret key',
'keys_helper' => 'Create the keys at :url.',
'altcha_complexity_label' => 'Complexity',
'altcha_complexity_helper' => 'The highest number the browser may have to try. Higher values slow down bots but also visitors on slow devices.',
],

'manage_notifications' => [
'subscriptions_section_title' => 'Subscriptions',
'allow_subscribers_label' => 'Allow subscriptions',
Expand Down
7 changes: 7 additions & 0 deletions resources/lang/en/subscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@
'email_label' => 'Email address',
'email_placeholder' => 'you@example.com',
'subscribe_button' => 'Subscribe',
'captcha' => [
'verifying' => 'Checking that you\'re not a robot…',
'verified' => 'Verified. You can subscribe now.',
'error' => 'We couldn\'t verify your browser. Reload the page and try again.',
'noscript' => 'JavaScript is required to subscribe.',
'failed' => 'We couldn\'t verify that you\'re not a robot. Please try again.',
],
],
'mail' => [
'unsubscribe' => 'Unsubscribe',
Expand Down
1 change: 1 addition & 0 deletions resources/lang/pt_BR/navigation.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
'manage_cachet' => 'Gerenciar Cachet',
'manage_customization' => 'Gerenciar Personalização',
'manage_localization' => 'Gerenciar Localização',
'manage_captcha' => 'Gerenciar Captcha',
'manage_theme' => 'Gerenciar Tema',
'manage_api_keys' => 'Gerenciar Chaves de API',
'manage_webhooks' => 'Gerenciar Webhooks',
Expand Down
25 changes: 25 additions & 0 deletions resources/lang/pt_BR/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@
'show_timezone_helper' => 'Mostra o fuso horário ativo ao lado das datas na página de status.',
],
],
'manage_captcha' => [
'section_title' => 'Captcha',
'section_description' => 'Proteja o formulário público de inscrição contra bots e contra o envio de emails de confirmação para endereços que não os pediram.',
'provider_label' => 'Serviço',
'providers' => [
'none' => 'Nenhum',
'altcha' => 'ALTCHA (auto-hospedado)',
'turnstile' => 'Cloudflare Turnstile',
'hcaptcha' => 'hCaptcha',
'recaptcha' => 'Google reCAPTCHA v2',
],
'provider_helpers' => [
'none' => 'O formulário de inscrição tem apenas limite de pedidos.',
'altcha' => 'O navegador do visitante resolve um pequeno desafio de prova de trabalho. Não precisa de serviço externo, cookies nem chaves.',
'turnstile' => 'O navegador do visitante carrega um script da Cloudflare, e o Cachet precisa de acesso a challenges.cloudflare.com para validar as respostas.',
'hcaptcha' => 'O navegador do visitante carrega um script do hCaptcha, e o Cachet precisa de acesso a api.hcaptcha.com para validar as respostas.',
'recaptcha' => 'O navegador do visitante carrega um script do Google, e o Cachet precisa de acesso a www.google.com para validar as respostas.',
],
'site_key_label' => 'Chave do site',
'secret_key_label' => 'Chave secreta',
'keys_helper' => 'Crie as chaves em :url.',
'altcha_complexity_label' => 'Complexidade',
'altcha_complexity_helper' => 'O número máximo que o navegador pode ter de testar. Valores mais altos atrasam os bots, mas também os visitantes em dispositivos lentos.',
],

'manage_theme' => [
'app_banner_label' => 'Imagem do Banner',
'app_banner_helper' => 'Uma imagem exibida no topo da página de status.',
Expand Down
7 changes: 7 additions & 0 deletions resources/lang/pt_BR/subscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,12 @@
'subscribe' => [
'button_label' => 'Inscrever-se',
],
'captcha' => [
'verifying' => 'Verificando que você não é um robô…',
'verified' => 'Verificado. Você já pode se inscrever.',
'error' => 'Não foi possível verificar o seu navegador. Recarregue a página e tente novamente.',
'noscript' => 'É preciso JavaScript para se inscrever.',
'failed' => 'Não foi possível verificar que você não é um robô. Tente novamente.',
],
],
];
13 changes: 13 additions & 0 deletions resources/views/captcha/altcha.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div
data-captcha="altcha"
x-data="cachetAltcha(@js($challengeUrl))"
x-bind:data-state="state"
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-500 ring-1 ring-inset ring-zinc-300 dark:text-zinc-400 dark:ring-zinc-700"
>
<input type="hidden" name="altcha" x-bind:value="payload" />
<x-heroicon-o-shield-check class="size-5 shrink-0" aria-hidden="true" />
<span x-show="state === 'verifying'" role="status" x-cloak>{{ __('cachet::subscriber.status_page.captcha.verifying') }}</span>
<span x-show="state === 'verified'" role="status" x-cloak>{{ __('cachet::subscriber.status_page.captcha.verified') }}</span>
<span x-show="state === 'error'" role="alert" x-cloak>{{ __('cachet::subscriber.status_page.captcha.error') }}</span>
<noscript>{{ __('cachet::subscriber.status_page.captcha.noscript') }}</noscript>
</div>
2 changes: 2 additions & 0 deletions resources/views/captcha/hcaptcha.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<div class="h-captcha" data-sitekey="{{ $siteKey }}"></div>
<script src="https://js.hcaptcha.com/1/api.js?hl={{ urlencode($locale) }}" async defer></script>
2 changes: 2 additions & 0 deletions resources/views/captcha/recaptcha.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<div class="g-recaptcha" data-sitekey="{{ $siteKey }}"></div>
<script src="https://www.google.com/recaptcha/api.js?hl={{ urlencode($locale) }}" async defer></script>
2 changes: 2 additions & 0 deletions resources/views/captcha/turnstile.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<div class="cf-turnstile" data-sitekey="{{ $siteKey }}" data-language="{{ $locale }}" data-theme="auto"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
11 changes: 11 additions & 0 deletions resources/views/components/captcha.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@inject('captcha', 'Cachet\Captcha\CaptchaManager')

@if ($captcha->driver()->enabled())
<div data-slot="captcha" class="flex flex-col gap-1.5">
{{ $captcha->driver()->render() }}

@error('captcha')
<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
@endif
4 changes: 3 additions & 1 deletion resources/views/status-page/subscribe.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ class="w-full rounded-md border-0 bg-white px-3 py-2 text-sm text-zinc-900 shado
@enderror
</div>

<button type="submit" class="inline-flex w-full items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-foreground shadow-sm ring-1 ring-accent/30 transition hover:opacity-90">
<x-cachet::captcha />

<button type="submit" class="inline-flex w-full items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-foreground shadow-sm ring-1 ring-accent/30 transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50">
{{ __('cachet::subscriber.status_page.subscribe_button') }}
</button>
</form>
Expand Down
Loading
Loading