git
This commit is contained in:
50
public/offline.html
Normal file
50
public/offline.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>مدستاگرام</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Tahoma, sans-serif;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #0e0e0e; color: #f5f5f5; }
|
||||
}
|
||||
img { width: 88px; height: 88px; border-radius: 22px; }
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 700; }
|
||||
p { margin: 0; font-size: 14px; opacity: 0.65; max-width: 320px; line-height: 1.7; }
|
||||
button {
|
||||
margin-top: 8px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 12px 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #FF107D;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active { opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/images/icons/192x192.png" alt="مدستاگرام" />
|
||||
<h1>اتصال برقرار نمیباشد</h1>
|
||||
<p>به نظر میرسد به اینترنت وصل نیستید. اتصال خود را بررسی کنید و دوباره تلاش کنید.</p>
|
||||
<button onclick="location.reload()">تلاش دوباره</button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -43,10 +43,11 @@ self.addEventListener("notificationclick", (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
const CACHE_NAME = "pwa-cache-v11";
|
||||
const CACHE_NAME = "pwa-cache-v12";
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
"/",
|
||||
"/offline.html",
|
||||
"/manifest.webmanifest",
|
||||
"/manifest.json",
|
||||
"/images/icons/192x192.png",
|
||||
@@ -82,7 +83,9 @@ self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(fetch(event.request));
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match("/offline.html"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -655,6 +655,7 @@ useEffect(() => {
|
||||
key={selectedVideo?._id || ""}
|
||||
className="w-full aspect-video"
|
||||
controls
|
||||
playsInline
|
||||
controlsList="nodownload noplaybackrate"
|
||||
onContextMenu={preventDownload}
|
||||
onKeyDown={preventDownload}
|
||||
|
||||
@@ -5,9 +5,17 @@ import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import EditPageHeader from "@/components/settings/EditPageHeader";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isAndroidDevice } from "@/lib/pwa/install";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { ANDROID_APP_VERSION } from "@/lib/pwaConfig";
|
||||
|
||||
// عین PwaInstallPrompt.tsx: مسیر مستقیمِ فایل APK که بکاند بدون روتِ جدید سرو میکنه
|
||||
const ANDROID_APK_URL = buildStorageUrl("/downloads/modstagram.apk");
|
||||
|
||||
function AboutUs() {
|
||||
const { t } = useTranslation("common");
|
||||
// فقط اندروید — طبق خواستهی صریح کاربر توی iOS این دکمه نمایش داده نشه
|
||||
const isAndroid = isAndroidDevice();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -23,12 +31,26 @@ function AboutUs() {
|
||||
<span>02167586</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
<div className="w-full flex flex-col items-center gap-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
{isAndroid ? (
|
||||
<a href={ANDROID_APK_URL} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.downloadApp")}</span>
|
||||
</RoundedDiv>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{isAndroid ? (
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t("aboutPage.appVersion", { version: ANDROID_APP_VERSION })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -618,6 +618,7 @@ select {
|
||||
.header-offline {
|
||||
background: linear-gradient(180deg, rgba(220, 38, 38, 0.92) 0%, rgba(185, 28, 28, 0.88) 100%) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.header-syncing .brand-logo {
|
||||
opacity: 0;
|
||||
@@ -631,6 +632,7 @@ select {
|
||||
.tab-active-glass {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
|
||||
creator: "Modstagram",
|
||||
manifest: "/manifest.webmanifest",
|
||||
applicationName: site.name,
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
|
||||
@@ -67,8 +68,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"theme-color": "#FF107D",
|
||||
"mobile-web-app-capable": "yes",
|
||||
"msapplication-TileColor": "#FF107D",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
|
||||
import { chatHeaderCircleBtn, chatHeaderInfoPill, chatHeaderGlassStyle } from "@/components/chat/ChatHeader";
|
||||
import VideoMessageBubble from "@/components/chat/VideoMessageBubble";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import {
|
||||
@@ -58,6 +58,7 @@ export default function ShopChatPage() {
|
||||
const params = useParams<{ shopId: string; buyerId: string }>();
|
||||
const { shopId, buyerId } = params;
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [messages, setMessages] = useState<ShopChatMessage[] | null>(null);
|
||||
const [counterpart, setCounterpart] = useState<Counterpart | null>(null);
|
||||
@@ -162,12 +163,15 @@ export default function ShopChatPage() {
|
||||
dir="rtl"
|
||||
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
|
||||
>
|
||||
<BackButton
|
||||
tinted
|
||||
iconSize={22}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className={chatHeaderCircleBtn}
|
||||
style={chatHeaderGlassStyle}
|
||||
/>
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
|
||||
<div dir="ltr" className={chatHeaderInfoPill} style={chatHeaderGlassStyle}>
|
||||
<div dir="rtl" className="min-w-0 flex-1 text-right">
|
||||
@@ -291,7 +295,10 @@ export default function ShopChatPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<div
|
||||
className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2"
|
||||
style={glassBackdropStyle("glass-chat-input")}
|
||||
>
|
||||
<textarea
|
||||
rows={1}
|
||||
value={text}
|
||||
|
||||
@@ -407,6 +407,7 @@ export default function UserSettingsPage() {
|
||||
isOpen={resetConfirmOpen}
|
||||
onClose={() => setResetConfirmOpen(false)}
|
||||
height="fit"
|
||||
elevated
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
<p className="font-bold">{t("settings.resetAlgorithm")}</p>
|
||||
|
||||
166
src/components/AnalyticsTracker.tsx
Normal file
166
src/components/AnalyticsTracker.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { parseUserAgent } from "@/lib/analytics/parseUserAgent";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
const SESSION_KEY = "modstagram_analytics_session_id";
|
||||
const FLUSH_INTERVAL_MS = 8000;
|
||||
|
||||
type AnalyticsEvent = {
|
||||
sessionId: string;
|
||||
type: "session_start" | "page_view" | "click";
|
||||
path?: string | null;
|
||||
durationMs?: number | null;
|
||||
label?: string | null;
|
||||
platform: "web";
|
||||
device: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
};
|
||||
|
||||
function getSessionId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
let id = window.sessionStorage.getItem(SESSION_KEY);
|
||||
if (!id) {
|
||||
id =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
window.sessionStorage.setItem(SESSION_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// نزدیکترین عنصر قابلکلیکِ معنادار (دکمه/لینک) از روی target — تا کلیکهای بیربط
|
||||
// (مثلاً یه <div> بینام) ثبت نشه و لیبل خوانا داشته باشیم
|
||||
function findClickLabel(target: EventTarget | null): string | null {
|
||||
let el = target as HTMLElement | null;
|
||||
let depth = 0;
|
||||
while (el && depth < 6) {
|
||||
if (
|
||||
el.tagName === "BUTTON" ||
|
||||
el.tagName === "A" ||
|
||||
el.getAttribute("role") === "button"
|
||||
) {
|
||||
const explicit = el.getAttribute("data-analytics-id");
|
||||
if (explicit) return explicit;
|
||||
const aria = el.getAttribute("aria-label");
|
||||
if (aria) return aria;
|
||||
const text = el.textContent?.trim().slice(0, 60);
|
||||
if (text) return text;
|
||||
if (el.tagName === "A") {
|
||||
const href = el.getAttribute("href");
|
||||
if (href) return `link:${href.slice(0, 60)}`;
|
||||
}
|
||||
return el.tagName.toLowerCase();
|
||||
}
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AnalyticsTracker() {
|
||||
const pathname = usePathname();
|
||||
const sessionIdRef = useRef<string>("");
|
||||
const uaRef = useRef({ device: "Desktop", browser: "Other", os: "Other" });
|
||||
const queueRef = useRef<AnalyticsEvent[]>([]);
|
||||
const currentPathRef = useRef<string | null>(null);
|
||||
const pageEnteredAtRef = useRef<number>(0);
|
||||
|
||||
const flush = (useBeacon = false) => {
|
||||
if (!queueRef.current.length) return;
|
||||
const events = queueRef.current;
|
||||
queueRef.current = [];
|
||||
const url = `${getApiBaseUrl()}/analytics/track`;
|
||||
const body = JSON.stringify({ events });
|
||||
|
||||
if (useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
navigator.sendBeacon(url, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const token =
|
||||
typeof document !== "undefined"
|
||||
? document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith("token="))
|
||||
?.split("=")[1]
|
||||
: null;
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const enqueue = (event: Omit<AnalyticsEvent, "sessionId" | "platform" | "device" | "browser" | "os">) => {
|
||||
queueRef.current.push({
|
||||
...event,
|
||||
sessionId: sessionIdRef.current,
|
||||
platform: "web",
|
||||
...uaRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
const closeCurrentPage = () => {
|
||||
if (!currentPathRef.current) return;
|
||||
const durationMs = Date.now() - pageEnteredAtRef.current;
|
||||
enqueue({ type: "page_view", path: currentPathRef.current, durationMs });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = getSessionId();
|
||||
uaRef.current = parseUserAgent(navigator.userAgent);
|
||||
enqueue({ type: "session_start" });
|
||||
|
||||
const interval = window.setInterval(() => flush(false), FLUSH_INTERVAL_MS);
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
closeCurrentPage();
|
||||
currentPathRef.current = null;
|
||||
flush(true);
|
||||
}
|
||||
};
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const label = findClickLabel(e.target);
|
||||
if (label) enqueue({ type: "click", path: location.pathname, label });
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
document.addEventListener("click", onClick, { capture: true });
|
||||
window.addEventListener("pagehide", () => {
|
||||
closeCurrentPage();
|
||||
flush(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
document.removeEventListener("click", onClick, { capture: true });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
closeCurrentPage();
|
||||
currentPathRef.current = pathname;
|
||||
pageEnteredAtRef.current = Date.now();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
|
||||
import PwaBootSplash from "@/components/pwa/PwaBootSplash";
|
||||
import PwaHead from "@/components/PwaHead";
|
||||
import NotificationBridge from "@/components/notifications/NotificationBridge";
|
||||
import AnalyticsTracker from "@/components/AnalyticsTracker";
|
||||
|
||||
interface ILayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -32,6 +33,7 @@ function Layout({ children, initialLanguage }: ILayoutProps) {
|
||||
<PwaInstallPrompt />
|
||||
<BackgroundPrefetch />
|
||||
<NotificationBridge />
|
||||
<AnalyticsTracker />
|
||||
{children}
|
||||
</ReactQueryProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -8,24 +8,31 @@ import {
|
||||
toPwaLang,
|
||||
} from "@/lib/pwaConfig";
|
||||
|
||||
function upsertLink(rel: string, href: string) {
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = rel;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = href;
|
||||
// این تگها (manifest/apple-touch-icon/apple-mobile-web-app-title/application-name)
|
||||
// از قبل توسط generateMetadata (layout.tsx) بهصورت سرور-ساید توسط خودِ ریاکت رندر
|
||||
// و مدیریت میشن. اینجا فقط برای هماهنگکردنِ manifest/برچسبها با زبانی که کاربر
|
||||
// سمتِ کلاینت (بدون رفرشِ صفحه) عوض میکنه، مقدارِ href/content رو روی همون نودِ
|
||||
// موجود عوض میکنیم — بدون remove()/appendChild و بدون MutationObserver.
|
||||
//
|
||||
// نسخهی قبلی این فایل با querySelectorAll + remove() تگهای «تکراری» رو پاک
|
||||
// میکرد و توی یه MutationObserver هم دوباره صدا زده میشد؛ چون این نودها همونایی
|
||||
// هستن که ریاکتِ خودِ Next.js (متادیتای App Router) داره مدیریت میکنه، حذف/
|
||||
// جابهجاییِ ساختاریِ بیرون از ریاکت باعث میشد فایبرِ ریاکت روی ریرندرِ بعدی
|
||||
// (مثلاً هر ناوبری بینِ صفحات که متادیتا رو دوباره reconcile میکنه) به یه نودِ
|
||||
// جداشده removeChild بزنه و کرش کنه — که این کرشِ مکرر باعث میشد کامیتِ ریاکت
|
||||
// برای کلیکِ اول ناقص بمونه و کل سایت («تنظیمات»، دکمهی برگشتِ چت و...) نیاز به
|
||||
// کلیکِ دوم پیدا کنه. تغییرِ فقط یه attribute (نه ساختار) روی نودِ از قبل موجود،
|
||||
// فایبرِ ریاکت رو دستنخورده نگه میداره و این تداخل رو کاملاً حذف میکنه.
|
||||
function setLinkHref(rel: string, href: string) {
|
||||
const link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (link) link.href = href;
|
||||
}
|
||||
|
||||
function upsertMeta(name: string, content: string) {
|
||||
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.name = name;
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = content;
|
||||
function setMetaContent(name: string, content: string) {
|
||||
const meta = document.querySelector<HTMLMetaElement>(
|
||||
`meta[name="${name}"]`
|
||||
);
|
||||
if (meta) meta.content = content;
|
||||
}
|
||||
|
||||
export default function PwaHead() {
|
||||
@@ -37,13 +44,12 @@ export default function PwaHead() {
|
||||
const pwaLang = toPwaLang(language);
|
||||
const labels = getPwaLabels(pwaLang);
|
||||
|
||||
upsertLink(
|
||||
setLinkHref(
|
||||
"manifest",
|
||||
`/manifest.webmanifest?lang=${pwaLang}&v=${PWA_VERSION}`
|
||||
);
|
||||
upsertLink("apple-touch-icon", "/images/icons/apple-touch-icon.png");
|
||||
upsertMeta("apple-mobile-web-app-title", labels.short_name);
|
||||
upsertMeta("application-name", labels.short_name);
|
||||
setMetaContent("apple-mobile-web-app-title", labels.short_name);
|
||||
setMetaContent("application-name", labels.short_name);
|
||||
}, [language, ready]);
|
||||
|
||||
return null;
|
||||
|
||||
228
src/components/chat/E2eeIdentityGate.tsx
Normal file
228
src/components/chat/E2eeIdentityGate.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import {
|
||||
IdentityRecoveryNeededError,
|
||||
createFreshIdentity,
|
||||
ensureIdentityKeys,
|
||||
fetchMyKeyBackup,
|
||||
recoverIdentityFromBackup,
|
||||
saveKeyBackup,
|
||||
} from "@/lib/e2ee";
|
||||
|
||||
const SETUP_DISMISS_KEY = "e2ee_backup_setup_dismissed";
|
||||
|
||||
type GateState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "recovery"; hasBackup: boolean }
|
||||
| { kind: "setupPrompt" };
|
||||
|
||||
/**
|
||||
* توی ChatSocketProvider (سطحِ لایوت /settings/chats) mount میشه — جایی که
|
||||
* ensureIdentityKeys برای اولینبار صدا زده میشه. اگه یه هویتِ قبلی روی سرور باشه
|
||||
* که این دستگاه بهش دسترسی نداره، بهجای اینکه کدِ صداکننده (ChatSocketProvider) بیصدا
|
||||
* fail کنه، این کامپوننت رو مسدود میکنه تا کاربر یا با PIN بازیابی کنه یا صراحتاً
|
||||
* بپذیره هویتِ تازه بسازه (پیامهای قدیمی برای همیشه قفل میمونن)
|
||||
*/
|
||||
export default function E2eeIdentityGate({ userId }: { userId: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
const [state, setState] = useState<GateState>({ kind: "idle" });
|
||||
const [pin, setPin] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmFresh, setConfirmFresh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
let cancelled = false;
|
||||
|
||||
ensureIdentityKeys(userId)
|
||||
.then(async () => {
|
||||
if (cancelled) return;
|
||||
const dismissed = localStorage.getItem(SETUP_DISMISS_KEY) === "1";
|
||||
if (dismissed) return;
|
||||
const backup = await fetchMyKeyBackup();
|
||||
if (!cancelled && !backup) setState({ kind: "setupPrompt" });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
if (err instanceof IdentityRecoveryNeededError) {
|
||||
setState({ kind: "recovery", hasBackup: err.hasBackup });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const handleRecover = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await recoverIdentityFromBackup(userId, pin);
|
||||
setState({ kind: "idle" });
|
||||
setPin("");
|
||||
} catch {
|
||||
setError(t("chats.e2ee.wrongPin"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartFresh = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await createFreshIdentity(userId);
|
||||
setState({ kind: "setupPrompt" });
|
||||
setConfirmFresh(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBackup = async () => {
|
||||
if (pin.trim().length < 4) {
|
||||
setError(t("chats.e2ee.pinTooShort"));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveKeyBackup(userId, pin);
|
||||
localStorage.setItem(SETUP_DISMISS_KEY, "1");
|
||||
setState({ kind: "idle" });
|
||||
setPin("");
|
||||
} catch {
|
||||
setError(t("chats.e2ee.backupSaveFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissSetupPrompt = () => {
|
||||
localStorage.setItem(SETUP_DISMISS_KEY, "1");
|
||||
setState({ kind: "idle" });
|
||||
};
|
||||
|
||||
if (state.kind === "recovery") {
|
||||
return (
|
||||
<Modal isOpen onClose={() => {}} height="fit" elevated>
|
||||
<div className="flex flex-col gap-3 py-2 text-center">
|
||||
<p className="font-bold">{t("chats.e2ee.recoveryTitle")}</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{state.hasBackup
|
||||
? t("chats.e2ee.recoveryHasBackupDesc")
|
||||
: t("chats.e2ee.recoveryNoBackupDesc")}
|
||||
</p>
|
||||
|
||||
{state.hasBackup ? (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
placeholder={t("chats.e2ee.pinPlaceholder")}
|
||||
className="w-full rounded-xl border border-neutral-300 bg-transparent px-4 py-2.5 text-center text-lg tracking-widest outline-none dark:border-neutral-600"
|
||||
/>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[220px] mx-auto"
|
||||
disabled={busy || !pin.trim()}
|
||||
onClick={() => void handleRecover()}
|
||||
>
|
||||
{busy ? "…" : t("chats.e2ee.recoverButton")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{confirmFresh ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-red-500">
|
||||
{t("chats.e2ee.confirmFreshWarning")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<RoundedButton
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmFresh(false)}
|
||||
>
|
||||
{t("common.no")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={() => void handleStartFresh()}
|
||||
>
|
||||
{busy ? "…" : t("common.yes")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmFresh(true)}
|
||||
className="text-xs text-neutral-500 underline"
|
||||
>
|
||||
{t("chats.e2ee.startFreshLink")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === "setupPrompt") {
|
||||
// برخلافِ حالتِ recovery (که واقعاً باید کاربر رو مسدود کنه)، این یه پیشنهادِ
|
||||
// اختیاریه — نباید با یه بکدراپِ تمامصفحه بقیهی صفحه (مثلاً دکمهی برگشتِ
|
||||
// هدر) رو زیرِ خودش قایم کنه. قبلاً از Modal (بکدراپِ fixed inset-0) استفاده
|
||||
// میکرد — لمسِ هر جای صفحه، حتی دکمهی برگشت، بهجای رسیدن به عنصرِ زیرش،
|
||||
// بکدراپ رو میگرفت و میبست؛ کاربر با «برگشت نمیکنه» تجربهش میکرد
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-3 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-[110] mx-auto max-w-md sm:inset-x-0">
|
||||
<div
|
||||
className="glass-modal-panel pointer-events-auto flex flex-col gap-3 rounded-2xl p-4 text-center shadow-2xl"
|
||||
>
|
||||
<p className="font-bold">{t("chats.e2ee.setupTitle")}</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t("chats.e2ee.setupDesc")}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
placeholder={t("chats.e2ee.pinPlaceholder")}
|
||||
className="w-full rounded-xl border border-neutral-300 bg-transparent px-4 py-2.5 text-center text-lg tracking-widest outline-none dark:border-neutral-600"
|
||||
/>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<RoundedButton
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={dismissSetupPrompt}
|
||||
>
|
||||
{t("chats.e2ee.setupLater")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={() => void handleSaveBackup()}
|
||||
>
|
||||
{busy ? "…" : t("chats.e2ee.setupSave")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1049,6 +1049,7 @@ export function CourseForm({
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
playsInline
|
||||
className="w-full max-h-48 rounded-lg border"
|
||||
controlsList="nodownload"
|
||||
/>
|
||||
@@ -1152,6 +1153,7 @@ export function CourseForm({
|
||||
<video
|
||||
src={buildStorageUrl(field.course_video)}
|
||||
controls
|
||||
playsInline
|
||||
className="w-full max-h-64 rounded-lg"
|
||||
controlsList="nodownload"
|
||||
>
|
||||
|
||||
@@ -106,6 +106,7 @@ const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({
|
||||
<video
|
||||
src={file.src}
|
||||
controls
|
||||
playsInline
|
||||
className="max-h-[60vh] w-auto rounded-2xl shadow-lg"
|
||||
onLoadedData={() => setImageLoading?.(false)}
|
||||
/>
|
||||
|
||||
@@ -199,14 +199,16 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
|
||||
>
|
||||
<BoldIcon name="grid-3" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
<button
|
||||
{/* طبق خواستهی صریح کاربر: فعلاً که تعداد فروشگاهها کمه این آیکون مخفیه؛
|
||||
هر وقت تعداد فروشگاهها زیاد شد از کامنت درش بیارید */}
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => router.push("/shops/discover")}
|
||||
aria-label={t("filters.toolbar.shop")}
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<BoldIcon name="shop" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
</button> */}
|
||||
</div>
|
||||
{showFilterModal && (
|
||||
<FilterModal
|
||||
|
||||
@@ -148,7 +148,7 @@ function ProjectRequestForm({
|
||||
className="relative h-20 w-20 overflow-hidden rounded-lg border"
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<video src={item.data} className="h-full w-full object-cover" />
|
||||
<video src={item.data} playsInline muted className="h-full w-full object-cover" />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
|
||||
@@ -166,6 +166,7 @@ function ProjectRequestsAction({
|
||||
src={url}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePwaInstall } from "@/hooks/usePwaInstall";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -25,22 +26,32 @@ export default function PwaInstallPrompt() {
|
||||
const { language } = useAppLanguage();
|
||||
const labels = getPwaLabels(toPwaLang(language));
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="glass-modal-overlay fixed inset-0 z-[300]"
|
||||
aria-hidden
|
||||
onClick={() => close(true)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="pwa-install-title"
|
||||
aria-describedby="pwa-install-desc"
|
||||
className="glass-modal-panel glass-modal-panel--sheet fixed bottom-0 left-0 right-0 z-[301] mx-auto max-w-xl p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-2xl"
|
||||
dir={labels.dir}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<>
|
||||
<motion.div
|
||||
key="pwa-install-overlay"
|
||||
className="glass-modal-overlay fixed inset-0 z-[300]"
|
||||
aria-hidden
|
||||
onClick={() => close(true)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
/>
|
||||
<motion.div
|
||||
key="pwa-install-sheet"
|
||||
role="dialog"
|
||||
aria-labelledby="pwa-install-title"
|
||||
aria-describedby="pwa-install-desc"
|
||||
className="glass-modal-panel glass-modal-panel--sheet fixed bottom-0 left-0 right-0 z-[301] mx-auto max-w-xl p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-2xl"
|
||||
dir={labels.dir}
|
||||
initial={{ y: "100%", opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: "100%", opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 28, stiffness: 320 }}
|
||||
>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl bg-white shadow-md">
|
||||
<Image
|
||||
@@ -140,7 +151,9 @@ export default function PwaInstallPrompt() {
|
||||
{labels.later}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</motion.div>
|
||||
</>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
34
src/lib/analytics/parseUserAgent.ts
Normal file
34
src/lib/analytics/parseUserAgent.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// پارسر سبک UA — بدون وابستگی خارجی؛ فقط چیزهایی که برای گزارش دستگاه/مرورگر/سیستمعامل
|
||||
// توی آنالیتیکس لازمه (نه یک UA-parser کامل)
|
||||
export type ParsedUserAgent = {
|
||||
device: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
};
|
||||
|
||||
export function parseUserAgent(ua: string): ParsedUserAgent {
|
||||
const s = ua || "";
|
||||
|
||||
let os = "Other";
|
||||
if (/windows/i.test(s)) os = "Windows";
|
||||
else if (/iphone|ipad|ipod/i.test(s)) os = "iOS";
|
||||
else if (/android/i.test(s)) os = "Android";
|
||||
else if (/mac os x/i.test(s)) os = "macOS";
|
||||
else if (/linux/i.test(s)) os = "Linux";
|
||||
|
||||
let browser = "Other";
|
||||
if (/edg\//i.test(s)) browser = "Edge";
|
||||
else if (/samsungbrowser/i.test(s)) browser = "Samsung Internet";
|
||||
else if (/firefox\//i.test(s)) browser = "Firefox";
|
||||
else if (/crios\//i.test(s)) browser = "Chrome (iOS)";
|
||||
else if (/fxios\//i.test(s)) browser = "Firefox (iOS)";
|
||||
else if (/chrome\//i.test(s)) browser = "Chrome";
|
||||
else if (/version\/.+safari/i.test(s)) browser = "Safari";
|
||||
else if (/safari\//i.test(s)) browser = "Safari";
|
||||
|
||||
let device = "Desktop";
|
||||
if (/ipad|tablet/i.test(s)) device = "Tablet";
|
||||
else if (/mobile|iphone|android/i.test(s)) device = "Mobile";
|
||||
|
||||
return { device, browser, os };
|
||||
}
|
||||
@@ -218,6 +218,73 @@ export async function unwrapIdentityKeyForDevice(
|
||||
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
|
||||
}
|
||||
|
||||
// بکآپِ کلیدِ خصوصی با PIN/رمزِ انتخابیِ کاربر — عین Signal: کلیدِ رمزنگاری از خودِ
|
||||
// PIN با PBKDF2 (تعداد تکرارِ بالا، چون PIN معمولاً کوتاهه و در برابر brute-force
|
||||
// نیاز به کندسازیِ عمدی داره) مشتق میشه؛ سرور فقط بلاکِ رمزنگاریشده رو میبینه
|
||||
const BACKUP_PBKDF2_ITERATIONS = 250000;
|
||||
|
||||
async function deriveBackupKey(
|
||||
pin: string,
|
||||
salt: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
const pinKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(pin),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: toArrayBuffer(salt),
|
||||
iterations: BACKUP_PBKDF2_ITERATIONS,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
pinKey,
|
||||
{ name: "AES-GCM", length: AES_BITS },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export type KeyBackupPayload = {
|
||||
salt: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
iterations: number;
|
||||
};
|
||||
|
||||
export async function encryptPrivateKeyForBackup(
|
||||
privateJwk: JsonWebKey,
|
||||
pin: string
|
||||
): Promise<KeyBackupPayload> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await deriveBackupKey(pin, salt);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(privateJwk));
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
||||
return {
|
||||
salt: bufToB64(salt),
|
||||
iv: bufToB64(iv),
|
||||
ciphertext: bufToB64(ct),
|
||||
iterations: BACKUP_PBKDF2_ITERATIONS,
|
||||
};
|
||||
}
|
||||
|
||||
/** PIN اشتباه → decrypt با AES-GCM شکست میخوره (تگِ احرازهویت جور درنمیاد) و throw میکنه */
|
||||
export async function decryptPrivateKeyFromBackup(
|
||||
backup: KeyBackupPayload,
|
||||
pin: string
|
||||
): Promise<JsonWebKey> {
|
||||
const salt = b64ToBuf(backup.salt);
|
||||
const key = await deriveBackupKey(pin, new Uint8Array(salt));
|
||||
const iv = b64ToBuf(backup.iv);
|
||||
const ct = b64ToBuf(backup.ciphertext);
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
|
||||
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
|
||||
}
|
||||
|
||||
export async function unwrapRoomKey(
|
||||
wrappedB64: string,
|
||||
myPrivateKey: CryptoKey
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { deriveDmAesKey, importPublicKeySpkiB64 } from "./crypto";
|
||||
import {
|
||||
decryptText,
|
||||
deriveDmAesKey,
|
||||
encryptText,
|
||||
importPublicKeySpkiB64,
|
||||
} from "./crypto";
|
||||
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
|
||||
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
|
||||
@@ -73,17 +78,24 @@ export function clearDmKeyCache(): void {
|
||||
dmKeyCache.clear();
|
||||
}
|
||||
|
||||
// E2EE موقتاً کاملاً غیرفعال شده (کلیدهای محلی که از بین میرفتن — پاکشدنِ
|
||||
// دیتای مرورگر، نصبِ مجددِ اپ، دستگاهِ جدید — رمزگشایی رو برای همیشه میشکوندن،
|
||||
// بدونِ راهِ بازیابی چون سرور خودِ متن رو نگه نمیداره). پیامهای جدید همیشه
|
||||
// متنِ ساده ارسال/دریافت میشن؛ توابعِ زیرِ کلید (getDmConversationKey و...)
|
||||
// دستنخورده میمونن چون جاهای دیگهای importشون میکنن، ولی دیگه از اینجا صدا زده نمیشن
|
||||
// دوباره فعال شده — حالا که بکآپِ کلید با PIN (keys.ts: saveKeyBackup/
|
||||
// recoverIdentityFromBackup) و چکِ ایمنِ «هویتِ قبلی روی سرور هست ولی این دستگاه
|
||||
// کلیدِ محلی نداره» (keys.ts: IdentityRecoveryNeededError) قبل از این وجود دارن، یه
|
||||
// کلیدِ محلی هیچوقت بیصدا جایگزینِ یه هویتِ قدیمی نمیشه. اگه کلیدِ طرفِ مقابل در
|
||||
// دسترس نباشه (fail شدنِ getDmConversationKey)، متنِ ساده ارسال میشه — بهتر از
|
||||
// شکستِ کاملِ ارسالِ پیام، ولی این حالت باید نادر باشه
|
||||
export async function encryptDmContent(
|
||||
myUserId: string,
|
||||
peerUserId: string,
|
||||
plaintext: string
|
||||
): Promise<string | null> {
|
||||
return plaintext;
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return plaintext;
|
||||
try {
|
||||
return encryptText(key, plaintext);
|
||||
} catch {
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptDmContent(
|
||||
@@ -92,11 +104,13 @@ export async function decryptDmContent(
|
||||
content: string
|
||||
): Promise<string> {
|
||||
const raw = normalizeMessageContent(content);
|
||||
// فقط پیامِ قدیمیِ رمزنگاریشده (از قبلِ غیرفعالسازیِ E2EE) به اینجا میرسه —
|
||||
// چون کلیدِ لازم ممکنه دیگه در دسترس نباشه، بهجایِ تلاشِ بیفایده برای
|
||||
// رمزگشایی، همونجا پیامِ روشن نشون میدیم
|
||||
if (isE2eeEnvelope(raw)) return E2EE_LOCKED_PREVIEW;
|
||||
return raw;
|
||||
if (!isE2eeEnvelope(raw)) return raw;
|
||||
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
|
||||
const plain = await decryptText(key, raw);
|
||||
return plain ?? E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptDmMessageList<
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
export {
|
||||
ensureIdentityKeys,
|
||||
fetchPeerPublicKey,
|
||||
fetchMyKeyBackup,
|
||||
saveKeyBackup,
|
||||
recoverIdentityFromBackup,
|
||||
createFreshIdentity,
|
||||
IdentityRecoveryNeededError,
|
||||
} from "./keys";
|
||||
export {
|
||||
encryptDmContent,
|
||||
decryptDmContent,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
decryptPrivateKeyFromBackup,
|
||||
encryptPrivateKeyForBackup,
|
||||
exportPrivateKeyJwk,
|
||||
exportPublicKeySpkiB64,
|
||||
generateIdentityKeyPair,
|
||||
importPrivateKeyJwk,
|
||||
importPublicKeySpkiB64,
|
||||
type KeyBackupPayload,
|
||||
} from "./crypto";
|
||||
import { idbGet, idbSet } from "./storage";
|
||||
|
||||
@@ -47,6 +50,39 @@ async function publishPublicKey(publicKeyB64: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* پرتاب میشه وقتی روی سرور از قبل یه کلیدِ عمومی برای این کاربر ثبته، ولی این
|
||||
* دستگاه هیچ کلیدِ محلیای نداره — یعنی یه هویتِ قبلی وجود داره که این دستگاه بهش
|
||||
* دسترسی نداره. تولیدِ خودکارِ یه کلیدِ تازه در این حالت یعنی جایگزینیِ هویتِ قدیمی و
|
||||
* غیرقابلبازگشتشدنِ همیشگیِ پیامهای رمزنگاریشدهی قبلی — دقیقاً همون باگی که E2EE رو
|
||||
* موقتاً غیرفعال کرده بود. کدِ صداکننده باید این خطا رو بگیره و یا با
|
||||
* recoverIdentityFromBackup (اگه hasBackup) یا با تاییدِ صریحِ کاربر برای شروعِ یه
|
||||
* هویتِ تازه (createFreshIdentity) ادامه بده — هیچوقت خودکار
|
||||
*/
|
||||
export class IdentityRecoveryNeededError extends Error {
|
||||
hasBackup: boolean;
|
||||
constructor(hasBackup: boolean) {
|
||||
super("E2EE identity recovery needed");
|
||||
this.name = "IdentityRecoveryNeededError";
|
||||
this.hasBackup = hasBackup;
|
||||
}
|
||||
}
|
||||
|
||||
async function storeAndAdopt(
|
||||
uid: string,
|
||||
privateKey: CryptoKey,
|
||||
privateJwk: JsonWebKey,
|
||||
publicKeyB64: string
|
||||
): Promise<{ privateKey: CryptoKey; publicKeyB64: string }> {
|
||||
await idbSet(`identity:${uid}`, {
|
||||
userId: uid,
|
||||
privateJwk,
|
||||
publicKeyB64,
|
||||
} satisfies StoredIdentity);
|
||||
memoryPair = { userId: uid, privateKey, publicKeyB64 };
|
||||
return { privateKey, publicKeyB64 };
|
||||
}
|
||||
|
||||
async function loadOrCreateIdentityKeys(uid: string): Promise<{
|
||||
privateKey: CryptoKey;
|
||||
publicKeyB64: string;
|
||||
@@ -69,22 +105,24 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
|
||||
return { privateKey, publicKeyB64: stored.publicKeyB64 };
|
||||
}
|
||||
|
||||
// هیچ کلیدِ محلیای نیست — قبل از ساختنِ یه هویتِ تازه، مطمئن شو که این واقعاً یه
|
||||
// کاربرِ تازهست، نه یه دستگاهِ جدید/دیتایپاکشده برای یه هویتِ از قبل موجود
|
||||
const existingPublicKey = await fetchPeerPublicKey(uid);
|
||||
if (existingPublicKey) {
|
||||
const backup = await fetchMyKeyBackup();
|
||||
throw new IdentityRecoveryNeededError(Boolean(backup));
|
||||
}
|
||||
|
||||
const pair = await generateIdentityKeyPair();
|
||||
const publicKeyB64 = await exportPublicKeySpkiB64(pair.publicKey);
|
||||
const privateJwk = await exportPrivateKeyJwk(pair.privateKey);
|
||||
await idbSet(storeKey, {
|
||||
userId: uid,
|
||||
privateJwk,
|
||||
publicKeyB64,
|
||||
} satisfies StoredIdentity);
|
||||
|
||||
memoryPair = { userId: uid, privateKey: pair.privateKey, publicKeyB64 };
|
||||
const result = await storeAndAdopt(uid, pair.privateKey, privateJwk, publicKeyB64);
|
||||
try {
|
||||
await publishPublicKey(publicKeyB64);
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
return { privateKey: pair.privateKey, publicKeyB64 };
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function ensureIdentityKeys(userId: string): Promise<{
|
||||
@@ -109,6 +147,77 @@ export async function ensureIdentityKeys(userId: string): Promise<{
|
||||
return task;
|
||||
}
|
||||
|
||||
/** بکآپِ رمزنگاریشدهی این کاربر رو از سرور میگیره — سرور فقط ciphertext میبینه */
|
||||
export async function fetchMyKeyBackup(): Promise<KeyBackupPayload | null> {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/account/e2ee/key-backup`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { backup?: KeyBackupPayload | null };
|
||||
return data.backup || null;
|
||||
}
|
||||
|
||||
/** کلیدِ فعلیِ این دستگاه رو با یه PIN رمزنگاری میکنه و روی سرور بکآپ میگیره */
|
||||
export async function saveKeyBackup(userId: string, pin: string): Promise<void> {
|
||||
const uid = String(userId);
|
||||
const stored = await idbGet<StoredIdentity>(`identity:${uid}`);
|
||||
if (!stored?.privateJwk) throw new Error("No local identity key to back up");
|
||||
|
||||
const payload = await encryptPrivateKeyForBackup(stored.privateJwk, pin);
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/account/e2ee/key-backup`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save key backup");
|
||||
}
|
||||
|
||||
/**
|
||||
* بکآپِ سرور رو با PIN باز میکنه و بهعنوان کلیدِ این دستگاه میپذیره — همون هویتِ
|
||||
* قدیمی، پس پیامهای قدیمی دوباره قابلرمزگشایی میشن. PIN اشتباه → AES-GCM حین
|
||||
* decrypt (تگِ احرازهویت جور درنمیاد) throw میکنه، نه یه نتیجهی نادرستِ بیصدا
|
||||
*/
|
||||
export async function recoverIdentityFromBackup(
|
||||
userId: string,
|
||||
pin: string
|
||||
): Promise<void> {
|
||||
const uid = String(userId);
|
||||
const backup = await fetchMyKeyBackup();
|
||||
if (!backup) throw new Error("No backup found");
|
||||
|
||||
const privateJwk = await decryptPrivateKeyFromBackup(backup, pin);
|
||||
const privateKey = await importPrivateKeyJwk(privateJwk);
|
||||
const publicKeyB64 = await fetchPeerPublicKey(uid);
|
||||
if (!publicKeyB64) throw new Error("Could not fetch published public key");
|
||||
|
||||
await storeAndAdopt(uid, privateKey, privateJwk, publicKeyB64);
|
||||
}
|
||||
|
||||
/**
|
||||
* وقتی هیچ بکآپی نیست و هیچ دستگاهِ دیگهای هم برای لینککردن در دسترس نیست — کاربر
|
||||
* صراحتاً میپذیره که پیامهای قدیمیِ رمزنگاریشده برای همیشه غیرقابلبازگشت میمونن و
|
||||
* یه هویتِ کاملاً تازه میسازه. باید فقط بعدِ تاییدِ آگاهانهی کاربر صدا زده بشه، نه
|
||||
* خودکار
|
||||
*/
|
||||
export async function createFreshIdentity(userId: string): Promise<{
|
||||
privateKey: CryptoKey;
|
||||
publicKeyB64: string;
|
||||
}> {
|
||||
const uid = String(userId);
|
||||
const pair = await generateIdentityKeyPair();
|
||||
const publicKeyB64 = await exportPublicKeySpkiB64(pair.publicKey);
|
||||
const privateJwk = await exportPrivateKeyJwk(pair.privateKey);
|
||||
const result = await storeAndAdopt(uid, pair.privateKey, privateJwk, publicKeyB64);
|
||||
try {
|
||||
await publishPublicKey(publicKeyB64);
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fetchPeerPublicKey(
|
||||
peerUserId: string
|
||||
): Promise<string | null> {
|
||||
|
||||
@@ -8,6 +8,10 @@ export const PWA_THEME_COLOR = "#FF107D";
|
||||
export const PWA_BACKGROUND_COLOR = "#FF107D";
|
||||
export const PWA_ID = "modstagram-pwa";
|
||||
export const PWA_VERSION = "12";
|
||||
// نسخهی فعلیِ APK اندروید — با هر انتشار نسخهی جدید (و آپلود APK جدید توی
|
||||
// storage/downloads/modstagram.apk) این مقدار هم دستی بهروز بشه؛ همون versionName
|
||||
// توی android/app/build.gradle
|
||||
export const ANDROID_APP_VERSION = "2.1.2";
|
||||
|
||||
export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
|
||||
export const USERNAME_MIN_LENGTH = 8;
|
||||
export const USERNAME_MIN_LENGTH = 6;
|
||||
export const USERNAME_MAX_LENGTH = 30;
|
||||
|
||||
export const USERNAME_REGEX = new RegExp(
|
||||
|
||||
@@ -1289,7 +1289,9 @@
|
||||
"title": "Contact Modstagram",
|
||||
"emailLabel": "Email:",
|
||||
"phoneLabel": "Phone:",
|
||||
"sendTicket": "Send ticket"
|
||||
"sendTicket": "Send ticket",
|
||||
"downloadApp": "Download app",
|
||||
"appVersion": "Version {{version}}"
|
||||
},
|
||||
"offerPage": {
|
||||
"paymentTitle": "Payment",
|
||||
@@ -1680,6 +1682,22 @@
|
||||
"searchPlaceholder": "Search…",
|
||||
"chatRoom": "Chat rooms",
|
||||
"chatRoomDesc": "Group conversations with users",
|
||||
"e2ee": {
|
||||
"recoveryTitle": "Recover chat encryption",
|
||||
"recoveryHasBackupDesc": "This device doesn't have access to your previous encryption key. Enter the recovery passphrase you set up earlier.",
|
||||
"recoveryNoBackupDesc": "This device doesn't have access to your previous encryption key, and no backup was saved. You can link this device from another device you're already signed in on, or continue with a fresh identity (old encrypted messages will be permanently unreadable).",
|
||||
"pinPlaceholder": "Recovery passphrase",
|
||||
"recoverButton": "Recover",
|
||||
"wrongPin": "Incorrect passphrase",
|
||||
"startFreshLink": "I don't have a backup, start fresh",
|
||||
"confirmFreshWarning": "This will permanently make your old encrypted messages unreadable. Are you sure?",
|
||||
"setupTitle": "Set up an encryption recovery passphrase",
|
||||
"setupDesc": "With a recovery passphrase, you can regain access to your encrypted chats if you ever switch devices or reinstall the app.",
|
||||
"pinTooShort": "Passphrase must be at least 4 characters",
|
||||
"backupSaveFailed": "Failed to save backup",
|
||||
"setupLater": "Later",
|
||||
"setupSave": "Save"
|
||||
},
|
||||
"nearby": {
|
||||
"title": "Find nearby friends",
|
||||
"desc": "People within about 5 km",
|
||||
|
||||
@@ -1289,7 +1289,9 @@
|
||||
"title": "راه های ارتباطی با مجموعه مدستاگرام",
|
||||
"emailLabel": "ایمیل مجموعه :",
|
||||
"phoneLabel": "شماره تماس :",
|
||||
"sendTicket": "ارسال تیکت"
|
||||
"sendTicket": "ارسال تیکت",
|
||||
"downloadApp": "دانلود اپلیکیشن",
|
||||
"appVersion": "نسخه {{version}}"
|
||||
},
|
||||
"offerPage": {
|
||||
"paymentTitle": "پرداخت",
|
||||
@@ -1680,6 +1682,22 @@
|
||||
"searchPlaceholder": "جستجو…",
|
||||
"chatRoom": "چت روم",
|
||||
"chatRoomDesc": "گفتگوی گروهی با کاربران",
|
||||
"e2ee": {
|
||||
"recoveryTitle": "بازیابی رمزنگاری گفتگوها",
|
||||
"recoveryHasBackupDesc": "این دستگاه به کلید رمزنگاری قبلی دسترسی نداره. رمز بازیابی که قبلاً تنظیم کردید رو وارد کنید.",
|
||||
"recoveryNoBackupDesc": "این دستگاه به کلید رمزنگاری قبلی دسترسی نداره و بکآپی هم ثبت نشده. میتونید از یه دستگاه دیگه که قبلاً واردش شدید لینک کنید، یا با هویت تازه ادامه بدید (پیامهای قدیمی رمزنگاریشده برای همیشه غیرقابلخوندن میمونن).",
|
||||
"pinPlaceholder": "رمز بازیابی",
|
||||
"recoverButton": "بازیابی",
|
||||
"wrongPin": "رمز اشتباه است",
|
||||
"startFreshLink": "بکآپی ندارم، هویت تازه بساز",
|
||||
"confirmFreshWarning": "با این کار پیامهای رمزنگاریشدهی قبلی برای همیشه غیرقابلبازگشت میشن. مطمئنید؟",
|
||||
"setupTitle": "رمز بازیابی رمزنگاری تنظیم کنید",
|
||||
"setupDesc": "با یه رمز بازیابی، اگه بعداً این دستگاه رو عوض کنید یا اپ رو پاک کنید، میتونید به گفتگوهای رمزنگاریشدهتون دوباره دسترسی پیدا کنید.",
|
||||
"pinTooShort": "رمز باید حداقل ۴ کاراکتر باشد",
|
||||
"backupSaveFailed": "ذخیرهی بکآپ ناموفق بود",
|
||||
"setupLater": "بعداً",
|
||||
"setupSave": "ذخیره"
|
||||
},
|
||||
"nearby": {
|
||||
"title": "یافتن دوستان نزدیک",
|
||||
"desc": "کاربران در شعاع حدود ۵ کیلومتر",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
joinUserRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
import { ensureIdentityKeys } from "@/lib/e2ee";
|
||||
import E2eeIdentityGate from "@/components/chat/E2eeIdentityGate";
|
||||
|
||||
export default function ChatSocketProvider({
|
||||
children,
|
||||
@@ -15,14 +15,12 @@ export default function ChatSocketProvider({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const user = useUser();
|
||||
const uid = user?._id ? String(user._id) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
if (!uid) return;
|
||||
|
||||
const socket = acquireChatSocket();
|
||||
const uid = String(user._id);
|
||||
|
||||
void ensureIdentityKeys(uid);
|
||||
|
||||
const join = () => joinUserRoom(uid);
|
||||
socket.on("connect", join);
|
||||
@@ -32,7 +30,15 @@ export default function ChatSocketProvider({
|
||||
socket.off("connect", join);
|
||||
releaseChatSocket();
|
||||
};
|
||||
}, [user?._id]);
|
||||
}, [uid]);
|
||||
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
{/* ensureIdentityKeys اینجا (تویِ خودِ گیت) صدا زده میشه، نه اینجا مستقیم —
|
||||
تا اگه هویتِ قبلی روی سرور هست و این دستگاه بهش دسترسی نداره، بهجای
|
||||
fail شدنِ بیصدا، از کاربر بازیابی/تاییدِ صریح خواسته بشه */}
|
||||
{uid ? <E2eeIdentityGate userId={uid} /> : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user