diff --git a/.devdbrc b/.devdbrc
new file mode 100644
index 0000000..1365d3e
--- /dev/null
+++ b/.devdbrc
@@ -0,0 +1,6 @@
+[
+ {
+ "type": "sqlite",
+ "path": "c:\\xampp\\htdocs\\SaaS-PDF\\backend\\data\\saas_pdf.db"
+ }
+]
\ No newline at end of file
diff --git a/.env.example b/.env.example
index 6e812b7..dc84f41 100644
--- a/.env.example
+++ b/.env.example
@@ -56,6 +56,9 @@ SENTRY_ENVIRONMENT=production
# PostgreSQL (production) — leave empty to use SQLite
DATABASE_URL=
+POSTGRES_DB=dociva
+POSTGRES_USER=dociva
+POSTGRES_PASSWORD=replace-with-strong-postgres-password
# Frontend
VITE_SITE_DOMAIN=https://dociva.io
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
index 0192858..e4d682d 100644
--- a/backend/app/__init__.py
+++ b/backend/app/__init__.py
@@ -1,7 +1,7 @@
"""Flask Application Factory."""
import os
-from flask import Flask
+from flask import Flask, jsonify
from config import config
from app.extensions import cors, limiter, talisman, init_celery
@@ -11,6 +11,7 @@ from app.services.ai_cost_service import init_ai_cost_db
from app.services.site_assistant_service import init_site_assistant_db
from app.services.contact_service import init_contact_db
from app.services.stripe_service import init_stripe_db
+from app.utils.csrf import CSRFError, apply_csrf_cookie, should_enforce_csrf, validate_csrf_request
def _init_sentry(app):
@@ -48,9 +49,10 @@ def create_app(config_name=None):
# Create upload/output/database directories
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True)
- db_dir = os.path.dirname(app.config["DATABASE_PATH"])
- if db_dir:
- os.makedirs(db_dir, exist_ok=True)
+ if not app.config.get("DATABASE_URL"):
+ db_dir = os.path.dirname(app.config["DATABASE_PATH"])
+ if db_dir:
+ os.makedirs(db_dir, exist_ok=True)
# Initialize extensions
cors.init_app(
@@ -97,6 +99,22 @@ def create_app(config_name=None):
force_https=config_name == "production",
)
+ @app.before_request
+ def enforce_csrf():
+ if not should_enforce_csrf():
+ return None
+
+ try:
+ validate_csrf_request()
+ except CSRFError as exc:
+ return jsonify({"error": exc.message}), exc.status_code
+
+ return None
+
+ @app.after_request
+ def sync_csrf_cookie(response):
+ return apply_csrf_cookie(response)
+
# Initialize Celery
init_celery(app)
diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py
index 4f5eb0d..34cfd0b 100644
--- a/backend/app/routes/auth.py
+++ b/backend/app/routes/auth.py
@@ -19,6 +19,7 @@ from app.utils.auth import (
login_user_session,
logout_user_session,
)
+from app.utils.csrf import get_or_create_csrf_token
auth_bp = Blueprint("auth", __name__)
@@ -105,6 +106,13 @@ def me_route():
return jsonify({"authenticated": True, "user": user}), 200
+@auth_bp.route("/csrf", methods=["GET"])
+@limiter.limit("240/hour")
+def csrf_route():
+ """Return the active CSRF token for SPA bootstrap flows."""
+ return jsonify({"csrf_token": get_or_create_csrf_token()}), 200
+
+
@auth_bp.route("/forgot-password", methods=["POST"])
@limiter.limit("5/hour")
def forgot_password_route():
diff --git a/backend/app/utils/csrf.py b/backend/app/utils/csrf.py
new file mode 100644
index 0000000..9652f4f
--- /dev/null
+++ b/backend/app/utils/csrf.py
@@ -0,0 +1,77 @@
+"""Lightweight CSRF protection for browser-originated session requests."""
+import secrets
+
+from flask import current_app, request, session
+
+CSRF_SESSION_KEY = "csrf_token"
+CSRF_COOKIE_NAME = "csrf_token"
+CSRF_HEADER_NAME = "X-CSRF-Token"
+_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
+_EXEMPT_PATHS = {
+ "/api/stripe/webhook",
+}
+
+
+class CSRFError(Exception):
+ """Raised when CSRF validation fails."""
+
+ def __init__(self, message: str = "Invalid CSRF token.", status_code: int = 403):
+ super().__init__(message)
+ self.message = message
+ self.status_code = status_code
+
+
+def get_or_create_csrf_token() -> str:
+ """Return the current CSRF token, creating one when missing."""
+ token = session.get(CSRF_SESSION_KEY)
+ if not isinstance(token, str) or not token:
+ token = secrets.token_urlsafe(32)
+ session[CSRF_SESSION_KEY] = token
+ return token
+
+
+def should_enforce_csrf() -> bool:
+ """Return whether the current request should pass CSRF validation."""
+ if request.method.upper() in _SAFE_METHODS:
+ return False
+
+ if not request.path.startswith("/api/"):
+ return False
+
+ if request.path in _EXEMPT_PATHS:
+ return False
+
+ if request.headers.get("X-API-Key", "").strip():
+ return False
+
+ return True
+
+
+def validate_csrf_request():
+ """Validate the current request against the active browser CSRF token."""
+ session_token = session.get(CSRF_SESSION_KEY)
+ cookie_token = request.cookies.get(CSRF_COOKIE_NAME, "")
+ header_token = request.headers.get(CSRF_HEADER_NAME, "").strip()
+
+ if not isinstance(session_token, str) or not session_token:
+ raise CSRFError("CSRF session token is missing.")
+
+ if not cookie_token or cookie_token != session_token:
+ raise CSRFError("CSRF cookie token is missing or invalid.")
+
+ if not header_token or header_token != session_token:
+ raise CSRFError("CSRF header token is missing or invalid.")
+
+
+def apply_csrf_cookie(response):
+ """Persist the active CSRF token into a readable cookie for the SPA."""
+ token = get_or_create_csrf_token()
+ response.set_cookie(
+ CSRF_COOKIE_NAME,
+ token,
+ secure=bool(current_app.config.get("SESSION_COOKIE_SECURE", False)),
+ httponly=False,
+ samesite=current_app.config.get("SESSION_COOKIE_SAMESITE", "Lax"),
+ path="/",
+ )
+ return response
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index a36711b..642ec68 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -11,6 +11,37 @@ from app.services.ai_cost_service import init_ai_cost_db
from app.services.site_assistant_service import init_site_assistant_db
from app.services.contact_service import init_contact_db
from app.services.stripe_service import init_stripe_db
+from flask.testing import FlaskClient
+from werkzeug.datastructures import Headers
+
+
+class CSRFTestClient(FlaskClient):
+ """Flask test client that auto-injects the SPA CSRF header for browser requests."""
+
+ def open(self, *args, **kwargs):
+ path = args[0] if args and isinstance(args[0], str) else kwargs.get("path", "")
+ method = str(kwargs.get("method", "GET")).upper()
+ headers = Headers(kwargs.pop("headers", {}))
+
+ should_add_csrf = (
+ method in {"POST", "PUT", "PATCH", "DELETE"}
+ and path != "/api/stripe/webhook"
+ and "X-API-Key" not in headers
+ and "X-CSRF-Token" not in headers
+ )
+
+ if should_add_csrf:
+ token_cookie = self.get_cookie("csrf_token")
+ if token_cookie is None:
+ FlaskClient.open(self, "/api/auth/csrf", method="GET")
+ token_cookie = self.get_cookie("csrf_token")
+ if token_cookie is not None:
+ headers["X-CSRF-Token"] = token_cookie.value
+
+ if headers:
+ kwargs["headers"] = headers
+
+ return super().open(*args, **kwargs)
@pytest.fixture
@@ -26,6 +57,7 @@ def app():
os.environ['OUTPUT_FOLDER'] = output_folder
app = create_app('testing')
+ app.test_client_class = CSRFTestClient
app.config.update({
'TESTING': True,
'UPLOAD_FOLDER': upload_folder,
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index d67d7b3..df5ba86 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -2,6 +2,13 @@
class TestAuthRoutes:
+ def test_csrf_bootstrap_returns_token(self, client):
+ response = client.get('/api/auth/csrf')
+
+ assert response.status_code == 200
+ assert isinstance(response.get_json()['csrf_token'], str)
+ assert response.get_json()['csrf_token']
+
def test_register_success(self, client):
response = client.post(
'/api/auth/register',
@@ -77,3 +84,13 @@ class TestAuthRoutes:
assert response.status_code == 200
assert response.get_json() == {'authenticated': False, 'user': None}
+
+ def test_register_rejects_invalid_csrf_token(self, client):
+ response = client.post(
+ '/api/auth/register',
+ json={'email': 'csrf@example.com', 'password': 'secretpass123'},
+ headers={'X-CSRF-Token': 'invalid-token'},
+ )
+
+ assert response.status_code == 403
+ assert 'csrf' in response.get_json()['error'].lower()
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index c1262a2..d1c0070 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -1,4 +1,20 @@
services:
+ # --- PostgreSQL (launch-ready cutover target) ---
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_DB=${POSTGRES_DB:-dociva}
+ - POSTGRES_USER=${POSTGRES_USER:-dociva}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change-me}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dociva} -d ${POSTGRES_DB:-dociva}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: always
+
# --- Redis ---
redis:
image: redis:7-alpine
@@ -23,11 +39,14 @@ services:
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
+ - DATABASE_URL=${DATABASE_URL:-}
volumes:
- upload_data:/tmp/uploads
- output_data:/tmp/outputs
- db_data:/app/data
depends_on:
+ postgres:
+ condition: service_healthy
redis:
condition: service_healthy
restart: always
@@ -49,11 +68,14 @@ services:
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
+ - DATABASE_URL=${DATABASE_URL:-}
volumes:
- upload_data:/tmp/uploads
- output_data:/tmp/outputs
- db_data:/app/data
depends_on:
+ postgres:
+ condition: service_healthy
redis:
condition: service_healthy
restart: always
@@ -73,9 +95,12 @@ services:
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
+ - DATABASE_URL=${DATABASE_URL:-}
volumes:
- db_data:/app/data
depends_on:
+ postgres:
+ condition: service_healthy
redis:
condition: service_healthy
restart: always
@@ -120,6 +145,7 @@ services:
- frontend_build:/app/dist
volumes:
+ postgres_data:
redis_data:
upload_data:
output_data:
diff --git a/fixed-ssl.md b/fixed-ssl.md
deleted file mode 100644
index d5a5e96..0000000
--- a/fixed-ssl.md
+++ /dev/null
@@ -1,273 +0,0 @@
-سؤالك ممتاز—وده بالضبط الفرق بين **حل “يشتغل وخلاص”** وحل **Production حقيقي**.
-
-خليني أشرح لك الأول **ليه الطريقة الأولى (certbot --nginx) مش مناسبة** عندك، وبعدين أديك **الحل الصحيح الجاهز (Nginx داخل Docker + SSL + Auto renew)** copy/paste.
-
----
-
-# لماذا `certbot --nginx` مش مناسب عندك؟
-
-أنت عندك:
-
-```text
-Nginx شغال داخل Docker (saas-pdf-nginx-1)
-```
-
-لكن الأمر:
-
-```bash
-certbot --nginx
-```
-
-بيفترض إن:
-
-```text
-Nginx مثبت على السيرفر (host)
-```
-
-📌 النتيجة:
-
-* Certbot حاول يتحكم في Nginx الخارجي
-* لكن البورت 80 مستخدم بالفعل بواسطة Docker
-* حصل الخطأ: `Address already in use`
-
----
-
-# هل الحل اللي عملناه “مؤقت”؟
-
-لا 👇
-
-## عندنا 3 مستويات حلول:
-
-### 🥉 حل بدائي
-
-تشغل Certbot خارج Docker → كل مرة توقف Nginx
-❌ غير عملي
-
----
-
-### 🥈 حل متوسط
-
-Certbot standalone + manual config
-✔ شغال
-❌ لكن renewal مش أوتوماتيك بسهولة
-
----
-
-### 🥇 الحل الاحترافي (اللي هنطبقه الآن)
-
-```text
-Nginx + Certbot داخل Docker
-```
-
-✔ Auto renew
-✔ scalable
-✔ نفس طريقة الشركات الكبيرة
-
----
-
-# المشاكل لو ما عملتش الحل الصحيح
-
-لو كملت بالحل القديم:
-
-* ❌ SSL هينتهي كل 90 يوم
-* ❌ الموقع هيقع فجأة
-* ❌ Google هيقلل ترتيبك
-* ❌ AdSense ممكن يرفضك
-* ❌ صعب التوسعة لاحقًا
-
----
-
-# الآن الحل الاحترافي (جاهز للتطبيق)
-
-هنعمل 3 حاجات:
-
-```text
-1. nginx config احترافي
-2. docker-compose فيه SSL
-3. auto renew
-```
-
----
-
-# 1️⃣ تعديل nginx config
-
-ادخل:
-
-```bash
-cd ~/SaaS-PDF/nginx
-nano default.conf
-```
-
-واستبدل كله بهذا:
-
-```nginx
-server {
- listen 80;
- server_name dociva.io www.dociva.io;
-
- location /.well-known/acme-challenge/ {
- root /var/www/certbot;
- }
-
- location / {
- return 301 https://$host$request_uri;
- }
-}
-
-server {
- listen 443 ssl;
- server_name dociva.io www.dociva.io;
-
- ssl_certificate /etc/letsencrypt/live/dociva.io/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/dociva.io/privkey.pem;
-
- location / {
- proxy_pass http://frontend:5173;
- }
-}
-```
-
----
-
-# 2️⃣ تعديل docker-compose.yml
-
-افتح:
-
-```bash
-nano docker-compose.yml
-```
-
-وابحث عن nginx وعدله:
-
-```yaml
-nginx:
- image: nginx:alpine
- ports:
- - "80:80"
- - "443:443"
- volumes:
- - ./nginx:/etc/nginx/conf.d
- - ./certbot/www:/var/www/certbot
- - ./certbot/conf:/etc/letsencrypt
- depends_on:
- - frontend
-```
-
----
-
-# 3️⃣ إضافة certbot container
-
-في نفس الملف أضف:
-
-```yaml
-certbot:
- image: certbot/certbot
- volumes:
- - ./certbot/www:/var/www/certbot
- - ./certbot/conf:/etc/letsencrypt
-```
-
----
-
-# 4️⃣ تشغيل المشروع
-
-```bash
-docker compose down
-docker compose up -d
-```
-
----
-
-# 5️⃣ إصدار الشهادة (مرة واحدة)
-
-```bash
-docker compose run --rm certbot certonly \
---webroot \
---webroot-path=/var/www/certbot \
--d dociva.io -d www.dociva.io \
---email your@email.com \
---agree-tos --no-eff-email
-```
-
----
-
-# 6️⃣ إعادة تشغيل Nginx
-
-```bash
-docker compose restart nginx
-```
-
----
-
-# 7️⃣ اختبار
-
-```text
-https://dociva.io
-```
-
-🔥 المفروض يشتغل
-
----
-
-# 8️⃣ auto renew (مهم جداً)
-
-أضف cron job:
-
-```bash
-crontab -e
-```
-
-وأضف:
-
-```bash
-0 3 * * * docker compose run --rm certbot renew && docker compose restart nginx
-```
-
----
-
-# النتيجة النهائية
-
-✔ HTTPS شغال
-✔ Auto renew
-✔ Production ready
-✔ مناسب لـ SEO + AdSense
-
----
-
-# خلاصة بسيطة
-
-| الطريقة | هل مناسبة؟ |
-| ---------------- | ---------- |
-| certbot --nginx | ❌ لا |
-| standalone | ⚠️ مؤقت |
-| Docker SSL setup | ✅ الأفضل |
-
----
-
-# رأيي كمستشار
-
-أنت حالياً وصلت لمستوى:
-
-```text
-Junior → Mid-level DevOps 🚀
-```
-
-ولو كملت بالشكل ده مشروعك فعلاً ممكن يتحول لمنتج حقيقي.
-
----
-
-# الخطوة التالية بعد SSL
-
-بعد ما نخلص:
-
-1. Google Search Console
-2. Sitemap
-3. SEO pages
-4. Analytics
-
----
-
-لو حابب، الخطوة الجاية أعملك:
-
-👉 **إعداد SEO كامل يجلب أول 10,000 زيارة**
-بطريقة عملية مش كلام نظري.
diff --git a/frontend/index.html b/frontend/index.html
index 834fab1..2afc173 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,6 +5,7 @@
+
diff --git a/frontend/src/components/seo/SEOHead.tsx b/frontend/src/components/seo/SEOHead.tsx
index c57a6c3..489a62c 100644
--- a/frontend/src/components/seo/SEOHead.tsx
+++ b/frontend/src/components/seo/SEOHead.tsx
@@ -1,6 +1,6 @@
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
-import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo';
+import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale, getSiteOrigin } from '@/utils/seo';
const SITE_NAME = 'Dociva';
@@ -26,7 +26,7 @@ interface SEOHeadProps {
*/
export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) {
const { i18n } = useTranslation();
- const origin = typeof window !== 'undefined' ? window.location.origin : '';
+ const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const canonicalUrl = `${origin}${path}`;
const socialImageUrl = buildSocialImageUrl(origin);
const fullTitle = `${title} — ${SITE_NAME}`;
diff --git a/frontend/src/components/seo/ToolLandingPage.tsx b/frontend/src/components/seo/ToolLandingPage.tsx
index 0a27d61..97bf612 100644
--- a/frontend/src/components/seo/ToolLandingPage.tsx
+++ b/frontend/src/components/seo/ToolLandingPage.tsx
@@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { CheckCircle } from 'lucide-react';
import { getToolSEO } from '@/config/seoData';
-import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo';
+import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale, getSiteOrigin } from '@/utils/seo';
import FAQSection from './FAQSection';
import RelatedTools from './RelatedTools';
import ToolRating from '@/components/shared/ToolRating';
@@ -37,7 +37,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
const toolTitle = t(`tools.${seo.i18nKey}.title`);
const toolDesc = t(`tools.${seo.i18nKey}.description`);
- const origin = typeof window !== 'undefined' ? window.location.origin : '';
+ const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const path = `/tools/${slug}`;
const canonicalUrl = `${origin}${path}`;
const socialImageUrl = buildSocialImageUrl(origin);
diff --git a/frontend/src/pages/AboutPage.tsx b/frontend/src/pages/AboutPage.tsx
index e9a8f07..d34594f 100644
--- a/frontend/src/pages/AboutPage.tsx
+++ b/frontend/src/pages/AboutPage.tsx
@@ -1,12 +1,13 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead';
-import { generateWebPage } from '@/utils/seo';
+import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { Target, Cpu, Shield, Lock, Wrench } from 'lucide-react';
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
export default function AboutPage() {
const { t } = useTranslation();
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[];
return (
@@ -18,7 +19,7 @@ export default function AboutPage() {
jsonLd={generateWebPage({
name: t('pages.about.title'),
description: t('pages.about.metaDescription'),
- url: `${window.location.origin}/about`,
+ url: `${siteOrigin}/about`,
})}
/>
diff --git a/frontend/src/pages/BlogPage.tsx b/frontend/src/pages/BlogPage.tsx
index 102e511..a098367 100644
--- a/frontend/src/pages/BlogPage.tsx
+++ b/frontend/src/pages/BlogPage.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead';
-import { generateWebPage } from '@/utils/seo';
+import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { BookOpen, Calendar, ArrowRight, Search, X } from 'lucide-react';
import {
BLOG_ARTICLES,
@@ -13,6 +13,7 @@ import {
export default function BlogPage() {
const { t, i18n } = useTranslation();
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
@@ -46,7 +47,7 @@ export default function BlogPage() {
jsonLd={generateWebPage({
name: t('pages.blog.metaTitle'),
description: t('pages.blog.metaDescription'),
- url: `${window.location.origin}/blog`,
+ url: `${siteOrigin}/blog`,
})}
/>
diff --git a/frontend/src/pages/BlogPostPage.tsx b/frontend/src/pages/BlogPostPage.tsx
index fa6f8c6..1616add 100644
--- a/frontend/src/pages/BlogPostPage.tsx
+++ b/frontend/src/pages/BlogPostPage.tsx
@@ -9,7 +9,7 @@ import {
getLocalizedBlogArticle,
normalizeBlogLocale,
} from '@/content/blogArticles';
-import { generateBlogPosting, generateBreadcrumbs, generateWebPage } from '@/utils/seo';
+import { generateBlogPosting, generateBreadcrumbs, generateWebPage, getSiteOrigin } from '@/utils/seo';
import NotFoundPage from './NotFoundPage';
export default function BlogPostPage() {
@@ -17,6 +17,7 @@ export default function BlogPostPage() {
const { t, i18n } = useTranslation();
const locale = normalizeBlogLocale(i18n.language);
const article = slug ? getBlogArticleBySlug(slug) : undefined;
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
if (!article) {
return ;
@@ -24,11 +25,11 @@ export default function BlogPostPage() {
const localizedArticle = getLocalizedBlogArticle(article, locale);
const path = `/blog/${localizedArticle.slug}`;
- const url = `${window.location.origin}${path}`;
+ const url = `${siteOrigin}${path}`;
const breadcrumbs = generateBreadcrumbs([
- { name: t('common.home'), url: window.location.origin },
- { name: t('common.blog'), url: `${window.location.origin}/blog` },
+ { name: t('common.home'), url: siteOrigin },
+ { name: t('common.blog'), url: `${siteOrigin}/blog` },
{ name: localizedArticle.title, url },
]);
diff --git a/frontend/src/pages/ContactPage.tsx b/frontend/src/pages/ContactPage.tsx
index c82e7a6..de8fbb7 100644
--- a/frontend/src/pages/ContactPage.tsx
+++ b/frontend/src/pages/ContactPage.tsx
@@ -3,16 +3,18 @@ import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import SEOHead from '@/components/seo/SEOHead';
-import { generateWebPage } from '@/utils/seo';
-import axios from 'axios';
+import { generateWebPage, getSiteOrigin } from '@/utils/seo';
+import { getApiClient } from '@/services/api';
const CONTACT_EMAIL = 'support@dociva.io';
const API_BASE = import.meta.env.VITE_API_URL || '';
+const api = getApiClient();
type Category = 'general' | 'bug' | 'feature';
export default function ContactPage() {
const { t } = useTranslation();
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const [category, setCategory] = useState('general');
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
@@ -29,7 +31,7 @@ export default function ContactPage() {
const data = new FormData(form);
try {
- await axios.post(`${API_BASE}/api/contact/submit`, {
+ await api.post(`${API_BASE}/contact/submit`, {
name: data.get('name'),
email: data.get('email'),
category,
@@ -38,10 +40,10 @@ export default function ContactPage() {
});
setSubmitted(true);
} catch (err: unknown) {
- if (axios.isAxiosError(err) && err.response?.data?.error) {
- setError(err.response.data.error);
+ if (err instanceof Error) {
+ setError(err.message);
} else {
- setError(t('pages.contact.errorMessage', 'Failed to send message. Please try again.'));
+ setError(err.response.data.error);
}
} finally {
setLoading(false);
@@ -79,7 +81,7 @@ export default function ContactPage() {
jsonLd={generateWebPage({
name: t('pages.contact.title'),
description: t('pages.contact.metaDescription'),
- url: `${window.location.origin}/contact`,
+ url: `${siteOrigin}/contact`,
})}
/>
diff --git a/frontend/src/pages/DevelopersPage.tsx b/frontend/src/pages/DevelopersPage.tsx
index f6ed8c8..5299e20 100644
--- a/frontend/src/pages/DevelopersPage.tsx
+++ b/frontend/src/pages/DevelopersPage.tsx
@@ -1,6 +1,6 @@
import SEOHead from '@/components/seo/SEOHead';
import SocialProofStrip from '@/components/shared/SocialProofStrip';
-import { generateWebPage } from '@/utils/seo';
+import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Code2, KeyRound, Rocket, Workflow } from 'lucide-react';
@@ -24,7 +24,7 @@ const ENDPOINT_GROUPS = [
export default function DevelopersPage() {
const { t } = useTranslation();
- const origin = typeof window !== 'undefined' ? window.location.origin : 'https://dociva.io';
+ const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\
-H "X-API-Key: spdf_your_api_key" \\
-F "file=@./sample.pdf"`;
@@ -40,7 +40,7 @@ export default function DevelopersPage() {
jsonLd={generateWebPage({
name: t('pages.developers.title'),
description: t('pages.developers.metaDescription'),
- url: `${window.location.origin}/developers`,
+ url: `${origin}/developers`,
})}
/>
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index ee5afd4..1351cb1 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -2,7 +2,7 @@ import { useDeferredValue } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead';
-import { generateOrganization } from '@/utils/seo';
+import { generateOrganization, getSiteOrigin } from '@/utils/seo';
import {
FileText,
FileOutput,
@@ -86,6 +86,7 @@ const otherTools: ToolInfo[] = [
export default function HomePage() {
const { t } = useTranslation();
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
@@ -123,15 +124,15 @@ export default function HomePage() {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: t('common.appName'),
- url: window.location.origin,
+ url: siteOrigin,
description: t('home.heroSub'),
potentialAction: {
'@type': 'SearchAction',
- target: `${window.location.origin}/?q={search_term_string}`,
+ target: `${siteOrigin}/?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
},
- generateOrganization(window.location.origin),
+ generateOrganization(siteOrigin),
]}
/>
diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx
index eb602fc..eeb3465 100644
--- a/frontend/src/pages/PricingPage.tsx
+++ b/frontend/src/pages/PricingPage.tsx
@@ -2,13 +2,14 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead';
-import { generateWebPage } from '@/utils/seo';
+import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { Check, X, Zap, Crown, Loader2 } from 'lucide-react';
-import axios from 'axios';
import { useAuthStore } from '@/stores/authStore';
import SocialProofStrip from '@/components/shared/SocialProofStrip';
+import { getApiClient } from '@/services/api';
const API_BASE = import.meta.env.VITE_API_URL || '';
+const api = getApiClient();
interface PlanFeature {
key: string;
@@ -31,6 +32,7 @@ const FEATURES: PlanFeature[] = [
export default function PricingPage() {
const { t } = useTranslation();
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const user = useAuthStore((s) => s.user);
const [loading, setLoading] = useState(false);
@@ -41,11 +43,7 @@ export default function PricingPage() {
}
setLoading(true);
try {
- const { data } = await axios.post(
- `${API_BASE}/api/stripe/create-checkout-session`,
- { billing },
- { withCredentials: true },
- );
+ const { data } = await api.post(`${API_BASE}/stripe/create-checkout-session`, { billing });
if (data.url) window.location.href = data.url;
} catch {
// Stripe not configured yet — show message
@@ -70,7 +68,7 @@ export default function PricingPage() {
jsonLd={generateWebPage({
name: t('pages.pricing.title', 'Pricing'),
description: t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva.'),
- url: `${window.location.origin}/pricing`,
+ url: `${siteOrigin}/pricing`,
})}
/>
diff --git a/frontend/src/pages/PrivacyPage.tsx b/frontend/src/pages/PrivacyPage.tsx
index 058c3b4..a54bd90 100644
--- a/frontend/src/pages/PrivacyPage.tsx
+++ b/frontend/src/pages/PrivacyPage.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import SEOHead from '@/components/seo/SEOHead';
-import { generateWebPage } from '@/utils/seo';
+import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
const LAST_UPDATED = '2026-03-06';
@@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io';
export default function PrivacyPage() {
const { t } = useTranslation();
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const fileItems = t('pages.privacy.fileHandlingItems', { minutes: FILE_RETENTION_MINUTES, returnObjects: true }) as string[];
const thirdPartyItems = t('pages.privacy.thirdPartyItems', { returnObjects: true }) as string[];
@@ -20,7 +21,7 @@ export default function PrivacyPage() {
jsonLd={generateWebPage({
name: t('pages.privacy.title'),
description: t('pages.privacy.metaDescription'),
- url: `${window.location.origin}/privacy`,
+ url: `${siteOrigin}/privacy`,
})}
/>
diff --git a/frontend/src/pages/TermsPage.tsx b/frontend/src/pages/TermsPage.tsx
index 784ac44..e3caf4f 100644
--- a/frontend/src/pages/TermsPage.tsx
+++ b/frontend/src/pages/TermsPage.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import SEOHead from '@/components/seo/SEOHead';
-import { generateWebPage } from '@/utils/seo';
+import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
const LAST_UPDATED = '2026-03-06';
@@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io';
export default function TermsPage() {
const { t } = useTranslation();
+ const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const useItems = t('pages.terms.useItems', { returnObjects: true }) as string[];
const fileItems = t('pages.terms.fileItems', { minutes: FILE_RETENTION_MINUTES, returnObjects: true }) as string[];
@@ -20,7 +21,7 @@ export default function TermsPage() {
jsonLd={generateWebPage({
name: t('pages.terms.title'),
description: t('pages.terms.metaDescription'),
- url: `${window.location.origin}/terms`,
+ url: `${siteOrigin}/terms`,
})}
/>
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index b9624c4..97ba407 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -1,4 +1,60 @@
-import axios from 'axios';
+import axios, { type InternalAxiosRequestConfig } from 'axios';
+
+const CSRF_COOKIE_NAME = 'csrf_token';
+const CSRF_HEADER_NAME = 'X-CSRF-Token';
+
+
+function getCookieValue(name: string): string {
+ if (typeof document === 'undefined') {
+ return '';
+ }
+
+ const encodedName = `${encodeURIComponent(name)}=`;
+ const cookie = document.cookie
+ .split('; ')
+ .find((item) => item.startsWith(encodedName));
+
+ return cookie ? decodeURIComponent(cookie.slice(encodedName.length)) : '';
+}
+
+
+function shouldAttachCsrfToken(config: InternalAxiosRequestConfig): boolean {
+ const method = String(config.method || 'get').toUpperCase();
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
+ return false;
+ }
+
+ const headers = config.headers ?? {};
+ if ('X-API-Key' in headers || 'x-api-key' in headers) {
+ return false;
+ }
+
+ return !String(config.url || '').includes('/auth/csrf');
+}
+
+
+function setRequestHeader(config: InternalAxiosRequestConfig, key: string, value: string) {
+ if (!config.headers) {
+ config.headers = {};
+ }
+
+ if (typeof (config.headers as { set?: (header: string, headerValue: string) => void }).set === 'function') {
+ (config.headers as { set: (header: string, headerValue: string) => void }).set(key, value);
+ return;
+ }
+
+ (config.headers as Record)[key] = value;
+}
+
+
+const csrfBootstrapClient = axios.create({
+ baseURL: '/api',
+ timeout: 15000,
+ withCredentials: true,
+ headers: {
+ Accept: 'application/json',
+ },
+});
const api = axios.create({
baseURL: '/api',
@@ -11,7 +67,23 @@ const api = axios.create({
// Request interceptor for logging
api.interceptors.request.use(
- (config) => config,
+ async (config) => {
+ if (!shouldAttachCsrfToken(config)) {
+ return config;
+ }
+
+ let csrfToken = getCookieValue(CSRF_COOKIE_NAME);
+ if (!csrfToken) {
+ await csrfBootstrapClient.get('/auth/csrf');
+ csrfToken = getCookieValue(CSRF_COOKIE_NAME);
+ }
+
+ if (csrfToken) {
+ setRequestHeader(config, CSRF_HEADER_NAME, csrfToken);
+ }
+
+ return config;
+ },
(error) => Promise.reject(error)
);
@@ -318,6 +390,10 @@ export async function getTaskStatus(taskId: string): Promise {
return response.data;
}
+export function getApiClient() {
+ return api;
+}
+
/**
* Send one message to the site assistant.
*/
diff --git a/frontend/src/utils/seo.ts b/frontend/src/utils/seo.ts
index f9d28d6..f0c82e3 100644
--- a/frontend/src/utils/seo.ts
+++ b/frontend/src/utils/seo.ts
@@ -18,6 +18,7 @@ export interface LanguageAlternate {
}
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg';
+const DEFAULT_SITE_ORIGIN = 'https://dociva.io';
const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = {
en: { hrefLang: 'en', ogLocale: 'en_US' },
@@ -44,6 +45,19 @@ export function buildLanguageAlternates(origin: string, path: string): LanguageA
}));
}
+export function getSiteOrigin(currentOrigin = ''): string {
+ const configuredOrigin = String(import.meta.env.VITE_SITE_DOMAIN || '').trim().replace(/\/$/, '');
+ if (configuredOrigin) {
+ return configuredOrigin;
+ }
+
+ if (currentOrigin) {
+ return currentOrigin.replace(/\/$/, '');
+ }
+
+ return DEFAULT_SITE_ORIGIN;
+}
+
export function buildSocialImageUrl(origin: string): string {
return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`;
}
diff --git a/nginx/nginx.prod.conf b/nginx/nginx.prod.conf
index a29b2ac..95519d1 100644
--- a/nginx/nginx.prod.conf
+++ b/nginx/nginx.prod.conf
@@ -8,7 +8,7 @@ server {
client_max_body_size 100M;
# Redirect HTTP to HTTPS
- return 301 https://dociva.io$request_uri;
+ return 308 https://dociva.io$request_uri;
}
# Redirect www to non-www
@@ -21,7 +21,7 @@ server {
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
- return 301 https://dociva.io$request_uri;
+ return 308 https://dociva.io$request_uri;
}
server {
@@ -43,6 +43,7 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+ add_header X-Canonical-Host "dociva.io" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com https://plausible.io; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always;
# SEO files — no cache, always fresh
@@ -71,6 +72,7 @@ server {
# Frontend static files
location / {
root /usr/share/nginx/html;
+ add_header Link "; rel=canonical" always;
try_files $uri $uri/ /index.html;
# Cache static assets