diff --git a/AGENTS.md b/AGENTS.md index b47f2d3..0cc5718 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 000a7c3..e1f1c76 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -362,12 +362,12 @@ impl CloudAuthManager { // --- API methods --- - pub async fn request_otp(&self, email: &str) -> Result { + pub async fn request_otp(&self, email: &str, captcha_token: &str) -> Result { 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 { - CLOUD_AUTH.request_otp(&email).await +pub async fn cloud_request_otp(email: String, captcha_token: String) -> Result { + CLOUD_AUTH.request_otp(&email, &captcha_token).await } #[tauri::command] diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index 7ae097d..281d0ed 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -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(null); + const [isCaptchaLoading, setIsCaptchaLoading] = useState(false); + const captchaContainerRef = useRef(null); + const turnstileWidgetIdRef = useRef(null); + const turnstileScriptLoadedRef = useRef(false); + const [activeTab, setActiveTab] = useState("cloud"); const [, setLiveProxyUsage] = useState(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 => { + 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("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) { void handleSendCode()} isLoading={isSendingCode} - disabled={!email || codeSent} + disabled={!email || codeSent || !captchaToken} variant="outline" > {t("sync.cloud.sendCode")} + + {!codeSent && isValidEmail(email) && TURNSTILE_SITE_KEY && ( +
+ {isCaptchaLoading && ( +
+
+ {t("sync.cloud.loadingCaptcha")} +
+ )} +
+
+ )}
{codeSent && ( diff --git a/src/hooks/use-cloud-auth.ts b/src/hooks/use-cloud-auth.ts index a0deb76..d77f7eb 100644 --- a/src/hooks/use-cloud-auth.ts +++ b/src/hooks/use-cloud-auth.ts @@ -7,7 +7,7 @@ interface UseCloudAuthReturn { user: CloudUser | null; isLoggedIn: boolean; isLoading: boolean; - requestOtp: (email: string) => Promise; + requestOtp: (email: string, captchaToken: string) => Promise; verifyOtp: (email: string, code: string) => Promise; logout: () => Promise; refreshProfile: () => Promise; @@ -50,9 +50,12 @@ export function useCloudAuth(): UseCloudAuthReturn { }; }, [loadUser]); - const requestOtp = useCallback((email: string): Promise => { - return invoke("cloud_request_otp", { email }); - }, []); + const requestOtp = useCallback( + (email: string, captchaToken: string): Promise => { + return invoke("cloud_request_otp", { email, captchaToken }); + }, + [], + ); const verifyOtp = useCallback( async (email: string, code: string): Promise => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ab2e4e0..3073ad8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index af7e1ac..a90c79b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 7ee46d3..8978abf 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 213b398..2c840d7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -384,7 +384,8 @@ "logout": "ログアウト", "logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。", "loginSuccess": "ログインに成功しました!", - "logoutSuccess": "ログアウトしました。" + "logoutSuccess": "ログアウトしました。", + "loadingCaptcha": "キャプチャを読み込み中..." }, "team": { "title": "チーム", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index fbe3b6e..9d8a4c7 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c2d9d98..9f0bc71 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -384,7 +384,8 @@ "logout": "Выйти", "logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.", "loginSuccess": "Вход выполнен успешно!", - "logoutSuccess": "Выход выполнен успешно." + "logoutSuccess": "Выход выполнен успешно.", + "loadingCaptcha": "Загрузка капчи..." }, "team": { "title": "Команда", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1228d99..c0d3a4b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -384,7 +384,8 @@ "logout": "退出登录", "logoutConfirm": "您确定要退出登录吗?云同步将会停止。", "loginSuccess": "登录成功!", - "logoutSuccess": "已成功退出登录。" + "logoutSuccess": "已成功退出登录。", + "loadingCaptcha": "正在加载验证码..." }, "team": { "title": "团队",