Skip to content
Merged
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
16 changes: 15 additions & 1 deletion src/cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ async function sendLoginOtp(email) {
});
}

async function checkAuthEmail(email) {
if (!supabase) return { data: null, error: new Error("Cloud authentication is unavailable") };
return supabase.functions.invoke("check-auth-email", { body: { email } });
}

function passwordPromptStorageKey() {
return session?.user?.id ? `${PASSWORD_PROMPT_KEY}:${session.user.id}` : PASSWORD_PROMPT_KEY;
}
Expand Down Expand Up @@ -298,7 +303,7 @@ function renderPasswordEditor({ mode = "setup" } = {}) {
function renderPasswordRecoveryRequest(prefillEmail = "") {
panel.innerHTML = `
<div class="cloud-heading"><h2>${icon("cloud")} 找回密码</h2><button class="icon-button" type="button" data-recovery-back aria-label="返回登录">${icon("close")}</button></div>
<p>输入账号邮箱,我们会发送密码重设邮件。为了保护账号,无论邮箱是否存在,页面都会显示相同结果。</p>
<p>输入账号邮箱,我们会先确认账号是否存在,再发送密码重设邮件。</p>
<form id="passwordRecoveryForm">
<label class="cloud-field">账号邮箱<input name="email" type="email" autocomplete="email" required value="${escapeHtml(prefillEmail)}" placeholder="parent@example.com"></label>
<div class="cloud-actions"><button class="cloud-action" type="submit">发送重设邮件</button><button class="cloud-action secondary" type="button" data-recovery-cancel>返回登录</button></div>
Expand All @@ -314,6 +319,15 @@ function renderPasswordRecoveryRequest(prefillEmail = "") {
const submitButton = form.querySelector("[type=submit]");
await runLockedAction(submitButton, async () => {
const email = String(new FormData(form).get("email") || "").trim();
const { data: emailStatus, error: lookupError } = await checkAuthEmail(email);
if (lookupError || typeof emailStatus?.registered !== "boolean") {
showToast("暂时无法确认该邮箱,请稍后再试。", 6000);
return;
}
if (!emailStatus.registered) {
showToast("该邮箱尚未注册,请先注册账号或使用邮箱验证码登录。", 6000);
return;
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: window.location.origin,
});
Expand Down
24 changes: 21 additions & 3 deletions supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -249,17 +249,29 @@ otp_expiry = 3600
# content_path = "./supabase/templates/invite.html"

[auth.email.template.confirmation]
subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}登录验证{{ end }} · 注册验证码'
subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 注册验证码'
content_path = "./supabase/templates/confirmation.html"

[auth.email.template.magic_link]
subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}登录验证{{ end }} · 登录验证码'
subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 登录验证码'
content_path = "./supabase/templates/magic_link.html"

[auth.email.template.recovery]
subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}Shadow Nexus{{ end }} · 重设密码'
subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 重设密码'
content_path = "./supabase/templates/recovery.html"

[auth.email.template.email_change]
subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 邮箱变更'
content_path = "./supabase/templates/email_change.html"

[auth.email.template.invite]
subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 邀请'
content_path = "./supabase/templates/invite.html"

[auth.email.template.reauthentication]
subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 安全确认'
content_path = "./supabase/templates/reauthentication.html"

# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
Expand Down Expand Up @@ -435,3 +447,9 @@ entrypoint = "./functions/delete-account/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/delete-account/*.html" ]

[functions.check-auth-email]
enabled = true
verify_jwt = false
import_map = "./functions/check-auth-email/deno.json"
entrypoint = "./functions/check-auth-email/index.ts"
5 changes: 5 additions & 0 deletions supabase/functions/check-auth-email/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"imports": {
"@supabase/supabase-js": "npm:@supabase/supabase-js@2.111.0"
}
}
101 changes: 101 additions & 0 deletions supabase/functions/check-auth-email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createClient } from "@supabase/supabase-js";

const corsHeaders = {
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
};
const MAX_REQUESTS_PER_MINUTE = 10;
const MAX_EMAIL_LENGTH = 320;
const requestBuckets = new Map<string, { count: number; resetAt: number }>();

function jsonResponse(body: Record<string, unknown>, status = 200) {
return new Response(JSON.stringify(body), { status, headers: corsHeaders });
}

function readSecret(name: string, fallbackName: string) {
const raw = Deno.env.get(name) || Deno.env.get(fallbackName);
if (!raw) return null;

try {
const parsed = JSON.parse(raw) as Record<string, string>;
return parsed.default || Object.values(parsed)[0] || null;
} catch {
return raw;
}
}

function requestKey(request: Request) {
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip")
|| "unknown";
}

function isRateLimited(request: Request) {
const now = Date.now();
const key = requestKey(request);
const current = requestBuckets.get(key);
if (!current || current.resetAt <= now) {
requestBuckets.set(key, { count: 1, resetAt: now + 60_000 });
return false;
}
current.count += 1;
return current.count > MAX_REQUESTS_PER_MINUTE;
}

function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}

async function checkAuthEmail(request: Request) {
if (request.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (request.method !== "POST") {
return jsonResponse({ code: "method_not_allowed" }, 405);
}
if (isRateLimited(request)) {
return jsonResponse({ code: "rate_limited" }, 429);
}

let payload: { email?: unknown };
try {
payload = await request.json();
} catch {
return jsonResponse({ code: "invalid_request" }, 400);
}

if (typeof payload.email !== "string" || payload.email.length > MAX_EMAIL_LENGTH) {
return jsonResponse({ code: "invalid_email" }, 400);
}

const email = normalizeEmail(payload.email);
if (!/^\S+@\S+\.\S+$/.test(email)) {
return jsonResponse({ code: "invalid_email" }, 400);
}

const supabaseUrl = Deno.env.get("SUPABASE_URL");
const secretKey = readSecret("SUPABASE_SECRET_KEYS", "SUPABASE_SERVICE_ROLE_KEY");
if (!supabaseUrl || !secretKey) {
return jsonResponse({ code: "auth_lookup_unavailable" }, 503);
}

const admin = createClient(supabaseUrl, secretKey, {
auth: { autoRefreshToken: false, persistSession: false },
});

for (let page = 1; ; page += 1) {
const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 1000 });
if (error) {
return jsonResponse({ code: "auth_lookup_unavailable" }, 503);
}

const users = data.users || [];
const registered = users.some((user) => normalizeEmail(user.email || "") === email);
if (registered) return jsonResponse({ registered: true });
if (users.length < 1000) return jsonResponse({ registered: false });
}
}

Deno.serve(checkAuthEmail);
Loading
Loading