This commit is contained in:
payacom
2026-09-03 16:04:52 +03:30
parent 76ef4e32a9
commit bf41ad4e0f
65 changed files with 1248 additions and 999 deletions

View File

@@ -1,9 +1,4 @@
import {
decryptText,
deriveDmAesKey,
encryptText,
importPublicKeySpkiB64,
} from "./crypto";
import { deriveDmAesKey, importPublicKeySpkiB64 } from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
@@ -78,19 +73,17 @@ export function clearDmKeyCache(): void {
dmKeyCache.clear();
}
// E2EE موقتاً کاملاً غیرفعال شده (کلیدهای محلی که از بین می‌رفتن — پاک‌شدنِ
// دیتای مرورگر، نصبِ مجددِ اپ، دستگاهِ جدید — رمزگشایی رو برای همیشه می‌شکوندن،
// بدونِ راهِ بازیابی چون سرور خودِ متن رو نگه نمی‌داره). پیام‌های جدید همیشه
// متنِ ساده ارسال/دریافت می‌شن؛ توابعِ زیرِ کلید (getDmConversationKey و...)
// دست‌نخورده می‌مونن چون جاهای دیگه‌ای importشون می‌کنن، ولی دیگه از اینجا صدا زده نمی‌شن
export async function encryptDmContent(
myUserId: string,
peerUserId: string,
plaintext: string
): Promise<string | null> {
if (!plaintext) return plaintext;
if (isE2eeEnvelope(plaintext)) return plaintext;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return null;
const encrypted = await encryptText(key, plaintext);
rememberOutboundPlaintext(encrypted, plaintext);
return encrypted;
return plaintext;
}
export async function decryptDmContent(
@@ -99,37 +92,11 @@ export async function decryptDmContent(
content: string
): Promise<string> {
const raw = normalizeMessageContent(content);
if (!isE2eeEnvelope(raw)) return raw;
const remembered = recallOutboundPlaintext(raw);
if (remembered) return remembered;
try {
const key = await getDmConversationKey(myUserId, peerUserId);
if (key) {
const pt = await decryptText(key, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
}
}
// کلید کش‌شده ممکن است با کلید فعلی طرف مقابل هم‌خوان نباشد (مثلاً بعد از
// لینک/ریست دستگاه) — یک بار با دریافت مجدد کلید عمومی طرف مقابل تلاش کن
const freshKey = await getDmConversationKey(myUserId, peerUserId, true);
if (freshKey) {
const pt = await decryptText(freshKey, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
}
}
return E2EE_LOCKED_PREVIEW;
} catch {
// خطای گذرا در رمزگشایی یک پیام نباید بقیه پیام‌های صفحه را قفل نشان دهد
return E2EE_LOCKED_PREVIEW;
}
// فقط پیامِ قدیمیِ رمزنگاری‌شده (از قبلِ غیرفعال‌سازیِ E2EE) به این‌جا می‌رسه —
// چون کلیدِ لازم ممکنه دیگه در دسترس نباشه، به‌جایِ تلاشِ بی‌فایده برای
// رمزگشایی، همون‌جا پیامِ روشن نشون می‌دیم
if (isE2eeEnvelope(raw)) return E2EE_LOCKED_PREVIEW;
return raw;
}
export async function decryptDmMessageList<

View File

@@ -1,178 +1,24 @@
import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
decryptText,
encryptText,
generateRoomKeyRaw,
importPublicKeySpkiB64,
importRoomAesKey,
unwrapRoomKey,
wrapRoomKeyForRecipient,
} from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys } from "./keys";
import { b64ToBuf, bufToB64, idbGet, idbSet } from "./storage";
type RoomKeysResponse = {
e2ee_enabled: boolean;
myWrappedKey: string | null;
memberPublicKeys: { userId: string; publicKey: string | null }[];
missingWrapUserIds: string[];
};
const roomAesCache = new Map<string, CryptoKey>();
const roomRawCache = new Map<string, Uint8Array>();
function authHeaders(): HeadersInit {
const token = Cookies.get("token");
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
async function fetchRoomKeys(roomId: string): Promise<RoomKeysResponse | null> {
const base = getApiBaseUrl();
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
headers: authHeaders(),
});
if (!res.ok) return null;
return (await res.json()) as RoomKeysResponse;
}
async function putRoomKeys(
roomId: string,
body: {
e2ee_enabled?: boolean;
wrapped_keys?: { userId: string; wrappedKey: string }[];
}
): Promise<void> {
const base = getApiBaseUrl();
await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify(body),
});
}
async function loadCachedRoomRaw(
myUserId: string,
roomId: string
): Promise<Uint8Array | null> {
const mem = roomRawCache.get(roomId);
if (mem) return mem;
const b64 = await idbGet<string>(`roomkey:${myUserId}:${roomId}`);
if (!b64) return null;
const raw = new Uint8Array(b64ToBuf(b64));
roomRawCache.set(roomId, raw);
return raw;
}
async function persistRoomRaw(
myUserId: string,
roomId: string,
raw: Uint8Array
): Promise<CryptoKey> {
roomRawCache.set(roomId, raw);
await idbSet(`roomkey:${myUserId}:${roomId}`, bufToB64(raw));
const aes = await importRoomAesKey(raw);
roomAesCache.set(roomId, aes);
return aes;
}
import { isE2eeEnvelope, E2EE_LOCKED_PREVIEW } from "./envelope";
// E2EE برای روم‌ها هم موقتاً کاملاً غیرفعال شده. نکته‌ی مهم: ensureRoomAesKey
// قبلاً اگه روم هنوز e2ee_enabled نبود، خودش یه کلیدِ تازه می‌ساخت و با PUT به
// سرور، رمزنگاری رو *خودکار* روی روم فعال می‌کرد — پس همین‌جا کاملاً no-op
// شده تا هیچ‌جایی (حتی موقعِ ساختنِ روم جدید) دیگه رمزنگاری رو روشن نکنه
/* eslint-disable @typescript-eslint/no-unused-vars -- امضا برای سازگاری با فراخوان‌های موجود حفظ شده */
export async function ensureRoomAesKey(
myUserId: string,
roomId: string,
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
forceFresh = false
roomId: string
): Promise<CryptoKey | null> {
if (!forceFresh) {
const cached = roomAesCache.get(roomId);
if (cached) return cached;
}
const { privateKey } = await ensureIdentityKeys(myUserId);
const info = await fetchRoomKeys(roomId);
if (!info) return null;
let raw = forceFresh ? null : await loadCachedRoomRaw(myUserId, roomId);
if (!raw && info.myWrappedKey) {
try {
raw = await unwrapRoomKey(info.myWrappedKey, privateKey);
await persistRoomRaw(myUserId, roomId, raw);
} catch {
raw = null;
}
}
if (!raw && !info.e2ee_enabled) {
raw = await generateRoomKeyRaw();
await persistRoomRaw(myUserId, roomId, raw);
const wraps: { userId: string; wrappedKey: string }[] = [];
for (const m of info.memberPublicKeys) {
if (!m.publicKey) continue;
try {
const pub = await importPublicKeySpkiB64(m.publicKey);
wraps.push({
userId: m.userId,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
} catch {
/* skip */
}
}
if (!wraps.some((w) => w.userId === myUserId)) {
const myPub = info.memberPublicKeys.find((m) => m.userId === myUserId);
if (myPub?.publicKey) {
const pub = await importPublicKeySpkiB64(myPub.publicKey);
wraps.push({
userId: myUserId,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
}
}
await putRoomKeys(roomId, { e2ee_enabled: true, wrapped_keys: wraps });
return roomAesCache.get(roomId) || (await persistRoomRaw(myUserId, roomId, raw));
}
if (raw) {
// Sync wraps for members missing keys
if (info.missingWrapUserIds?.length) {
const wraps: { userId: string; wrappedKey: string }[] = [];
for (const uid of info.missingWrapUserIds) {
const m = info.memberPublicKeys.find((x) => x.userId === uid);
if (!m?.publicKey) continue;
try {
const pub = await importPublicKeySpkiB64(m.publicKey);
wraps.push({
userId: uid,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
} catch {
/* skip */
}
}
if (wraps.length) {
await putRoomKeys(roomId, { e2ee_enabled: true, wrapped_keys: wraps });
}
}
return persistRoomRaw(myUserId, roomId, raw);
}
return null;
}
/* eslint-enable @typescript-eslint/no-unused-vars */
export async function encryptRoomContent(
myUserId: string,
roomId: string,
plaintext: string
): Promise<string | null> {
if (!plaintext) return plaintext;
if (isE2eeEnvelope(plaintext)) return plaintext;
const key = await ensureRoomAesKey(myUserId, roomId);
if (!key) return null;
return encryptText(key, plaintext);
return plaintext;
}
export async function decryptRoomContent(
@@ -180,22 +26,9 @@ export async function decryptRoomContent(
roomId: string,
content: string
): Promise<string> {
if (!isE2eeEnvelope(content)) return content;
const key = await ensureRoomAesKey(myUserId, roomId);
if (key) {
const pt = await decryptText(key, content);
if (pt != null) return pt;
}
// کلید کش‌شده ممکن است دیگر با wrapped key فعلی سرور هم‌خوان نباشد — یک بار تازه امتحان کن
const freshKey = await ensureRoomAesKey(myUserId, roomId, true);
if (freshKey) {
const pt = await decryptText(freshKey, content);
if (pt != null) return pt;
}
return E2EE_LOCKED_PREVIEW;
// فقط پیامِ قدیمیِ رمزنگاری‌شده (از قبلِ غیرفعال‌سازیِ E2EE) به این‌جا می‌رسه
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
return content;
}
export async function decryptRoomMessageList<
@@ -208,24 +41,14 @@ export async function decryptRoomMessageList<
roomId: string,
messages: T[]
): Promise<T[]> {
await ensureRoomAesKey(myUserId, roomId);
const out: T[] = [];
for (const msg of messages) {
let next = msg;
if (msg.content && isE2eeEnvelope(msg.content)) {
const decrypted = await decryptRoomContent(myUserId, roomId, msg.content);
next = { ...next, content: decrypted };
next = { ...next, content: E2EE_LOCKED_PREVIEW };
}
if (next.replyTo?.content && isE2eeEnvelope(next.replyTo.content)) {
const replyContent = await decryptRoomContent(
myUserId,
roomId,
next.replyTo.content
);
next = {
...next,
replyTo: { ...next.replyTo, content: replyContent },
};
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
}
out.push(next);
}

View File

@@ -1,4 +1,4 @@
import { optimizeImageToWebP, optimizeImages } from "./optimizeImage";
import { optimizeImageToWebP, optimizeImages, isImageAspectRatioAllowed } from "./optimizeImage";
import { optimizeVideoToMp4 } from "./optimizeVideo";
export async function optimizeMediaFile(
@@ -14,4 +14,4 @@ export async function optimizeMediaFile(
return file;
}
export { optimizeImageToWebP, optimizeImages, optimizeVideoToMp4 };
export { optimizeImageToWebP, optimizeImages, optimizeVideoToMp4, isImageAspectRatioAllowed };

View File

@@ -2,6 +2,27 @@
const MAX_EDGE = 1920;
const WEBP_QUALITY = 0.85;
// حداکثر کشیدگیِ مجاز برای عکسِ آپلودی توی پست/دوره — هیچ طرفی نباید بیشتر از ۴ برابرِ
// طرفِ دیگه باشه (یعنی حداقل نسبت ۱:۴)، وگرنه کارت‌های گرید/فید با این عکس به‌هم می‌ریزن —
// دقیقاً همون قانونِ isImageAspectRatioAllowed توی shared/lib/media.ts اندروید
export const MAX_UPLOAD_ASPECT_RATIO = 4;
/** true یعنی مجازه. فایل‌های غیرعکسی یا خراب رو رد نمی‌کنه (فقط وقتی واقعاً می‌تونیم
* ابعادش رو بخونیم چک می‌کنیم) */
export async function isImageAspectRatioAllowed(file: File): Promise<boolean> {
if (!file.type.startsWith("image/")) return true;
try {
const bitmap = await createImageBitmap(file);
const { width, height } = bitmap;
bitmap.close();
if (!width || !height) return true;
const ratio = Math.max(width, height) / Math.min(width, height);
return ratio <= MAX_UPLOAD_ASPECT_RATIO;
} catch {
return true;
}
}
export async function optimizeImageToWebP(file: File): Promise<File> {
if (!file.type.startsWith("image/")) return file;
if (file.type === "image/webp" && file.size < 2 * 1024 * 1024) return file;

12
src/lib/seo/safeJsonLd.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* JSON.stringify فقط کوتیشن/بک‌اسلش رو escape می‌کنه، نه `<` رو — پس اگه هر فیلدی
* که از کاربر میاد (بایو، توضیحاتِ فروشگاه/کالا و...) توی جیسون-الدی قرار بگیره،
* یه مقدار مثل `</script><script>...</script>` تگِ اسکریپتِ جیسون-الدی رو زودتر
* می‌بنده و باقیِ محتوا رو به‌عنوان اسکریپتِ واقعی اجرا می‌کنه (XSS ذخیره‌شده، چون
* توکنِ auth این اپ عمداً httpOnly نیست و از جاوااسکریپت قابل‌خوندنه). escapeِ
* کاراکترِ `<` (استانداردترین راهِ رفعِ این کلاس از حمله برای JSON-LD) این مسیر رو
* می‌بنده، بدون اینکه ساختارِ خودِ جیسون رو خراب کنه.
*/
export function safeJsonLdString(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}