check
This commit is contained in:
@@ -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<
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
12
src/lib/seo/safeJsonLd.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user