post
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
} from "@/lib/postLinkCaption";
|
||||
import type { AppLanguage } from "@/lib/i18n/registry";
|
||||
|
||||
/** همراستا با maxLength مدل پست در بکاند */
|
||||
export const POST_CAPTION_MAX_LENGTH = 2000;
|
||||
|
||||
export function buildFullPostCaption(
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import Cookies from "js-cookie";
|
||||
import { SOCKET_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
let sharedSocket: Socket | null = null;
|
||||
let refCount = 0;
|
||||
const connectionListeners = new Set<(connected: boolean) => void>();
|
||||
|
||||
type PendingGroupJoin = { roomId: string; userId: string };
|
||||
type PendingGroupJoin = { roomId: string };
|
||||
const pendingGroupJoins = new Map<string, PendingGroupJoin>();
|
||||
let pendingUserId: string | null = null;
|
||||
let pendingUserJoin = false;
|
||||
|
||||
function notifyConnectionListeners(connected: boolean) {
|
||||
connectionListeners.forEach((fn) => fn(connected));
|
||||
}
|
||||
|
||||
function getAuthToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return Cookies.get("token") || "";
|
||||
}
|
||||
|
||||
function flushPendingJoins(): void {
|
||||
if (!sharedSocket?.connected) return;
|
||||
|
||||
if (pendingUserId) {
|
||||
sharedSocket.emit("joinUser", { userId: pendingUserId });
|
||||
if (pendingUserJoin) {
|
||||
sharedSocket.emit("joinUser");
|
||||
}
|
||||
|
||||
for (const { roomId, userId } of pendingGroupJoins.values()) {
|
||||
sharedSocket.emit("joinGroupRoom", { roomId, userId });
|
||||
for (const { roomId } of pendingGroupJoins.values()) {
|
||||
sharedSocket.emit("joinGroupRoom", { roomId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +47,9 @@ export function acquireChatSocket(): Socket {
|
||||
timeout: 20000,
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
auth: (cb) => {
|
||||
cb({ token: getAuthToken() });
|
||||
},
|
||||
});
|
||||
|
||||
sharedSocket.on("connect", () => {
|
||||
@@ -69,34 +78,30 @@ export function disconnectChatSocket(): void {
|
||||
sharedSocket = null;
|
||||
refCount = 0;
|
||||
pendingGroupJoins.clear();
|
||||
pendingUserId = null;
|
||||
pendingUserJoin = false;
|
||||
notifyConnectionListeners(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function joinUserRoom(userId: string): void {
|
||||
const id = String(userId).trim();
|
||||
if (!id) return;
|
||||
pendingUserId = id;
|
||||
export function joinUserRoom(_userId?: string): void {
|
||||
pendingUserJoin = true;
|
||||
if (sharedSocket?.connected) {
|
||||
sharedSocket.emit("joinUser", { userId: id });
|
||||
sharedSocket.emit("joinUser");
|
||||
}
|
||||
}
|
||||
|
||||
export function joinChatRoom(userId: string, receiverId: string): void {
|
||||
const uid = String(userId).trim();
|
||||
export function joinChatRoom(_userId: string, receiverId: string): void {
|
||||
const rid = String(receiverId).trim();
|
||||
if (!uid || !rid || !sharedSocket) return;
|
||||
sharedSocket.emit("joinChat", { userId: uid, receiverId: rid });
|
||||
if (!rid || !sharedSocket) return;
|
||||
sharedSocket.emit("joinChat", { receiverId: rid });
|
||||
}
|
||||
|
||||
export function joinGroupRoom(roomId: string, userId: string): void {
|
||||
export function joinGroupRoom(roomId: string, _userId?: string): void {
|
||||
const rid = String(roomId).trim();
|
||||
const uid = String(userId).trim();
|
||||
if (!rid || !uid) return;
|
||||
pendingGroupJoins.set(rid, { roomId: rid, userId: uid });
|
||||
if (!rid) return;
|
||||
pendingGroupJoins.set(rid, { roomId: rid });
|
||||
if (sharedSocket?.connected) {
|
||||
sharedSocket.emit("joinGroupRoom", { roomId: rid, userId: uid });
|
||||
sharedSocket.emit("joinGroupRoom", { roomId: rid });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
186
src/lib/e2ee/crypto.ts
Normal file
186
src/lib/e2ee/crypto.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { b64ToBuf, bufToB64, toArrayBuffer } from "./storage";
|
||||
import {
|
||||
E2eeEnvelope,
|
||||
parseEnvelope,
|
||||
serializeEnvelope,
|
||||
} from "./envelope";
|
||||
|
||||
const ECDH_PARAMS: EcKeyGenParams = { name: "ECDH", namedCurve: "P-256" };
|
||||
const AES_BITS = 256;
|
||||
|
||||
export async function generateIdentityKeyPair(): Promise<CryptoKeyPair> {
|
||||
return crypto.subtle.generateKey(ECDH_PARAMS, true, ["deriveBits"]);
|
||||
}
|
||||
|
||||
export async function exportPublicKeySpkiB64(publicKey: CryptoKey): Promise<string> {
|
||||
const spki = await crypto.subtle.exportKey("spki", publicKey);
|
||||
return bufToB64(spki);
|
||||
}
|
||||
|
||||
export async function importPublicKeySpkiB64(b64: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"spki",
|
||||
b64ToBuf(b64),
|
||||
ECDH_PARAMS,
|
||||
true,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportPrivateKeyJwk(privateKey: CryptoKey): Promise<JsonWebKey> {
|
||||
return crypto.subtle.exportKey("jwk", privateKey);
|
||||
}
|
||||
|
||||
export async function importPrivateKeyJwk(jwk: JsonWebKey): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey("jwk", jwk, ECDH_PARAMS, true, ["deriveBits"]);
|
||||
}
|
||||
|
||||
async function deriveAesKey(
|
||||
privateKey: CryptoKey,
|
||||
publicKey: CryptoKey,
|
||||
infoLabel: string
|
||||
): Promise<CryptoKey> {
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: "ECDH", public: publicKey },
|
||||
privateKey,
|
||||
256
|
||||
);
|
||||
|
||||
const hkdfKey = await crypto.subtle.importKey("raw", bits, "HKDF", false, [
|
||||
"deriveKey",
|
||||
]);
|
||||
|
||||
const info = new TextEncoder().encode(infoLabel);
|
||||
const salt = new Uint8Array(16);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt, info },
|
||||
hkdfKey,
|
||||
{ name: "AES-GCM", length: AES_BITS },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function deriveDmAesKey(
|
||||
myPrivateKey: CryptoKey,
|
||||
peerPublicKey: CryptoKey,
|
||||
userIdA: string,
|
||||
userIdB: string
|
||||
): Promise<CryptoKey> {
|
||||
const [a, b] = [String(userIdA), String(userIdB)].sort();
|
||||
return deriveAesKey(myPrivateKey, peerPublicKey, `modstagram-dm-v1:${a}:${b}`);
|
||||
}
|
||||
|
||||
export async function encryptWithAesGcm(
|
||||
key: CryptoKey,
|
||||
plaintext: string
|
||||
): Promise<E2eeEnvelope> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
||||
return {
|
||||
v: 1,
|
||||
e2ee: true,
|
||||
iv: bufToB64(iv),
|
||||
alg: "AES-GCM",
|
||||
ciphertext: bufToB64(ct),
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptWithAesGcm(
|
||||
key: CryptoKey,
|
||||
envelope: E2eeEnvelope
|
||||
): Promise<string> {
|
||||
const iv = b64ToBuf(envelope.iv);
|
||||
const ct = b64ToBuf(envelope.ciphertext);
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
|
||||
return new TextDecoder().decode(pt);
|
||||
}
|
||||
|
||||
export async function encryptText(
|
||||
key: CryptoKey,
|
||||
plaintext: string
|
||||
): Promise<string> {
|
||||
return serializeEnvelope(await encryptWithAesGcm(key, plaintext));
|
||||
}
|
||||
|
||||
export async function decryptText(
|
||||
key: CryptoKey,
|
||||
content: string
|
||||
): Promise<string | null> {
|
||||
const env = parseEnvelope(content);
|
||||
if (!env) return null;
|
||||
try {
|
||||
return await decryptWithAesGcm(key, env);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateRoomKeyRaw(): Promise<Uint8Array> {
|
||||
return crypto.getRandomValues(new Uint8Array(32));
|
||||
}
|
||||
|
||||
export async function importRoomAesKey(raw: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
toArrayBuffer(raw),
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function wrapRoomKeyForRecipient(
|
||||
roomKeyRaw: Uint8Array,
|
||||
recipientPublicKey: CryptoKey
|
||||
): Promise<string> {
|
||||
const ephemeral = await crypto.subtle.generateKey(ECDH_PARAMS, true, [
|
||||
"deriveBits",
|
||||
]);
|
||||
const aesKey = await deriveAesKey(
|
||||
ephemeral.privateKey,
|
||||
recipientPublicKey,
|
||||
"modstagram-room-wrap-v1"
|
||||
);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
aesKey,
|
||||
toArrayBuffer(roomKeyRaw)
|
||||
);
|
||||
const epk = await exportPublicKeySpkiB64(ephemeral.publicKey);
|
||||
return bufToB64(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
epk,
|
||||
iv: bufToB64(iv),
|
||||
ct: bufToB64(ct),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function unwrapRoomKey(
|
||||
wrappedB64: string,
|
||||
myPrivateKey: CryptoKey
|
||||
): Promise<Uint8Array> {
|
||||
const parsed = JSON.parse(new TextDecoder().decode(b64ToBuf(wrappedB64))) as {
|
||||
epk: string;
|
||||
iv: string;
|
||||
ct: string;
|
||||
};
|
||||
const epk = await importPublicKeySpkiB64(parsed.epk);
|
||||
const aesKey = await deriveAesKey(
|
||||
myPrivateKey,
|
||||
epk,
|
||||
"modstagram-room-wrap-v1"
|
||||
);
|
||||
const pt = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: b64ToBuf(parsed.iv) },
|
||||
aesKey,
|
||||
b64ToBuf(parsed.ct)
|
||||
);
|
||||
return new Uint8Array(pt);
|
||||
}
|
||||
148
src/lib/e2ee/dm.ts
Normal file
148
src/lib/e2ee/dm.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
decryptText,
|
||||
deriveDmAesKey,
|
||||
encryptText,
|
||||
importPublicKeySpkiB64,
|
||||
} from "./crypto";
|
||||
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
|
||||
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
|
||||
const dmKeyCache = new Map<string, CryptoKey>();
|
||||
|
||||
/** ciphertext → plaintext برای پیامهای خودمان (وقتی decrypt بههر دلیل fail شود) */
|
||||
const outboundPlainByCipher = new Map<string, string>();
|
||||
|
||||
function cacheKey(a: string, b: string): string {
|
||||
return [String(a), String(b)].sort().join(":");
|
||||
}
|
||||
|
||||
export function rememberOutboundPlaintext(
|
||||
ciphertext: string,
|
||||
plaintext: string
|
||||
): void {
|
||||
if (ciphertext && plaintext && isE2eeEnvelope(ciphertext)) {
|
||||
outboundPlainByCipher.set(ciphertext, plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
export function recallOutboundPlaintext(ciphertext: string): string | null {
|
||||
if (!ciphertext) return null;
|
||||
return outboundPlainByCipher.get(ciphertext) ?? null;
|
||||
}
|
||||
|
||||
export function normalizeMessageContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (content && typeof content === "object") {
|
||||
try {
|
||||
return JSON.stringify(content);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
if (content == null) return "";
|
||||
return String(content);
|
||||
}
|
||||
|
||||
export async function getDmConversationKey(
|
||||
myUserId: string,
|
||||
peerUserId: string
|
||||
): Promise<CryptoKey | null> {
|
||||
const me = String(myUserId || "").trim();
|
||||
const peer = String(peerUserId || "").trim();
|
||||
if (!me || !peer) return null;
|
||||
|
||||
const ck = cacheKey(me, peer);
|
||||
const cached = dmKeyCache.get(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const { privateKey } = await ensureIdentityKeys(me);
|
||||
const peerPubB64 = await fetchPeerPublicKey(peer);
|
||||
if (!peerPubB64) return null;
|
||||
|
||||
const peerPub = await importPublicKeySpkiB64(peerPubB64);
|
||||
const key = await deriveDmAesKey(privateKey, peerPub, me, peer);
|
||||
dmKeyCache.set(ck, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function clearDmKeyCache(): void {
|
||||
dmKeyCache.clear();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function decryptDmContent(
|
||||
myUserId: string,
|
||||
peerUserId: string,
|
||||
content: string
|
||||
): Promise<string> {
|
||||
const raw = normalizeMessageContent(content);
|
||||
if (!isE2eeEnvelope(raw)) return raw;
|
||||
|
||||
const remembered = recallOutboundPlaintext(raw);
|
||||
if (remembered) return remembered;
|
||||
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
|
||||
const pt = await decryptText(key, raw);
|
||||
if (pt != null) {
|
||||
rememberOutboundPlaintext(raw, pt);
|
||||
return pt;
|
||||
}
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptDmMessageList<
|
||||
T extends {
|
||||
content?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
replyTo?: { content?: string; [k: string]: unknown } | null;
|
||||
}
|
||||
>(
|
||||
myUserId: string,
|
||||
peerUserId: string,
|
||||
messages: T[]
|
||||
): Promise<T[]> {
|
||||
const out: T[] = [];
|
||||
for (const msg of messages) {
|
||||
let next = msg;
|
||||
const content = normalizeMessageContent(msg.content);
|
||||
if (content && isE2eeEnvelope(content)) {
|
||||
const decrypted = await decryptDmContent(myUserId, peerUserId, content);
|
||||
next = { ...next, content: decrypted };
|
||||
} else if (typeof msg.content !== "string" && content) {
|
||||
next = { ...next, content };
|
||||
}
|
||||
if (next.replyTo?.content) {
|
||||
const replyRaw = normalizeMessageContent(next.replyTo.content);
|
||||
if (isE2eeEnvelope(replyRaw)) {
|
||||
const replyContent = await decryptDmContent(
|
||||
myUserId,
|
||||
peerUserId,
|
||||
replyRaw
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
}
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
34
src/lib/e2ee/envelope.ts
Normal file
34
src/lib/e2ee/envelope.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type E2eeEnvelope = {
|
||||
v: 1;
|
||||
e2ee: true;
|
||||
iv: string;
|
||||
alg: "AES-GCM";
|
||||
ciphertext: string;
|
||||
};
|
||||
|
||||
export const E2EE_LOCKED_PREVIEW = "🔒 پیام رمزنگاریشده";
|
||||
|
||||
export function isE2eeEnvelope(content: unknown): content is string {
|
||||
if (typeof content !== "string") return false;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith("{")) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Partial<E2eeEnvelope>;
|
||||
return Boolean(parsed?.e2ee === true && parsed?.ciphertext && parsed?.iv);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEnvelope(content: string): E2eeEnvelope | null {
|
||||
if (!isE2eeEnvelope(content)) return null;
|
||||
try {
|
||||
return JSON.parse(content.trim()) as E2eeEnvelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeEnvelope(env: E2eeEnvelope): string {
|
||||
return JSON.stringify(env);
|
||||
}
|
||||
18
src/lib/e2ee/index.ts
Normal file
18
src/lib/e2ee/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
export {
|
||||
encryptDmContent,
|
||||
decryptDmContent,
|
||||
decryptDmMessageList,
|
||||
getDmConversationKey,
|
||||
rememberOutboundPlaintext,
|
||||
recallOutboundPlaintext,
|
||||
normalizeMessageContent,
|
||||
clearDmKeyCache,
|
||||
} from "./dm";
|
||||
export {
|
||||
ensureRoomAesKey,
|
||||
encryptRoomContent,
|
||||
decryptRoomContent,
|
||||
decryptRoomMessageList,
|
||||
} from "./room";
|
||||
export { isE2eeEnvelope, E2EE_LOCKED_PREVIEW } from "./envelope";
|
||||
105
src/lib/e2ee/keys.ts
Normal file
105
src/lib/e2ee/keys.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
exportPrivateKeyJwk,
|
||||
exportPublicKeySpkiB64,
|
||||
generateIdentityKeyPair,
|
||||
importPrivateKeyJwk,
|
||||
importPublicKeySpkiB64,
|
||||
} from "./crypto";
|
||||
import { idbGet, idbSet } from "./storage";
|
||||
|
||||
type StoredIdentity = {
|
||||
userId: string;
|
||||
privateJwk: JsonWebKey;
|
||||
publicKeyB64: string;
|
||||
};
|
||||
|
||||
let memoryPair: { userId: string; privateKey: CryptoKey; publicKeyB64: string } | null =
|
||||
null;
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = Cookies.get("token");
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function publishPublicKey(publicKeyB64: string): Promise<void> {
|
||||
const base = getApiBaseUrl();
|
||||
await fetch(`${base}/account/e2ee/public-key`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ publicKey: publicKeyB64 }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureIdentityKeys(userId: string): Promise<{
|
||||
privateKey: CryptoKey;
|
||||
publicKeyB64: string;
|
||||
}> {
|
||||
const uid = String(userId);
|
||||
if (memoryPair?.userId === uid) {
|
||||
return {
|
||||
privateKey: memoryPair.privateKey,
|
||||
publicKeyB64: memoryPair.publicKeyB64,
|
||||
};
|
||||
}
|
||||
|
||||
const storeKey = `identity:${uid}`;
|
||||
const stored = await idbGet<StoredIdentity>(storeKey);
|
||||
|
||||
if (stored?.privateJwk && stored.publicKeyB64) {
|
||||
const privateKey = await importPrivateKeyJwk(stored.privateJwk);
|
||||
memoryPair = {
|
||||
userId: uid,
|
||||
privateKey,
|
||||
publicKeyB64: stored.publicKeyB64,
|
||||
};
|
||||
try {
|
||||
await publishPublicKey(stored.publicKeyB64);
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
return { privateKey, publicKeyB64: stored.publicKeyB64 };
|
||||
}
|
||||
|
||||
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 };
|
||||
try {
|
||||
await publishPublicKey(publicKeyB64);
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
return { privateKey: pair.privateKey, publicKeyB64 };
|
||||
}
|
||||
|
||||
export async function fetchPeerPublicKey(
|
||||
peerUserId: string
|
||||
): Promise<string | null> {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(
|
||||
`${base}/account/e2ee/public-key/${encodeURIComponent(peerUserId)}`,
|
||||
{ headers: authHeaders() }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { publicKey?: string | null };
|
||||
return data.publicKey || null;
|
||||
}
|
||||
|
||||
export async function importPeerPublicKey(
|
||||
peerUserId: string
|
||||
): Promise<CryptoKey | null> {
|
||||
const b64 = await fetchPeerPublicKey(peerUserId);
|
||||
if (!b64) return null;
|
||||
return importPublicKeySpkiB64(b64);
|
||||
}
|
||||
218
src/lib/e2ee/room.ts
Normal file
218
src/lib/e2ee/room.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export async function ensureRoomAesKey(
|
||||
myUserId: string,
|
||||
roomId: string
|
||||
): Promise<CryptoKey | null> {
|
||||
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 = 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export async function decryptRoomContent(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
content: string
|
||||
): Promise<string> {
|
||||
if (!isE2eeEnvelope(content)) return content;
|
||||
const key = await ensureRoomAesKey(myUserId, roomId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
const pt = await decryptText(key, content);
|
||||
return pt ?? E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptRoomMessageList<
|
||||
T extends {
|
||||
content?: string;
|
||||
replyTo?: { content?: string; [k: string]: unknown } | null;
|
||||
}
|
||||
>(
|
||||
myUserId: string,
|
||||
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 };
|
||||
}
|
||||
if (next.replyTo?.content && isE2eeEnvelope(next.replyTo.content)) {
|
||||
const replyContent = await decryptRoomContent(
|
||||
myUserId,
|
||||
roomId,
|
||||
next.replyTo.content
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
58
src/lib/e2ee/storage.ts
Normal file
58
src/lib/e2ee/storage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
const DB_NAME = "modstagram-e2ee";
|
||||
const DB_VERSION = 1;
|
||||
const STORE = "keys";
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function idbGet<T>(key: string): Promise<T | null> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readonly");
|
||||
const req = tx.objectStore(STORE).get(key);
|
||||
req.onsuccess = () => resolve((req.result as T) ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function idbSet(key: string, value: unknown): Promise<void> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.objectStore(STORE).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function bufToB64(buf: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function b64ToBuf(b64: string): ArrayBuffer {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.buffer.slice(
|
||||
data.byteOffset,
|
||||
data.byteOffset + data.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export function buildExplorePostFilters(
|
||||
return { exploreFilter: "professional", userLevel: USER_LEVELS.PRO };
|
||||
case "trending":
|
||||
return { exploreFilter: "trending" };
|
||||
case "predict_trends":
|
||||
return { exploreFilter: "predict_trends" };
|
||||
case "best_month":
|
||||
return { exploreFilter: "best_month" };
|
||||
case "near_me":
|
||||
|
||||
@@ -18,19 +18,36 @@ export function getPostMedia(post: Post): {
|
||||
src: string;
|
||||
poster?: string;
|
||||
} | null {
|
||||
const file = post.files?.[0];
|
||||
if (!file?.path) return null;
|
||||
|
||||
const src = buildStorageUrl(file.path);
|
||||
const resolvedType = resolvePostMediaType(post);
|
||||
const isVideo =
|
||||
resolvedType === "video" ||
|
||||
file.type === "video" ||
|
||||
VIDEO_EXT.test(file.path);
|
||||
|
||||
if (isVideo) {
|
||||
return { kind: "video", src, poster: getVideoPoster(post) };
|
||||
const files = post.files || [];
|
||||
const firstFile = files[0];
|
||||
if (!firstFile?.path && !post.post_images?.length && !post.post_video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { kind: "image", src };
|
||||
const resolvedType = resolvePostMediaType(post);
|
||||
const videoFile = files.find((file) => file.type === "video");
|
||||
const isVideo =
|
||||
resolvedType === "video" ||
|
||||
Boolean(videoFile) ||
|
||||
(firstFile?.type === "video") ||
|
||||
(firstFile?.path ? VIDEO_EXT.test(firstFile.path) : false) ||
|
||||
Boolean(post.post_video);
|
||||
|
||||
if (isVideo) {
|
||||
const videoPath = videoFile?.path || post.post_video || firstFile?.path;
|
||||
if (!videoPath) {
|
||||
const posterOnly = getVideoPoster(post);
|
||||
if (!posterOnly) return null;
|
||||
return { kind: "video", src: posterOnly, poster: posterOnly };
|
||||
}
|
||||
return {
|
||||
kind: "video",
|
||||
src: buildStorageUrl(videoPath),
|
||||
poster: getVideoPoster(post),
|
||||
};
|
||||
}
|
||||
|
||||
const imagePath = firstFile?.path || post.post_images?.[0];
|
||||
if (!imagePath) return null;
|
||||
return { kind: "image", src: buildStorageUrl(imagePath) };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
formatBubbleTime,
|
||||
formatChatDateLabel,
|
||||
parseMessageDate,
|
||||
} from "@/lib/chat/formatMessageTime";
|
||||
@@ -18,7 +19,8 @@ export function groupItemsByDate<T>(
|
||||
items: T[],
|
||||
getCreatedAt: (item: T) => string | null | undefined,
|
||||
getItemKey: (item: T, index: number) => string,
|
||||
lang: AppLanguage = DEFAULT_LANGUAGE
|
||||
lang: AppLanguage = DEFAULT_LANGUAGE,
|
||||
options?: { includeTime?: boolean }
|
||||
): DateGroupedItem<T>[] {
|
||||
const groups: DateGroupedItem<T>[] = [];
|
||||
let lastDay = "";
|
||||
@@ -29,9 +31,13 @@ export function groupItemsByDate<T>(
|
||||
|
||||
if (dk !== lastDay) {
|
||||
lastDay = dk;
|
||||
const dateLabel = formatChatDateLabel(createdAt, lang);
|
||||
const timeLabel = options?.includeTime
|
||||
? formatBubbleTime(createdAt, lang)
|
||||
: "";
|
||||
groups.push({
|
||||
type: "date",
|
||||
label: formatChatDateLabel(createdAt, lang),
|
||||
label: timeLabel ? `${dateLabel} ${timeLabel}` : dateLabel,
|
||||
key: `date-${dk}`,
|
||||
});
|
||||
}
|
||||
|
||||
8
src/lib/moderationStatus.ts
Normal file
8
src/lib/moderationStatus.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** وضعیتهایی که در وب مثل تاییدشده نمایش داده میشوند */
|
||||
export function isPublicPostStatus(status?: string | null): boolean {
|
||||
return status === "accept" || status === "temp_accept";
|
||||
}
|
||||
|
||||
export function isPublicAcademyStatus(status?: string | null): boolean {
|
||||
return status === "accept" || status === "temp_accept";
|
||||
}
|
||||
19
src/lib/postEditWindow.ts
Normal file
19
src/lib/postEditWindow.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const POST_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function canEditPostWithinWindow(
|
||||
createdAt?: string | Date | null
|
||||
): boolean {
|
||||
if (!createdAt) return false;
|
||||
const ts = new Date(createdAt).getTime();
|
||||
if (Number.isNaN(ts)) return false;
|
||||
return Date.now() - ts < POST_EDIT_WINDOW_MS;
|
||||
}
|
||||
|
||||
export function postEditRemainingMs(
|
||||
createdAt?: string | Date | null
|
||||
): number {
|
||||
if (!createdAt) return 0;
|
||||
const ts = new Date(createdAt).getTime();
|
||||
if (Number.isNaN(ts)) return 0;
|
||||
return Math.max(0, POST_EDIT_WINDOW_MS - (Date.now() - ts));
|
||||
}
|
||||
@@ -15,8 +15,7 @@ export function captureVideoFrame(
|
||||
timeSec: number
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSeeked = () => {
|
||||
video.removeEventListener("seeked", onSeeked);
|
||||
const drawFrame = () => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth || 720;
|
||||
@@ -43,10 +42,45 @@ export function captureVideoFrame(
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener("seeked", onSeeked);
|
||||
video.currentTime = Math.min(
|
||||
const target = Math.min(
|
||||
Math.max(0, timeSec),
|
||||
Math.max(0, (video.duration || 1) - 0.05)
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fn();
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
video.removeEventListener("seeked", onSeeked);
|
||||
finish(() => reject(new Error("seek timeout")));
|
||||
}, 5000);
|
||||
|
||||
const onSeeked = () => {
|
||||
video.removeEventListener("seeked", onSeeked);
|
||||
window.clearTimeout(timeoutId);
|
||||
finish(drawFrame);
|
||||
};
|
||||
|
||||
if (
|
||||
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
||||
Math.abs(video.currentTime - target) < 0.01
|
||||
) {
|
||||
window.clearTimeout(timeoutId);
|
||||
finish(drawFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener("seeked", onSeeked);
|
||||
try {
|
||||
video.currentTime = target;
|
||||
} catch (err) {
|
||||
video.removeEventListener("seeked", onSeeked);
|
||||
window.clearTimeout(timeoutId);
|
||||
finish(() => reject(err instanceof Error ? err : new Error("seek failed")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
62
src/lib/projectDrafts.ts
Normal file
62
src/lib/projectDrafts.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ProjectFormData } from "@/contexts/ProjectFormContext";
|
||||
|
||||
export type ProjectDraft = {
|
||||
id: string;
|
||||
step: number;
|
||||
formData: ProjectFormData;
|
||||
updatedAt: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "projectDrafts";
|
||||
|
||||
function makeId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function listProjectDrafts(): ProjectDraft[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as ProjectDraft[];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectDraft(id: string): ProjectDraft | null {
|
||||
return listProjectDrafts().find((d) => d.id === id) || null;
|
||||
}
|
||||
|
||||
export function saveProjectDraft(input: {
|
||||
id?: string | null;
|
||||
step: number;
|
||||
formData: ProjectFormData;
|
||||
}): ProjectDraft {
|
||||
const drafts = listProjectDrafts();
|
||||
const id = input.id || makeId();
|
||||
const next: ProjectDraft = {
|
||||
id,
|
||||
step: input.step,
|
||||
formData: input.formData,
|
||||
updatedAt: Date.now(),
|
||||
title: input.formData.projTitle?.trim() || "پیشنویس بدون عنوان",
|
||||
};
|
||||
const idx = drafts.findIndex((d) => d.id === id);
|
||||
if (idx >= 0) drafts[idx] = next;
|
||||
else drafts.unshift(next);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts.slice(0, 30)));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeProjectDraft(id: string) {
|
||||
const drafts = listProjectDrafts().filter((d) => d.id !== id);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts));
|
||||
}
|
||||
13
src/lib/projectFormStyles.ts
Normal file
13
src/lib/projectFormStyles.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** یکسان با کادر نام پروژه در ثبت پروژه */
|
||||
export const PROJECT_FIELD_CLASS =
|
||||
"border border-neutral-950 dark:border-neutral-400";
|
||||
|
||||
export const PROJECT_FIELD_ERROR_CLASS =
|
||||
"border border-red-500 dark:border-red-500";
|
||||
|
||||
/** ردیف دکمههای ۲/۳/چندتایی — عرض کامل و فاصله یکسان از کنارهها */
|
||||
export const PROJECT_TOGGLE_ROW =
|
||||
"mt-4 grid w-full gap-2 px-0";
|
||||
|
||||
export const PROJECT_TOGGLE_BTN =
|
||||
"flex h-10 w-full min-w-0 max-w-none items-center justify-center gap-1 px-2 text-xs";
|
||||
@@ -1,9 +1,9 @@
|
||||
export type PwaLang = "fa" | "en";
|
||||
|
||||
export const PWA_THEME_COLOR = "#FF107D";
|
||||
export const PWA_BACKGROUND_COLOR = "#ffffff";
|
||||
export const PWA_BACKGROUND_COLOR = "#FF107D";
|
||||
export const PWA_ID = "modstagram-pwa";
|
||||
export const PWA_VERSION = "10";
|
||||
export const PWA_VERSION = "11";
|
||||
|
||||
export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang";
|
||||
|
||||
|
||||
19
src/lib/rangeValidation.ts
Normal file
19
src/lib/rangeValidation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** حداکثر باید strictly بیشتر از حداقل باشد (وقتی هر دو پر هستند) */
|
||||
export function isMaxGreaterThanMin(min: string, max: string): boolean {
|
||||
if (min === "" || max === "" || min == null || max == null) return true;
|
||||
const a = Number(min);
|
||||
const b = Number(max);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return true;
|
||||
return b > a;
|
||||
}
|
||||
|
||||
export function validateMinMaxPairs(
|
||||
pairs: Array<{ min: string; max: string; label?: string }>
|
||||
): string | null {
|
||||
for (const pair of pairs) {
|
||||
if (!isMaxGreaterThanMin(pair.min, pair.max)) {
|
||||
return pair.label || "max";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
429
src/lib/sitemap/builders.ts
Normal file
429
src/lib/sitemap/builders.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { buildPostPath } from "@/lib/postSlug";
|
||||
import {
|
||||
SITE_URL,
|
||||
SITEMAP_PAGE_SIZE,
|
||||
absoluteUrl,
|
||||
clampPages,
|
||||
pagesFromTotal,
|
||||
sitemapFetchJson,
|
||||
type SitemapEntry,
|
||||
} from "./config";
|
||||
|
||||
type ExpertiseRow = { name?: string; title?: string; _id?: string };
|
||||
type ProvinceRow = { id: number; name: string; slug: string };
|
||||
type CategoryRow = {
|
||||
name?: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
_id?: string;
|
||||
};
|
||||
|
||||
function slugPart(value: string): string {
|
||||
return value.trim().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function entry(
|
||||
path: string,
|
||||
opts?: Partial<SitemapEntry>
|
||||
): SitemapEntry {
|
||||
return {
|
||||
url: absoluteUrl(path),
|
||||
lastModified: opts?.lastModified || new Date(),
|
||||
changeFrequency: opts?.changeFrequency || "daily",
|
||||
priority: opts?.priority ?? 0.7,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStaticEntries(): SitemapEntry[] {
|
||||
const today = new Date();
|
||||
return [
|
||||
entry("/", { changeFrequency: "daily", priority: 1, lastModified: today }),
|
||||
entry("/explore", {
|
||||
changeFrequency: "hourly",
|
||||
priority: 0.9,
|
||||
lastModified: today,
|
||||
}),
|
||||
entry("/projects", {
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
lastModified: today,
|
||||
}),
|
||||
entry("/billboards", {
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
lastModified: today,
|
||||
}),
|
||||
entry("/academy", {
|
||||
changeFrequency: "daily",
|
||||
priority: 0.85,
|
||||
lastModified: today,
|
||||
}),
|
||||
entry("/videos", {
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
lastModified: today,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function extractHashtags(...texts: Array<string | undefined | null>): string[] {
|
||||
const found = new Set<string>();
|
||||
const re = /#([\p{L}\p{N}_]+)/gu;
|
||||
for (const text of texts) {
|
||||
if (!text) continue;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const tag = match[1]?.trim();
|
||||
if (tag && tag.length >= 2 && tag.length <= 40) found.add(tag);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
async function fetchExpertiseNames(): Promise<string[]> {
|
||||
const data = await sitemapFetchJson<
|
||||
ExpertiseRow[] | { expertises?: ExpertiseRow[]; data?: ExpertiseRow[] }
|
||||
>("/expertise");
|
||||
const rows = Array.isArray(data)
|
||||
? data
|
||||
: data?.expertises || data?.data || [];
|
||||
return rows
|
||||
.map((e) => e.name || e.title || "")
|
||||
.filter(Boolean)
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
async function fetchAcademyCategories(): Promise<string[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
categories?: CategoryRow[];
|
||||
data?: CategoryRow[] | { categories?: CategoryRow[] };
|
||||
}>("/academy/categories?status=active&limit=100");
|
||||
const rows = Array.isArray(data?.categories)
|
||||
? data.categories
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray((data?.data as { categories?: CategoryRow[] })?.categories)
|
||||
? (data?.data as { categories: CategoryRow[] }).categories
|
||||
: [];
|
||||
return rows
|
||||
.map((c) => c.name || c.title || c.slug || "")
|
||||
.filter(Boolean)
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function sampleHashtags(): Promise<string[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
posts?: Array<{ caption?: string }>;
|
||||
}>(`/users/web?page=1&limit=200&sort=latest`);
|
||||
const tags = extractHashtags(
|
||||
...(data?.posts || []).map((p) => p.caption || "")
|
||||
);
|
||||
return tags.slice(0, 120);
|
||||
}
|
||||
|
||||
export async function buildFilterEntries(): Promise<SitemapEntry[]> {
|
||||
const provincesModule = await import("@/data/provinces.json");
|
||||
const provinces = (
|
||||
Array.isArray(provincesModule)
|
||||
? provincesModule
|
||||
: (provincesModule as { default: ProvinceRow[] }).default
|
||||
) as ProvinceRow[];
|
||||
|
||||
const [expertises, categories, hashtags] = await Promise.all([
|
||||
fetchExpertiseNames(),
|
||||
fetchAcademyCategories(),
|
||||
sampleHashtags(),
|
||||
]);
|
||||
|
||||
const urls = new Map<string, SitemapEntry>();
|
||||
const add = (path: string, priority = 0.65) => {
|
||||
urls.set(path, entry(path, { changeFrequency: "daily", priority }));
|
||||
};
|
||||
|
||||
// صفحه پستها — فیلتر تخصص / استان
|
||||
for (const exp of expertises) {
|
||||
add(`/${slugPart(`استخدام-${exp}`)}`, 0.75);
|
||||
add(`/videos/${slugPart(`ویدئو-ها-و-نمونه-کارهای-${exp}`)}`, 0.7);
|
||||
add(`/projects?expertise=${encodeURIComponent(exp)}`, 0.7);
|
||||
}
|
||||
add(`/${slugPart("استخدام-متخصصین-مد-و-زیبایی")}`, 0.7);
|
||||
add(`/videos/${slugPart("ویدئو-ها-متخصصین-مد-و-زیبایی")}`, 0.65);
|
||||
|
||||
for (const province of provinces) {
|
||||
const pName = province.name;
|
||||
add(
|
||||
`/${slugPart(`استخدام-متخصصین-مد-و-زیبایی-در-استان-${pName}`)}`,
|
||||
0.7
|
||||
);
|
||||
add(
|
||||
`/billboards/${slugPart(`تبلیغات-خدمات-مد-و-زیبایی-در-استان-${pName}`)}`,
|
||||
0.7
|
||||
);
|
||||
for (const exp of expertises.slice(0, 12)) {
|
||||
add(`/${slugPart(`استخدام-${exp}-در-استان-${pName}`)}`, 0.68);
|
||||
}
|
||||
}
|
||||
|
||||
// آموزشگاه — دستهبندی و رایگان
|
||||
add(`/academy?isFree=true`, 0.7);
|
||||
for (const cat of categories) {
|
||||
add(`/academy?category=${encodeURIComponent(cat)}`, 0.7);
|
||||
}
|
||||
|
||||
// هشتگهای داینامیک (از محتوای واقعی)
|
||||
for (const tag of hashtags) {
|
||||
const q = encodeURIComponent(tag);
|
||||
add(`/?hashtag=${q}`, 0.72);
|
||||
add(`/explore?hashtag=${q}`, 0.72);
|
||||
add(`/academy?search=${encodeURIComponent(`#${tag}`)}`, 0.6);
|
||||
}
|
||||
|
||||
return [...urls.values()];
|
||||
}
|
||||
|
||||
/* ─── page counters ─── */
|
||||
|
||||
export async function countPostsPages(): Promise<number> {
|
||||
const data = await sitemapFetchJson<{
|
||||
totalPages?: number;
|
||||
totalItems?: number;
|
||||
}>(`/users/web?page=1&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
if (data?.totalPages) return clampPages(data.totalPages);
|
||||
if (data?.totalItems) return pagesFromTotal(data.totalItems);
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function countUsersPages(): Promise<number> {
|
||||
const data = await sitemapFetchJson<{
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
users?: unknown[];
|
||||
}>(`/users/seo/public-profiles?page=1&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
if (typeof data?.total === "number") return pagesFromTotal(data.total);
|
||||
return data?.users?.length ? 1 : 1;
|
||||
}
|
||||
|
||||
export async function countProjectsPages(): Promise<number> {
|
||||
const data = await sitemapFetchJson<{
|
||||
totalPages?: number;
|
||||
totalItems?: number;
|
||||
projects?: unknown[];
|
||||
}>(`/projects?page=1&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
if (data?.totalPages) return clampPages(data.totalPages);
|
||||
if (data?.totalItems) return pagesFromTotal(data.totalItems);
|
||||
return data?.projects?.length ? 1 : 1;
|
||||
}
|
||||
|
||||
export async function countBillboardsPages(): Promise<number> {
|
||||
const data = await sitemapFetchJson<{
|
||||
totalPages?: number;
|
||||
totalItems?: number;
|
||||
advertisings?: unknown[];
|
||||
}>(`/advertising/web?page=1&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
if (data?.totalPages) return clampPages(data.totalPages);
|
||||
if (data?.totalItems) return pagesFromTotal(data.totalItems);
|
||||
return data?.advertisings?.length ? 1 : 1;
|
||||
}
|
||||
|
||||
export async function countCoursesPages(): Promise<number> {
|
||||
const data = await sitemapFetchJson<{
|
||||
data?: {
|
||||
pagination?: { totalPages?: number; totalItems?: number };
|
||||
courses?: unknown[];
|
||||
};
|
||||
}>(
|
||||
`/academy/academy/get/curses?page=1&limit=${SITEMAP_PAGE_SIZE}&status=accept`
|
||||
);
|
||||
const pagination = data?.data?.pagination;
|
||||
if (pagination?.totalPages) return clampPages(pagination.totalPages);
|
||||
if (pagination?.totalItems) return pagesFromTotal(pagination.totalItems);
|
||||
return data?.data?.courses?.length ? 1 : 1;
|
||||
}
|
||||
|
||||
export async function countAcademiesPages(): Promise<number> {
|
||||
const data = await sitemapFetchJson<{
|
||||
data?: {
|
||||
pagination?: { totalPages?: number; totalItems?: number };
|
||||
academies?: unknown[];
|
||||
};
|
||||
success?: boolean;
|
||||
}>(`/academy/academies?page=1&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
const pagination = data?.data?.pagination;
|
||||
if (pagination?.totalPages) return clampPages(pagination.totalPages);
|
||||
if (pagination?.totalItems) return pagesFromTotal(pagination.totalItems);
|
||||
return data?.data?.academies?.length ? 1 : 1;
|
||||
}
|
||||
|
||||
/* ─── page builders ─── */
|
||||
|
||||
export async function buildPostsPage(page: number): Promise<SitemapEntry[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
posts?: Array<{
|
||||
_id: string;
|
||||
caption?: string;
|
||||
user_name?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
}>(`/users/web?page=${page}&limit=${SITEMAP_PAGE_SIZE}&sort=latest`);
|
||||
|
||||
return (data?.posts || [])
|
||||
.filter((p) => p?._id)
|
||||
.map((p) =>
|
||||
entry(buildPostPath(p._id, p), {
|
||||
changeFrequency: "hourly",
|
||||
priority: p.type === "video" ? 0.8 : 0.78,
|
||||
lastModified: p.updatedAt || p.createdAt || new Date(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildUsersPage(page: number): Promise<SitemapEntry[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
users?: Array<{ user_name?: string; updatedAt?: string }>;
|
||||
}>(
|
||||
`/users/seo/public-profiles?page=${page}&limit=${SITEMAP_PAGE_SIZE}`
|
||||
);
|
||||
|
||||
return (data?.users || [])
|
||||
.filter((u) => u.user_name)
|
||||
.map((u) =>
|
||||
entry(`/users/${encodeURIComponent(String(u.user_name))}`, {
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.75,
|
||||
lastModified: u.updatedAt || new Date(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildProjectsPage(page: number): Promise<SitemapEntry[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
projects?: Array<{
|
||||
_id: string;
|
||||
title?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
}>(`/projects?page=${page}&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
|
||||
return (data?.projects || [])
|
||||
.filter((p) => p?._id)
|
||||
.map((p) => {
|
||||
const title = encodeURIComponent(p.title || "project");
|
||||
return entry(`/projects/${p._id}/${title}`, {
|
||||
changeFrequency: "daily",
|
||||
priority: 0.85,
|
||||
lastModified: p.updatedAt || p.createdAt || new Date(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildBillboardsPage(
|
||||
page: number
|
||||
): Promise<SitemapEntry[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
advertisings?: Array<{
|
||||
_id: string;
|
||||
title?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
billboards?: Array<{
|
||||
_id: string;
|
||||
title?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
}>(`/advertising/web?page=${page}&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
|
||||
const rows = data?.advertisings || data?.billboards || [];
|
||||
return rows
|
||||
.filter((b) => b?._id)
|
||||
.map((b) => {
|
||||
const title = encodeURIComponent(b.title || "billboard");
|
||||
return entry(`/billboards/${b._id}/${title}`, {
|
||||
changeFrequency: "daily",
|
||||
priority: 0.85,
|
||||
lastModified: b.updatedAt || b.createdAt || new Date(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildCoursesPage(page: number): Promise<SitemapEntry[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
data?: {
|
||||
courses?: Array<{
|
||||
_id: string;
|
||||
cuorse_name?: string;
|
||||
academyId?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
`/academy/academy/get/curses?page=${page}&limit=${SITEMAP_PAGE_SIZE}&status=accept`
|
||||
);
|
||||
|
||||
const courses = data?.data?.courses || [];
|
||||
return courses
|
||||
.filter((c) => c?._id)
|
||||
.map((c) => {
|
||||
const name = encodeURIComponent(c.cuorse_name || "course");
|
||||
return entry(`/academy/${c._id}/${name}`, {
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
lastModified: c.updatedAt || c.createdAt || new Date(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildAcademiesPage(
|
||||
page: number
|
||||
): Promise<SitemapEntry[]> {
|
||||
const data = await sitemapFetchJson<{
|
||||
data?: {
|
||||
academies?: Array<{
|
||||
_id: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
};
|
||||
}>(`/academy/academies?page=${page}&limit=${SITEMAP_PAGE_SIZE}`);
|
||||
|
||||
const academies = data?.data?.academies || [];
|
||||
if (academies.length) {
|
||||
return academies
|
||||
.filter((a) => a?._id)
|
||||
.map((a) =>
|
||||
entry(`/academy/profile/${a._id}`, {
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.75,
|
||||
lastModified: a.updatedAt || a.createdAt || new Date(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// fallback: استخراج academyId از پکیجها
|
||||
const coursesData = await sitemapFetchJson<{
|
||||
data?: {
|
||||
courses?: Array<{ academyId?: string; updatedAt?: string }>;
|
||||
};
|
||||
}>(
|
||||
`/academy/academy/get/curses?page=${page}&limit=${SITEMAP_PAGE_SIZE}&status=accept`
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
for (const c of coursesData?.data?.courses || []) {
|
||||
if (c.academyId) ids.add(String(c.academyId));
|
||||
}
|
||||
return [...ids].map((id) =>
|
||||
entry(`/academy/profile/${id}`, {
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.75,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export { SITE_URL };
|
||||
72
src/lib/sitemap/config.ts
Normal file
72
src/lib/sitemap/config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
|
||||
"https://modstagram.com";
|
||||
|
||||
/** تعداد URL در هر فایل sitemap فرزند */
|
||||
export const SITEMAP_PAGE_SIZE = 500;
|
||||
|
||||
/** سقف صفحات برای جلوگیری از انفجار ایندکس */
|
||||
export const SITEMAP_MAX_PAGES = 80;
|
||||
|
||||
export const SITEMAP_REVALIDATE_SECONDS = 3600;
|
||||
|
||||
export type SitemapEntry = {
|
||||
url: string;
|
||||
lastModified?: Date | string;
|
||||
changeFrequency?:
|
||||
| "always"
|
||||
| "hourly"
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "never";
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export function absoluteUrl(path: string): string {
|
||||
if (!path) return SITE_URL;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export async function sitemapFetchJson<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T | null> {
|
||||
const base = getApiBaseUrl().replace(/\/$/, "");
|
||||
const url = path.startsWith("http")
|
||||
? path
|
||||
: `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
next: { revalidate: SITEMAP_REVALIDATE_SECONDS },
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`[sitemap] ${res.status} ${url}`);
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
console.error(`[sitemap] fetch failed ${url}`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clampPages(totalPages: number): number {
|
||||
if (!Number.isFinite(totalPages) || totalPages < 1) return 1;
|
||||
return Math.min(Math.max(1, Math.floor(totalPages)), SITEMAP_MAX_PAGES);
|
||||
}
|
||||
|
||||
export function pagesFromTotal(totalItems: number, pageSize = SITEMAP_PAGE_SIZE) {
|
||||
if (!totalItems || totalItems < 1) return 1;
|
||||
return clampPages(Math.ceil(totalItems / pageSize));
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
|
||||
export const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{2,7}$/;
|
||||
export const USERNAME_MIN_LENGTH = 8;
|
||||
export const USERNAME_MAX_LENGTH = 30;
|
||||
|
||||
export const USERNAME_MIN_LENGTH = 3;
|
||||
export const USERNAME_MAX_LENGTH = 8;
|
||||
export const USERNAME_REGEX = new RegExp(
|
||||
`^[a-zA-Z0-9][a-zA-Z0-9._-]{${USERNAME_MIN_LENGTH - 1},${USERNAME_MAX_LENGTH - 1}}$`
|
||||
);
|
||||
|
||||
export const USERNAME_ALLOWED_CHARS = /[^a-zA-Z0-9._-]/g;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user