diff --git a/.claude/prompt/fix-token-storage-xss-headers.md b/.claude/prompt/fix-token-storage-xss-headers.md
new file mode 100644
index 0000000..fa45d5b
--- /dev/null
+++ b/.claude/prompt/fix-token-storage-xss-headers.md
@@ -0,0 +1,179 @@
+# سختسازی امنیتی سایت عمومی: ذخیرهی توکن، XSS، هدرها، client_secret
+
+## پروژه
+
+`nobat724_front` (سایت عمومی). این پرامپت **cross-repo** است — پرامپت همتای backend: `clinicpro/.claude/prompt/fix-auth-token-hardening.md` که **اول** باید اجرا شود (قرارداد توکن را عوض میکند: `oauth/token` بهجای `uuid` فیلد `grant` میگیرد و `verify-code` فیلد `grant` برمیگرداند).
+
+مرجع: گزارش امنیتی این session (OWASP Top 10). یافتههای frontend: **C-2 (Critical)**، **C-3 (Critical)**، **H-2 (High)**، **H-3 (High)**.
+
+## زمینه
+
+ممیزی امنیتی این مشکلات را در سایت عمومی پیدا کرد:
+
+- **C-2:** `access_token` و `refresh_token` با `js-cookie` ست میشوند (`Cookies.set(...)`) — یعنی **غیر HttpOnly، بدون Secure، بدون SameSite**؛ هر اسکریپتی میتواند بخواندشان. با XSS → سرقت کامل توکن.
+- **C-3:** بدنهی بلاگ/کلینیک/پزشک با `dangerouslySetInnerHTML={{ __html: sanitizeHtml(...) }}` رندر میشود، اما `lib/sanitize.js` یک sanitizer **regex دستی و قابل دور زدن** است (مثلاً `
` چون `
` در لیست خطرناک نیست عبور میکند). → Stored XSS.
+- **H-2:** `next.config.js` هیچ تابع `headers()` ندارد → بدون CSP/HSTS/X-Frame-Options/nosniff/Referrer-Policy/Permissions-Policy.
+- **H-3:** توکن با `NEXT_PUBLIC_CLIENT_SECRET` گرفته میشود؛ هر مقدار `NEXT_PUBLIC_*` داخل bundle مرورگر inline و عمومی میشود.
+
+## مشکل / هدف
+
+refresh token را به کوکی **HttpOnly سمت سرور** ببر و توکنگیری/OAuth را در یک Route Handler سرور-ساید انجام بده (تا `client_secret` و `refresh_token` هرگز به مرورگر نروند)؛ sanitizer را با **DOMPurify** عوض کن؛ security headers را در `next.config.js` اضافه کن.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `services/api.js` | interceptor — خواندن `access_token` از کوکی |
+| `services/response.js` | `getToken` که `client_secret` میفرستد |
+| `components/register/verificationPage/SendReq.js` | ستکردن کوکیها بعد از لاگین (`Cookies.set`) |
+| `components/appointment/detail/SubmitData.js` | `Cookies.set("access_token"/"refresh_token")` |
+| `components/dashboard/userAccount/.../ButtonSendData.js` | `Cookies.set("access_token")` |
+| `lib/sanitize.js` | sanitizer regex فعلی |
+| `lib/req.js` / `services/clinicApi.js` | فراخوانیهای مصرفکنندهی توکن جدید |
+| `app/api/auth/token/route.js` (جدید) | Route Handler سرور-ساید برای OAuth + ست کوکی HttpOnly |
+| `next.config.js` | افزودن `headers()` |
+
+## وضعیت فعلی (کد واقعی)
+
+`services/response.js` — `client_secret` در درخواست:
+```js
+getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
+ // ...
+ formData.append("client_secret", client_secret);
+ // ...
+}
+```
+
+`components/register/verificationPage/SendReq.js` (و مشابه در SubmitData.js):
+```js
+Cookies.set("access_token", response.access_token, cookieOptions);
+Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
+```
+
+`services/api.js` interceptor:
+```js
+if (config.requireAuth) {
+ const token = Cookies.get("access_token");
+ if (token) config.headers.Authorization = `Bearer ${token}`;
+}
+```
+
+`lib/sanitize.js` — regex قابل دور زدن:
+```js
+const DANGEROUS_TAGS = ['script','iframe','object','embed','link','meta','base','form'];
+export function sanitizeHtml(html) {
+ let sanitized = html;
+ DANGEROUS_TAGS.forEach((tag) => { /* regex replace */ });
+ sanitized = sanitized.replace(/\s+on\w+\s*=\s*.../gi, '');
+ return sanitized;
+}
+```
+
+`next.config.js` — بدون `headers()`.
+
+## وظایف
+
+### ۱. OAuth + ست کوکی در یک Route Handler سرور-ساید (C-2, H-3)
+
+یک Route Handler بساز: `app/api/auth/token/route.js`. این هندلر:
+- ورودی `{ grant }` (مطابق قرارداد جدید backend) را از body میگیرد.
+- توکن را با `process.env.CLIENT_SECRET` (**بدون** `NEXT_PUBLIC`) و `process.env.CLIENT_ID` از backend (`oauth/token`) میگیرد.
+- `refresh_token` را در کوکی **HttpOnly; Secure; SameSite=Lax; path=/** ست میکند.
+- `access_token` را در پاسخ JSON برمیگرداند تا کلاینت در **memory** نگهش دارد (نه کوکی، نه localStorage).
+
+```js
+import { cookies } from 'next/headers';
+import axios from 'axios';
+
+export async function POST(req) {
+ const { grant } = await req.json();
+ const res = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/oauth/token`, {
+ grant_type: 'mobile',
+ grant,
+ client_id: process.env.CLIENT_ID,
+ client_secret: process.env.CLIENT_SECRET,
+ });
+ const { access_token, refresh_token, expires_in } = res.data;
+ const jar = cookies();
+ jar.set('refresh_token', refresh_token, {
+ httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,
+ });
+ return Response.json({ access_token, expires_in });
+}
+```
+
+یک Route Handler refresh هم بساز (`app/api/auth/refresh/route.js`) که `refresh_token` را از کوکی HttpOnly میخواند، `oauth/token/refresh` را صدا میزند، کوکی جدید ست میکند و access token تازه برمیگرداند. و `app/api/auth/logout/route.js` که کوکی را پاک و `oauth/logout` را صدا میزند.
+
+> `services/response.js::getToken` که `client_secret` سمت کلاینت میفرستد را **حذف/جایگزین** کن با فراخوانی این Route Handler. هیچ `NEXT_PUBLIC_CLIENT_SECRET` در کد کلاینت نماند.
+
+### ۲. مدیریت access token در memory بهجای کوکی JS (C-2)
+
+- همهی `Cookies.set("access_token", ...)` و `Cookies.set("refresh_token", ...)` را حذف کن (SendReq.js, SubmitData.js, ButtonSendData.js و هرجای دیگر).
+- access token را در یک ماژول in-memory نگهدار (مثلاً `lib/tokenStore.js` با یک متغیر و getter/setter، یا context). در `services/api.js` interceptor بهجای `Cookies.get("access_token")` از این store بخوان.
+- روی 401، interceptor اول `app/api/auth/refresh` را امتحان کند؛ اگر شکست خورد، logout و redirect.
+- چون access token در memory با refresh صفحه پاک میشود، در bootstrap اپ (مثلاً یک Provider بالای درخت) یکبار `app/api/auth/refresh` صدا بزن تا از روی کوکی HttpOnly، access token تازه بگیری.
+
+> کوکیهای `uuid`/`userInfo` که حساس نیستند میتوانند بمانند ولی `userInfo` را به فیلدهای غیرحساس محدود کن.
+
+### ۳. جایگزینی sanitizer با DOMPurify (C-3)
+
+`isomorphic-dompurify` را نصب و `lib/sanitize.js` را بازنویسی کن (امضای `sanitizeHtml` حفظ شود تا `Caption.js`/`TextDetail.js`/`blog`/`clinic`/`doctor` تغییر نکنند):
+```js
+import DOMPurify from 'isomorphic-dompurify';
+
+export function sanitizeHtml(html) {
+ if (!html || typeof html !== 'string') return '';
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: ['p','br','strong','em','b','i','u','ul','ol','li','a','h2','h3','h4','blockquote','img','span','table','thead','tbody','tr','td','th'],
+ ALLOWED_ATTR: ['href','target','rel','src','alt','title'],
+ ALLOW_DATA_ATTR: false,
+ });
+}
+// safeJsonParse را همانطور که هست نگه دار
+```
+> `isomorphic-dompurify` در SSR (Server Component) و کلاینت هر دو کار میکند — مهم، چون این صفحات SSR هستند.
+
+### ۴. Security headers در next.config.js (H-2)
+
+تابع `headers()` به `nextConfig` اضافه کن:
+```js
+async headers() {
+ return [{
+ source: '/(.*)',
+ headers: [
+ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
+ { key: 'Content-Security-Policy', value: [
+ "default-src 'self'",
+ "img-src 'self' https: data:",
+ "script-src 'self' 'unsafe-inline'", // اگر JSON-LD/Next نیاز داشت؛ در صورت امکان nonce
+ "style-src 'self' 'unsafe-inline'",
+ "font-src 'self' data:",
+ "connect-src 'self' https://api.clinic-pro.ir",
+ "frame-ancestors 'none'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ ].join('; ') },
+ ],
+ }];
+},
+```
+> CSP را با build و مرور صفحات اصلی (home/doctor/clinic/blog/panel) تست کن؛ اگر چیزی بلاک شد (MUI inline style, JSON-LD)، با `'unsafe-inline'` فقط برای style یا nonce برای script حلش کن — نه باز کردن کامل.
+
+## نکات مهم
+
+- **ترتیب:** اول پرامپت backend اجرا شود (قرارداد `grant`)، بعد این. تا قبل از آن، `oauth/token` همچنان `uuid` میخواهد؛ این پرامپت بر اساس قرارداد جدید (`grant`) نوشته شده.
+- App Router؛ Route Handlerها سرور-ساید هستند و به `process.env.CLIENT_SECRET` دسترسی دارند بدون افشا به مرورگر.
+- multi-domain را نشکن: `domain` کوکی را مثل کد فعلی بر اساس hostname ست کن (برای کوکی HttpOnly در Route Handler هم همان منطق domain را اعمال کن تا روی سابدامینهای شهرها کار کند).
+- بعد از تغییر env، مطمئن شو `CLIENT_SECRET` و `CLIENT_ID` (بدون `NEXT_PUBLIC`) در محیط deploy ست شدهاند (در `docker-compose.yml` از قبل `CLIENT_SECRET`/`CLIENT_ID` تعریف شده).
+- تست:
+ - `npm run build` بدون خطا.
+ - جریان لاگین کامل: OTP → access token در memory، refresh در کوکی HttpOnly (در DevTools → Application → Cookies باید `HttpOnly` ✓ و `Secure` ✓ باشد).
+ - refresh صفحه → اپ از کوکی HttpOnly دوباره access token میگیرد و کاربر لاگین میماند.
+ - در DevTools Console: `document.cookie` نباید `access_token`/`refresh_token` نشان دهد.
+ - بدنهی بلاگ با payload تست `
` رندر شود ولی اجرا نشود (DOMPurify پاکش کند).
+ - هدرهای امنیتی در Network tab روی پاسخ صفحات دیده شوند.
+ - در bundle مرورگر (`.next/static`) رشتهی `client_secret` یا مقدار آن نباشد.
diff --git a/app/Providers.js b/app/Providers.js
index 8313164..47c40e1 100644
--- a/app/Providers.js
+++ b/app/Providers.js
@@ -2,10 +2,23 @@
import { ThemeProvider } from "next-themes";
import { usePathname } from "next/navigation";
+import { useEffect } from "react";
+import Cookies from "js-cookie";
+import { setAccessToken } from "@/lib/tokenStore";
export function Providers({ children }) {
const router = usePathname();
+ useEffect(() => {
+ if (!Cookies.get("userInfo")) return;
+ fetch("/api/auth/refresh", { method: "POST" })
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => {
+ if (data?.access_token) setAccessToken(data.access_token);
+ })
+ .catch(() => {});
+ }, []);
+
return (
{
+ const logout = async () => {
setLoading(true);
+ try {
+ await fetch("/api/auth/logout", { method: "POST" });
+ } catch {
+ // clearing client state below is what matters
+ }
removeToken();
router.replace("/login");
};
diff --git a/app/component/date/dateTime/SendAppo.js b/app/component/date/dateTime/SendAppo.js
index 734caf9..a7ba9d6 100644
--- a/app/component/date/dateTime/SendAppo.js
+++ b/app/component/date/dateTime/SendAppo.js
@@ -4,7 +4,7 @@ import Cookies from "js-cookie";
function SendAppo({ hour, setStep, setSelectedSlot, date, setSelectedDate }) {
const sendReq = () => {
- const isLogged = Cookies.get("access_token");
+ const isLogged = Cookies.get("userInfo");
// ذخیره کردن اطلاعات اسلات انتخاب شده
if (hour && date) {
diff --git a/app/dashboard/page.js b/app/dashboard/page.js
index 2257b12..fedf132 100644
--- a/app/dashboard/page.js
+++ b/app/dashboard/page.js
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { safeJsonParse } from "@/lib/sanitize";
import { buildPatientUser } from "@/lib/representationAdapters";
+import { getServerAccessToken } from "@/lib/serverToken";
export default async function Dashboard({ searchParams }) {
const awaitedSearchParams = await searchParams;
@@ -16,9 +17,9 @@ export default async function Dashboard({ searchParams }) {
const ability = defineAbilitiesFor(user);
const cookieStore = await cookies();
- const token = cookieStore.get("access_token");
+ const token = await getServerAccessToken();
- if (!ability.can("access", "Dashboard")) {
+ if (!ability.can("access", "Dashboard") || !token) {
removeToken();
return redirect("/login");
}
@@ -27,7 +28,7 @@ export default async function Dashboard({ searchParams }) {
// may still carry the OTP uuid, which 404s against user-profile.
const userInfo = safeJsonParse(cookieStore.get("userInfo")?.value);
const userUuid = userInfo?.uuid || cookieStore.get("uuid")?.value;
- const authHeader = { headers: { Authorization: `Bearer ${token.value}` } };
+ const authHeader = { headers: { Authorization: `Bearer ${token}` } };
const API = process.env.NEXT_PUBLIC_API_URL;
const [profile, appointmentsRes, paymentsRes] = await Promise.all([
@@ -54,7 +55,7 @@ export default async function Dashboard({ searchParams }) {
return (
{
try {
- const refreshToken = Cookies.get("refresh_token");
- if (!refreshToken) return false;
-
- const res = await fetch("/api/auth/refresh", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ refresh_token: refreshToken }),
- });
+ const res = await fetch("/api/auth/refresh", { method: "POST" });
+ if (!res.ok) return false;
const response = await res.json();
if (response?.access_token) {
- Cookies.set("access_token", response.access_token, { expires: 7 });
- if (response.refresh_token) {
- Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
- }
+ setAccessToken(response.access_token);
return true;
}
return false;
diff --git a/components/dashboard/userAccount/detailUser/index.js b/components/dashboard/userAccount/detailUser/index.js
index 069dee1..351df2e 100644
--- a/components/dashboard/userAccount/detailUser/index.js
+++ b/components/dashboard/userAccount/detailUser/index.js
@@ -12,7 +12,6 @@ import Surgeries from "./surgeries";
import FamilyHistory from "./familyHistory";
import Relatives from "./relatives";
import { removeAdditionalKeysDashboard } from "@/helper";
-import Cookies from "js-cookie";
import { toast } from "react-toastify";
import LoadingComponent from "@/app/component/LoadingComponent";
@@ -37,11 +36,7 @@ function DetailUser({ user, isTurnsDetails, setIsTurnsDetails }) {
const usedKays = removeAdditionalKeysDashboard(information);
setLoading(true);
request
- .patchUserProfile(
- usedKays,
- information?.uuid,
- Cookies.get("access_token")
- )
+ .patchUserProfile(usedKays, information?.uuid)
.then(() => {
setLoading(false);
toast.success("اطلاعات با موفقیت ثبت شد");
diff --git a/components/dashboard/userAccount/detailUser/information/ButtonSendData.js b/components/dashboard/userAccount/detailUser/information/ButtonSendData.js
index f884b88..316a2e7 100644
--- a/components/dashboard/userAccount/detailUser/information/ButtonSendData.js
+++ b/components/dashboard/userAccount/detailUser/information/ButtonSendData.js
@@ -1,14 +1,13 @@
import ArrowLeftWhiteD from "@/components/icons/ArrowLeftWhiteD";
import {
changeDateType,
- handleTimeExpiresToken,
isValidIranNationalCode,
removeAdditionalKeysDashboard,
} from "@/helper";
import { request } from "@/services/response";
import { Button, CircularProgress } from "@mui/material";
-import Cookies from "js-cookie";
import { toast } from "react-toastify";
+import { setAccessToken } from "@/lib/tokenStore";
function ButtonSendData({
loading,
@@ -31,21 +30,11 @@ function ButtonSendData({
.postUserProfile(changeDateType(information, true))
.then((response) => {
if (response.uuid) {
- fetch("/api/auth/refresh", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ refresh_token: Cookies.get("refresh_token") }),
- })
- .then((r) => r.json())
+ fetch("/api/auth/refresh", { method: "POST" })
+ .then((r) => (r.ok ? r.json() : null))
.then((res) => {
if (res?.access_token) {
- const expiresTime = handleTimeExpiresToken(res.expires_in);
- Cookies.set("access_token", res.access_token, {
- expires: expiresTime.accessTokenExpires,
- path: "/",
- secure: true,
- sameSite: "strict",
- });
+ setAccessToken(res.access_token);
}
setInformation({
...information,
@@ -69,11 +58,7 @@ function ButtonSendData({
const usedKays = removeAdditionalKeysDashboard(information);
request
- .patchUserProfile(
- changeDateType(usedKays, true),
- information?.uuid,
- Cookies.get("access_token")
- )
+ .patchUserProfile(changeDateType(usedKays, true), information?.uuid)
.then(() => {
setLoading(false);
toast.success("اطلاعات با موفقیت ثبت شد");
diff --git a/components/layout/StLayout.js b/components/layout/StLayout.js
index 0c98603..1f3734f 100644
--- a/components/layout/StLayout.js
+++ b/components/layout/StLayout.js
@@ -6,14 +6,14 @@ import { cookies } from "next/headers";
async function StLayout({ children, name }) {
const { matchedCity } = await getStateInfo();
const cookieStore = await cookies();
- const token = cookieStore.get("access_token");
+ const isLogged = Boolean(cookieStore.get("userInfo"));
return (
diff --git a/components/layout/header/Content.js b/components/layout/header/Content.js
index 26e1342..99b6913 100644
--- a/components/layout/header/Content.js
+++ b/components/layout/header/Content.js
@@ -18,7 +18,7 @@ function Content({ matchedCity, name, logged }) {
const pathname = usePathname();
useEffect(() => {
- setIsLogged(Boolean(Cookies.get("access_token")));
+ setIsLogged(Boolean(Cookies.get("userInfo")));
}, [pathname]);
return (
diff --git a/components/register/verificationPage/SendReq.js b/components/register/verificationPage/SendReq.js
index 95e2a73..ad62c79 100644
--- a/components/register/verificationPage/SendReq.js
+++ b/components/register/verificationPage/SendReq.js
@@ -3,6 +3,7 @@ import { request } from "@/services/response";
import Cookies from "js-cookie";
import { toast } from "react-toastify";
import { handleTimeExpiresToken } from "@/helper";
+import { setAccessToken } from "@/lib/tokenStore";
function SendReq({
loading,
@@ -44,14 +45,13 @@ function SendReq({
const isHttps = window.location.protocol === "https:";
const isLocalhost = hostname.includes("localhost");
- // تنظیمات پایه
+ // فقط دادههای غیرحساس در کوکی JS؛ access_token در memory، refresh_token در کوکی HttpOnly سرور
let cookieOptions = {
path: "/",
sameSite: "lax",
- expires: expiresTime.accessTokenExpires,
+ expires: expiresTime.refreshTokenExpires,
};
- // اگر روی دامنه اصلی و https بود
if (!isLocalhost && isHttps) {
cookieOptions = {
...cookieOptions,
@@ -60,17 +60,8 @@ function SendReq({
};
}
- Cookies.set("access_token", response.access_token, cookieOptions);
-
- Cookies.set("refresh_token", response.refresh_token, {
- ...cookieOptions,
- expires: expiresTime.refreshTokenExpires,
- });
-
- Cookies.set("uuid", uuid, {
- ...cookieOptions,
- expires: expiresTime.refreshTokenExpires,
- });
+ setAccessToken(response.access_token);
+ Cookies.set("uuid", uuid, cookieOptions);
getInfo(response.access_token, cookieOptions);
};
diff --git a/lib/auth.js b/lib/auth.js
index 9c05f9f..abf7976 100644
--- a/lib/auth.js
+++ b/lib/auth.js
@@ -2,11 +2,8 @@ import { cookies } from "next/headers";
export async function getUser() {
const cookieStore = await cookies();
- const raw =
- cookieStore.get("access_token") &&
- cookieStore.get("refresh_token") &&
- cookieStore.get("uuid") &&
- cookieStore.get("userInfo");
- if (!raw) return null;
- return raw;
+ const isAuthenticated =
+ cookieStore.get("refresh_token") && cookieStore.get("userInfo");
+ if (!isAuthenticated) return null;
+ return cookieStore.get("userInfo");
}
diff --git a/lib/refreshCookie.js b/lib/refreshCookie.js
new file mode 100644
index 0000000..12d888e
--- /dev/null
+++ b/lib/refreshCookie.js
@@ -0,0 +1,34 @@
+const COOKIE_NAME = "refresh_token";
+
+function cookieDomain(host) {
+ if (!host) return undefined;
+ const hostname = host.split(":")[0];
+ if (hostname.includes("localhost") || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
+ return undefined;
+ }
+ return "." + hostname.split(".").slice(-2).join(".");
+}
+
+export function setRefreshCookie(response, refreshToken, host, maxAge = 60 * 60 * 24 * 30) {
+ response.cookies.set(COOKIE_NAME, refreshToken, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge,
+ domain: cookieDomain(host),
+ });
+}
+
+export function clearRefreshCookie(response, host) {
+ response.cookies.set(COOKIE_NAME, "", {
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 0,
+ domain: cookieDomain(host),
+ });
+}
+
+export { COOKIE_NAME };
diff --git a/lib/sanitize.js b/lib/sanitize.js
index 549bf41..d03c61a 100644
--- a/lib/sanitize.js
+++ b/lib/sanitize.js
@@ -1,19 +1,17 @@
-const DANGEROUS_TAGS = ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'base', 'form'];
+import DOMPurify from "isomorphic-dompurify";
export function sanitizeHtml(html) {
- if (!html || typeof html !== 'string') return '';
+ if (!html || typeof html !== "string") return "";
- let sanitized = html;
-
- DANGEROUS_TAGS.forEach((tag) => {
- const openClose = new RegExp(`<${tag}[\\s\\S]*?(?:<\\/${tag}>|/?>)`, 'gi');
- sanitized = sanitized.replace(openClose, '');
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: [
+ "p", "br", "strong", "em", "b", "i", "u", "ul", "ol", "li", "a",
+ "h2", "h3", "h4", "h5", "blockquote", "img", "span", "div",
+ "table", "thead", "tbody", "tr", "td", "th",
+ ],
+ ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"],
+ ALLOW_DATA_ATTR: false,
});
-
- sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
- sanitized = sanitized.replace(/(?:javascript|vbscript):/gi, '');
-
- return sanitized;
}
export function safeJsonParse(str, fallback = null) {
diff --git a/lib/serverToken.js b/lib/serverToken.js
new file mode 100644
index 0000000..6f632ce
--- /dev/null
+++ b/lib/serverToken.js
@@ -0,0 +1,19 @@
+import { cookies } from "next/headers";
+import { axiosInstance } from "@/lib/req";
+
+export async function getServerAccessToken() {
+ const cookieStore = await cookies();
+ const refreshToken = cookieStore.get("refresh_token")?.value;
+ if (!refreshToken) return null;
+
+ try {
+ const res = await axiosInstance.post(
+ `${process.env.NEXT_PUBLIC_API_URL}/oauth/token/refresh`,
+ { refresh_token: refreshToken },
+ { headers: { "Content-Type": "application/json", Authorization: "" } }
+ );
+ return res.data?.access_token ?? null;
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/tokenStore.js b/lib/tokenStore.js
new file mode 100644
index 0000000..763800a
--- /dev/null
+++ b/lib/tokenStore.js
@@ -0,0 +1,13 @@
+let accessToken = null;
+
+export function getAccessToken() {
+ return accessToken;
+}
+
+export function setAccessToken(token) {
+ accessToken = token || null;
+}
+
+export function clearAccessToken() {
+ accessToken = null;
+}
diff --git a/next.config.js b/next.config.js
index 461abcb..263efe7 100644
--- a/next.config.js
+++ b/next.config.js
@@ -40,6 +40,34 @@ const nextConfig = {
poweredByHeader: false,
// Enable standalone output for Docker
output: 'standalone',
+ async headers() {
+ const csp = [
+ "default-src 'self'",
+ "img-src 'self' https: data: blob:",
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
+ "style-src 'self' 'unsafe-inline'",
+ "font-src 'self' data:",
+ "connect-src 'self' https://api.clinic-pro.ir https://back-dev.clinic-pro.ir",
+ "frame-ancestors 'none'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ ].join('; ');
+
+ return [
+ {
+ source: '/(.*)',
+ headers: [
+ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
+ { key: 'Content-Security-Policy', value: csp },
+ ],
+ },
+ ];
+ },
};
module.exports = nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 1030427..23a630e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"dayjs": "^1.11.19",
"html2canvas": "^1.4.1",
"i": "^0.3.7",
+ "isomorphic-dompurify": "^3.18.0",
"jalaali-js": "^1.2.8",
"jalali-moment": "^3.3.11",
"js-cookie": "^3.0.5",
@@ -68,6 +69,53 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -207,6 +255,18 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
"node_modules/@casl/ability": {
"version": "6.7.3",
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.3.tgz",
@@ -229,6 +289,140 @@
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz",
+ "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
+ "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@emnapi/runtime": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
@@ -404,6 +598,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@@ -1978,6 +2189,15 @@
],
"license": "MIT"
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2368,6 +2588,19 @@
"postcss-value-parser": "^4.0.2"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2510,6 +2743,19 @@
"node": ">=12"
}
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -2552,6 +2798,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@@ -2620,11 +2872,10 @@
}
},
"node_modules/dompurify": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
- "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
+ "version": "3.4.11",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+ "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
- "optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -2649,6 +2900,18 @@
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -3066,6 +3329,18 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -3283,6 +3558,12 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "license": "MIT"
+ },
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -3302,6 +3583,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/isomorphic-dompurify": {
+ "version": "3.18.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.18.0.tgz",
+ "integrity": "sha512-ajp0D8laIHeoYlhBTevpE2HUhqWaqLXFk6K/wV3Ok8kDraBZpZsifwVWaY8IfJntMRIo1VSksgKV+lXyet9Q7A==",
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "^3.4.11",
+ "jsdom": "^29.1.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ }
+ },
"node_modules/jalaali-js": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
@@ -3352,6 +3646,46 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3479,6 +3813,15 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/maplibre-gl": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.2.tgz",
@@ -3525,6 +3868,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5886,6 +6235,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6203,6 +6564,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -6428,6 +6798,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -6584,6 +6963,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -6988,6 +7379,12 @@
"node": ">= 4.7.0"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -7113,6 +7510,24 @@
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
+ "node_modules/tldts": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
+ "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.3"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
+ "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7125,6 +7540,30 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -7149,6 +7588,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/uninstall": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/uninstall/-/uninstall-0.0.0.tgz",
@@ -7183,6 +7631,18 @@
"uuid": "dist/esm/bin/uuid"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -7192,6 +7652,38 @@
"defaults": "^1.0.3"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7228,6 +7720,21 @@
"node": ">=8"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
diff --git a/package.json b/package.json
index 3c31661..17ca12e 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"dayjs": "^1.11.19",
"html2canvas": "^1.4.1",
"i": "^0.3.7",
+ "isomorphic-dompurify": "^3.18.0",
"jalaali-js": "^1.2.8",
"jalali-moment": "^3.3.11",
"js-cookie": "^3.0.5",
@@ -35,6 +36,7 @@
"next": "^15.5.7",
"next-themes": "^0.4.6",
"npm": "^11.7.0",
+ "postcss": "^8.5.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
@@ -49,7 +51,6 @@
"stylis-plugin-rtl": "^2.1.1",
"swiper": "^12.0.3",
"tailwindcss": "^3.4.19",
- "postcss": "^8.5.6",
"uninstall": "^0.0.0"
},
"devDependencies": {
@@ -60,4 +61,4 @@
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
-}
\ No newline at end of file
+}
diff --git a/services/api.js b/services/api.js
index 1028394..6c354d0 100644
--- a/services/api.js
+++ b/services/api.js
@@ -1,6 +1,7 @@
import axios from "axios";
import Cookies from "js-cookie";
import { toast } from "react-toastify";
+import { getAccessToken, setAccessToken, clearAccessToken } from "@/lib/tokenStore";
function extractErrorMessage(error) {
const errors = error?.response?.data?.errors;
@@ -17,48 +18,77 @@ const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
const api = axios.create({
baseURL: BASE_URL,
- "X-CSRF-Token": "",
headers: {
"Content-Type": "application/json",
},
});
-// اضافه کردن interceptor برای افزودن token به ریکوستهایی که نیاز به احراز هویت دارند
+let refreshPromise = null;
+
+async function refreshAccessToken() {
+ if (!refreshPromise) {
+ refreshPromise = fetch("/api/auth/refresh", { method: "POST" })
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => {
+ const token = data?.access_token ?? null;
+ setAccessToken(token);
+ return token;
+ })
+ .catch(() => null)
+ .finally(() => {
+ refreshPromise = null;
+ });
+ }
+ return refreshPromise;
+}
+
+function handleSessionExpired() {
+ clearAccessToken();
+ const hostname = window.location.hostname;
+ const domain = hostname.includes("localhost")
+ ? undefined
+ : `.${hostname.split(".").slice(-2).join(".")}`;
+ const opts = { path: "/", ...(domain && { domain }) };
+ ["uuid", "userInfo"].forEach((key) => Cookies.remove(key, opts));
+ window.location.href = "/login";
+}
+
api.interceptors.request.use(
(config) => {
- // فقط اگر requireAuth در config تنظیم شده باشد، token اضافه میشود
if (config.requireAuth) {
- const token = Cookies.get("access_token");
+ const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
- (error) => {
- return Promise.reject(error);
- }
+ (error) => Promise.reject(error)
);
api.interceptors.response.use(
- (response) => {
- return response.data;
- },
- (error) => {
- if (typeof window !== "undefined") {
- if (error?.response?.status === 401) {
- const hostname = window.location.hostname;
- const domain = hostname.includes("localhost")
- ? undefined
- : `.${hostname.split(".").slice(-2).join(".")}`;
- const opts = { path: "/", ...(domain && { domain }) };
- ["access_token", "refresh_token", "uuid", "userInfo"].forEach((key) => {
- Cookies.remove(key, opts);
- });
- window.location.href = "/login";
- } else if (!error?.config?.skipErrorToast) {
- toast.error(extractErrorMessage(error));
+ (response) => response.data,
+ async (error) => {
+ const original = error?.config;
+
+ if (
+ error?.response?.status === 401 &&
+ original?.requireAuth &&
+ !original._retried &&
+ typeof window !== "undefined"
+ ) {
+ original._retried = true;
+ const token = await refreshAccessToken();
+ if (token) {
+ original.headers = { ...original.headers, Authorization: `Bearer ${token}` };
+ return api(original);
}
+ handleSessionExpired();
+ return Promise.reject(error);
+ }
+
+ if (typeof window !== "undefined" && !error?.config?.skipErrorToast) {
+ toast.error(extractErrorMessage(error));
}
return Promise.reject(error);
}
diff --git a/services/response.js b/services/response.js
index f2c8888..dbe949a 100644
--- a/services/response.js
+++ b/services/response.js
@@ -11,30 +11,6 @@ export const request = {
},
removeTokenHead
),
- getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
- const formData = new URLSearchParams();
- formData.append("grant_type", grant_type);
- formData.append("client_id", client_id);
- formData.append("client_secret", client_secret);
- formData.append("uuid", uuid);
- formData.append("code", code);
- formData.append("scope", scope);
-
- return api.post("oauth/token", formData, {
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- Authorization: "",
- },
- });
- },
- postRefreshToken: (formData) => {
- return api.post("oauth/token", formData, {
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- Authorization: "",
- },
- });
- },
getUserProfile: (uuid) => api.get(`/api/v1/user-profile/${uuid}`, { requireAuth: true }),
postUserProfile: (data) => api.post("api/v1/user-profile", data, { requireAuth: true }),
patchUserProfile: (data, uuid) =>
diff --git a/utils/index.js b/utils/index.js
index d56fad2..9b8587d 100644
--- a/utils/index.js
+++ b/utils/index.js
@@ -1,10 +1,12 @@
import Cookies from "js-cookie";
+import { clearAccessToken } from "@/lib/tokenStore";
const removeToken = () => {
- Cookies.remove("access_token", { path: "/" });
- Cookies.remove("refresh_token", { path: "/" });
Cookies.remove("uuid", { path: "/" });
Cookies.remove("userInfo", { path: "/" });
+ if (typeof window !== "undefined") {
+ clearAccessToken();
+ }
};
export { removeToken };