mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-22 03:46:43 +02:00
feat: captcha on email input
This commit is contained in:
@@ -83,6 +83,27 @@ donutbrowser/
|
||||
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
|
||||
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
|
||||
|
||||
## Publishing Linux Repositories
|
||||
|
||||
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$(pwd):/work" -w /work --env-file .env -e GH_TOKEN="$(gh auth token)" \
|
||||
ubuntu:24.04 bash -c '
|
||||
export DEBIAN_FRONTEND=noninteractive &&
|
||||
apt-get update -qq > /dev/null 2>&1 &&
|
||||
apt-get install -y -qq dpkg-dev createrepo-c gzip curl python3-pip > /dev/null 2>&1 &&
|
||||
pip3 install --break-system-packages awscli > /dev/null 2>&1 &&
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null &&
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list &&
|
||||
apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq gh > /dev/null 2>&1 &&
|
||||
bash scripts/publish-repo.sh v0.18.1'
|
||||
```
|
||||
|
||||
The `.github/workflows/publish-repos.yml` workflow runs automatically after stable releases and can also be triggered manually via `gh workflow run publish-repos.yml -f tag=v0.18.1`.
|
||||
|
||||
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
|
||||
|
||||
## Proprietary Changes
|
||||
|
||||
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
|
||||
|
||||
@@ -362,12 +362,12 @@ impl CloudAuthManager {
|
||||
|
||||
// --- API methods ---
|
||||
|
||||
pub async fn request_otp(&self, email: &str) -> Result<String, String> {
|
||||
pub async fn request_otp(&self, email: &str, captcha_token: &str) -> Result<String, String> {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/otp/request");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "email": email }))
|
||||
.json(&serde_json::json!({ "email": email, "captchaToken": captcha_token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to request OTP: {e}"))?;
|
||||
@@ -1100,8 +1100,8 @@ impl CloudAuthManager {
|
||||
// --- Tauri commands ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_request_otp(email: String) -> Result<String, String> {
|
||||
CLOUD_AUTH.request_otp(&email).await
|
||||
pub async fn cloud_request_otp(email: String, captcha_token: String) -> Result<String, String> {
|
||||
CLOUD_AUTH.request_otp(&email, &captcha_token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -27,6 +27,33 @@ import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE ?? "";
|
||||
|
||||
interface TurnstileWindow extends Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
container: string | HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
"expired-callback": () => void;
|
||||
"error-callback": () => void;
|
||||
theme: "light" | "dark" | "auto";
|
||||
},
|
||||
) => string;
|
||||
remove: (widgetId: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
// RFC 5322 compliant email regex (emailregex.com)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const EMAIL_REGEX =
|
||||
/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/;
|
||||
|
||||
function isValidEmail(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email);
|
||||
}
|
||||
|
||||
interface SyncConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: (loginOccurred?: boolean) => void;
|
||||
@@ -66,6 +93,13 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const [isSendingCode, setIsSendingCode] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
// Turnstile captcha state
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
const [isCaptchaLoading, setIsCaptchaLoading] = useState(false);
|
||||
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
const turnstileScriptLoadedRef = useRef(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||
|
||||
@@ -101,6 +135,111 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
}
|
||||
}, [testConnection]);
|
||||
|
||||
const removeTurnstileWidget = useCallback(() => {
|
||||
const win = window as TurnstileWindow;
|
||||
if (turnstileWidgetIdRef.current && win.turnstile) {
|
||||
win.turnstile.remove(turnstileWidgetIdRef.current);
|
||||
turnstileWidgetIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderTurnstile = useCallback(() => {
|
||||
const win = window as TurnstileWindow;
|
||||
if (!win.turnstile || !captchaContainerRef.current) return;
|
||||
|
||||
removeTurnstileWidget();
|
||||
captchaContainerRef.current.innerHTML = "";
|
||||
|
||||
const widgetId = win.turnstile.render(captchaContainerRef.current, {
|
||||
sitekey: TURNSTILE_SITE_KEY,
|
||||
callback: (token: string) => {
|
||||
setCaptchaToken(token);
|
||||
setIsCaptchaLoading(false);
|
||||
},
|
||||
"expired-callback": () => {
|
||||
setCaptchaToken(null);
|
||||
},
|
||||
"error-callback": () => {
|
||||
setCaptchaToken(null);
|
||||
setIsCaptchaLoading(false);
|
||||
},
|
||||
theme: "auto",
|
||||
});
|
||||
turnstileWidgetIdRef.current = widgetId;
|
||||
}, [removeTurnstileWidget]);
|
||||
|
||||
const loadTurnstileScript = useCallback((): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
const win = window as TurnstileWindow;
|
||||
if (win.turnstile) {
|
||||
turnstileScriptLoadedRef.current = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (turnstileScriptLoadedRef.current) {
|
||||
const check = setInterval(() => {
|
||||
if ((window as TurnstileWindow).turnstile) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = document.querySelector(
|
||||
'script[src*="challenges.cloudflare.com/turnstile"]',
|
||||
);
|
||||
if (existing) {
|
||||
const check = setInterval(() => {
|
||||
if ((window as TurnstileWindow).turnstile) {
|
||||
clearInterval(check);
|
||||
turnstileScriptLoadedRef.current = true;
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
turnstileScriptLoadedRef.current = true;
|
||||
const script = document.createElement("script");
|
||||
script.src =
|
||||
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
const check = setInterval(() => {
|
||||
if ((window as TurnstileWindow).turnstile) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const emailValid = isValidEmail(email);
|
||||
if (emailValid && !codeSent && TURNSTILE_SITE_KEY) {
|
||||
setIsCaptchaLoading(true);
|
||||
setCaptchaToken(null);
|
||||
void loadTurnstileScript().then(() => {
|
||||
renderTurnstile();
|
||||
});
|
||||
} else {
|
||||
removeTurnstileWidget();
|
||||
setCaptchaToken(null);
|
||||
setIsCaptchaLoading(false);
|
||||
}
|
||||
}, [
|
||||
email,
|
||||
codeSent,
|
||||
loadTurnstileScript,
|
||||
renderTurnstile,
|
||||
removeTurnstileWidget,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setConnectionStatus("unknown");
|
||||
@@ -108,13 +247,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setCodeSent(false);
|
||||
setOtpCode("");
|
||||
setEmail("");
|
||||
setCaptchaToken(null);
|
||||
setIsCaptchaLoading(false);
|
||||
removeTurnstileWidget();
|
||||
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
||||
.then(setLiveProxyUsage)
|
||||
.catch(() => {
|
||||
setLiveProxyUsage(null);
|
||||
});
|
||||
}
|
||||
}, [isOpen, loadSettings]);
|
||||
return () => {
|
||||
removeTurnstileWidget();
|
||||
};
|
||||
}, [isOpen, loadSettings, removeTurnstileWidget]);
|
||||
|
||||
// Auto-select the appropriate tab based on connection state
|
||||
useEffect(() => {
|
||||
@@ -201,11 +346,13 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
}, []);
|
||||
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) return;
|
||||
if (!email || !captchaToken) return;
|
||||
setIsSendingCode(true);
|
||||
try {
|
||||
await requestOtp(email);
|
||||
await requestOtp(email, captchaToken);
|
||||
setCodeSent(true);
|
||||
removeTurnstileWidget();
|
||||
setCaptchaToken(null);
|
||||
showSuccessToast(t("sync.cloud.codeSent"));
|
||||
} catch (error) {
|
||||
console.error("Failed to send OTP:", error);
|
||||
@@ -213,7 +360,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
} finally {
|
||||
setIsSendingCode(false);
|
||||
}
|
||||
}, [email, requestOtp, t]);
|
||||
}, [email, captchaToken, requestOtp, removeTurnstileWidget, t]);
|
||||
|
||||
const handleVerifyOtp = useCallback(async () => {
|
||||
if (!email || !otpCode) return;
|
||||
@@ -392,7 +539,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !codeSent) {
|
||||
if (e.key === "Enter" && !codeSent && captchaToken) {
|
||||
void handleSendCode();
|
||||
}
|
||||
}}
|
||||
@@ -400,12 +547,24 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
<LoadingButton
|
||||
onClick={() => void handleSendCode()}
|
||||
isLoading={isSendingCode}
|
||||
disabled={!email || codeSent}
|
||||
disabled={!email || codeSent || !captchaToken}
|
||||
variant="outline"
|
||||
>
|
||||
{t("sync.cloud.sendCode")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
{!codeSent && isValidEmail(email) && TURNSTILE_SITE_KEY && (
|
||||
<div className="mt-2">
|
||||
{isCaptchaLoading && (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
{t("sync.cloud.loadingCaptcha")}
|
||||
</div>
|
||||
)}
|
||||
<div ref={captchaContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{codeSent && (
|
||||
|
||||
@@ -7,7 +7,7 @@ interface UseCloudAuthReturn {
|
||||
user: CloudUser | null;
|
||||
isLoggedIn: boolean;
|
||||
isLoading: boolean;
|
||||
requestOtp: (email: string) => Promise<string>;
|
||||
requestOtp: (email: string, captchaToken: string) => Promise<string>;
|
||||
verifyOtp: (email: string, code: string) => Promise<CloudAuthState>;
|
||||
logout: () => Promise<void>;
|
||||
refreshProfile: () => Promise<CloudUser>;
|
||||
@@ -50,9 +50,12 @@ export function useCloudAuth(): UseCloudAuthReturn {
|
||||
};
|
||||
}, [loadUser]);
|
||||
|
||||
const requestOtp = useCallback((email: string): Promise<string> => {
|
||||
return invoke<string>("cloud_request_otp", { email });
|
||||
}, []);
|
||||
const requestOtp = useCallback(
|
||||
(email: string, captchaToken: string): Promise<string> => {
|
||||
return invoke<string>("cloud_request_otp", { email, captchaToken });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const verifyOtp = useCallback(
|
||||
async (email: string, code: string): Promise<CloudAuthState> => {
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"logout": "Log Out",
|
||||
"logoutConfirm": "Are you sure you want to log out? Cloud sync will stop.",
|
||||
"loginSuccess": "Successfully logged in!",
|
||||
"logoutSuccess": "Successfully logged out."
|
||||
"logoutSuccess": "Successfully logged out.",
|
||||
"loadingCaptcha": "Loading captcha..."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"logout": "Cerrar Sesión",
|
||||
"logoutConfirm": "¿Estás seguro de que deseas cerrar sesión? La sincronización en la nube se detendrá.",
|
||||
"loginSuccess": "¡Sesión iniciada exitosamente!",
|
||||
"logoutSuccess": "Sesión cerrada exitosamente."
|
||||
"logoutSuccess": "Sesión cerrada exitosamente.",
|
||||
"loadingCaptcha": "Cargando captcha..."
|
||||
},
|
||||
"team": {
|
||||
"title": "Equipo",
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"logout": "Se Déconnecter",
|
||||
"logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ? La synchronisation cloud sera arrêtée.",
|
||||
"loginSuccess": "Connexion réussie !",
|
||||
"logoutSuccess": "Déconnexion réussie."
|
||||
"logoutSuccess": "Déconnexion réussie.",
|
||||
"loadingCaptcha": "Chargement du captcha..."
|
||||
},
|
||||
"team": {
|
||||
"title": "Équipe",
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"logout": "ログアウト",
|
||||
"logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。",
|
||||
"loginSuccess": "ログインに成功しました!",
|
||||
"logoutSuccess": "ログアウトしました。"
|
||||
"logoutSuccess": "ログアウトしました。",
|
||||
"loadingCaptcha": "キャプチャを読み込み中..."
|
||||
},
|
||||
"team": {
|
||||
"title": "チーム",
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"logout": "Sair",
|
||||
"logoutConfirm": "Tem certeza de que deseja sair? A sincronização na nuvem será interrompida.",
|
||||
"loginSuccess": "Login realizado com sucesso!",
|
||||
"logoutSuccess": "Logout realizado com sucesso."
|
||||
"logoutSuccess": "Logout realizado com sucesso.",
|
||||
"loadingCaptcha": "Carregando captcha..."
|
||||
},
|
||||
"team": {
|
||||
"title": "Equipe",
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"logout": "Выйти",
|
||||
"logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.",
|
||||
"loginSuccess": "Вход выполнен успешно!",
|
||||
"logoutSuccess": "Выход выполнен успешно."
|
||||
"logoutSuccess": "Выход выполнен успешно.",
|
||||
"loadingCaptcha": "Загрузка капчи..."
|
||||
},
|
||||
"team": {
|
||||
"title": "Команда",
|
||||
|
||||
@@ -384,7 +384,8 @@
|
||||
"logout": "退出登录",
|
||||
"logoutConfirm": "您确定要退出登录吗?云同步将会停止。",
|
||||
"loginSuccess": "登录成功!",
|
||||
"logoutSuccess": "已成功退出登录。"
|
||||
"logoutSuccess": "已成功退出登录。",
|
||||
"loadingCaptcha": "正在加载验证码..."
|
||||
},
|
||||
"team": {
|
||||
"title": "团队",
|
||||
|
||||
Reference in New Issue
Block a user