This commit is contained in:
payacom
2026-07-28 10:06:42 +03:30
parent a1d7db5e7e
commit 67f59eefdf
129 changed files with 7964 additions and 1561 deletions

View File

@@ -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(

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View File

@@ -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":

View File

@@ -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) };
}

View File

@@ -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}`,
});
}

View 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
View 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));
}

View File

@@ -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
View 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));
}

View 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";

View File

@@ -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";

View 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
View 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
View 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));
}

View File

@@ -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;