feat: Implement CSRF protection and PostgreSQL support

- Added CSRF protection mechanism in the backend with utility functions for token management.
- Introduced a new CSRF route to fetch the active CSRF token for SPA bootstrap flows.
- Updated the auth routes to validate CSRF tokens on sensitive operations.
- Configured PostgreSQL as a database option in the environment settings and Docker Compose.
- Created a new SQLite configuration file for local development.
- Enhanced the API client to automatically attach CSRF tokens to requests.
- Updated various frontend components to utilize the new site origin utility for SEO purposes.
- Modified Nginx configuration to improve redirection and SEO headers.
- Added tests for CSRF token handling in the authentication routes.
This commit is contained in:
Your Name
2026-03-17 23:26:32 +02:00
parent 3f24a7ea3e
commit a2824b2132
24 changed files with 332 additions and 319 deletions

6
.devdbrc Normal file
View File

@@ -0,0 +1,6 @@
[
{
"type": "sqlite",
"path": "c:\\xampp\\htdocs\\SaaS-PDF\\backend\\data\\saas_pdf.db"
}
]

View File

@@ -56,6 +56,9 @@ SENTRY_ENVIRONMENT=production
# PostgreSQL (production) — leave empty to use SQLite # PostgreSQL (production) — leave empty to use SQLite
DATABASE_URL= DATABASE_URL=
POSTGRES_DB=dociva
POSTGRES_USER=dociva
POSTGRES_PASSWORD=replace-with-strong-postgres-password
# Frontend # Frontend
VITE_SITE_DOMAIN=https://dociva.io VITE_SITE_DOMAIN=https://dociva.io

View File

@@ -1,7 +1,7 @@
"""Flask Application Factory.""" """Flask Application Factory."""
import os import os
from flask import Flask from flask import Flask, jsonify
from config import config from config import config
from app.extensions import cors, limiter, talisman, init_celery 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.site_assistant_service import init_site_assistant_db
from app.services.contact_service import init_contact_db from app.services.contact_service import init_contact_db
from app.services.stripe_service import init_stripe_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): def _init_sentry(app):
@@ -48,9 +49,10 @@ def create_app(config_name=None):
# Create upload/output/database directories # Create upload/output/database directories
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True) os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True)
db_dir = os.path.dirname(app.config["DATABASE_PATH"]) if not app.config.get("DATABASE_URL"):
if db_dir: db_dir = os.path.dirname(app.config["DATABASE_PATH"])
os.makedirs(db_dir, exist_ok=True) if db_dir:
os.makedirs(db_dir, exist_ok=True)
# Initialize extensions # Initialize extensions
cors.init_app( cors.init_app(
@@ -97,6 +99,22 @@ def create_app(config_name=None):
force_https=config_name == "production", 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 # Initialize Celery
init_celery(app) init_celery(app)

View File

@@ -19,6 +19,7 @@ from app.utils.auth import (
login_user_session, login_user_session,
logout_user_session, logout_user_session,
) )
from app.utils.csrf import get_or_create_csrf_token
auth_bp = Blueprint("auth", __name__) auth_bp = Blueprint("auth", __name__)
@@ -105,6 +106,13 @@ def me_route():
return jsonify({"authenticated": True, "user": user}), 200 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"]) @auth_bp.route("/forgot-password", methods=["POST"])
@limiter.limit("5/hour") @limiter.limit("5/hour")
def forgot_password_route(): def forgot_password_route():

77
backend/app/utils/csrf.py Normal file
View File

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

View File

@@ -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.site_assistant_service import init_site_assistant_db
from app.services.contact_service import init_contact_db from app.services.contact_service import init_contact_db
from app.services.stripe_service import init_stripe_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 @pytest.fixture
@@ -26,6 +57,7 @@ def app():
os.environ['OUTPUT_FOLDER'] = output_folder os.environ['OUTPUT_FOLDER'] = output_folder
app = create_app('testing') app = create_app('testing')
app.test_client_class = CSRFTestClient
app.config.update({ app.config.update({
'TESTING': True, 'TESTING': True,
'UPLOAD_FOLDER': upload_folder, 'UPLOAD_FOLDER': upload_folder,

View File

@@ -2,6 +2,13 @@
class TestAuthRoutes: 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): def test_register_success(self, client):
response = client.post( response = client.post(
'/api/auth/register', '/api/auth/register',
@@ -77,3 +84,13 @@ class TestAuthRoutes:
assert response.status_code == 200 assert response.status_code == 200
assert response.get_json() == {'authenticated': False, 'user': None} 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()

View File

@@ -1,4 +1,20 @@
services: 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 ---
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -23,11 +39,14 @@ services:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1 - CELERY_RESULT_BACKEND=redis://redis:6379/1
- DATABASE_URL=${DATABASE_URL:-}
volumes: volumes:
- upload_data:/tmp/uploads - upload_data:/tmp/uploads
- output_data:/tmp/outputs - output_data:/tmp/outputs
- db_data:/app/data - db_data:/app/data
depends_on: depends_on:
postgres:
condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
restart: always restart: always
@@ -49,11 +68,14 @@ services:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1 - CELERY_RESULT_BACKEND=redis://redis:6379/1
- DATABASE_URL=${DATABASE_URL:-}
volumes: volumes:
- upload_data:/tmp/uploads - upload_data:/tmp/uploads
- output_data:/tmp/outputs - output_data:/tmp/outputs
- db_data:/app/data - db_data:/app/data
depends_on: depends_on:
postgres:
condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
restart: always restart: always
@@ -73,9 +95,12 @@ services:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1 - CELERY_RESULT_BACKEND=redis://redis:6379/1
- DATABASE_URL=${DATABASE_URL:-}
volumes: volumes:
- db_data:/app/data - db_data:/app/data
depends_on: depends_on:
postgres:
condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
restart: always restart: always
@@ -120,6 +145,7 @@ services:
- frontend_build:/app/dist - frontend_build:/app/dist
volumes: volumes:
postgres_data:
redis_data: redis_data:
upload_data: upload_data:
output_data: output_data:

View File

@@ -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 زيارة**
بطريقة عملية مش كلام نظري.

View File

@@ -5,6 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." /> <meta name="description" content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
<meta name="keywords" content="PDF tools, merge PDF, split PDF, compress PDF, PDF to Word, image converter, free online tools, Arabic PDF tools" /> <meta name="keywords" content="PDF tools, merge PDF, split PDF, compress PDF, PDF to Word, image converter, free online tools, Arabic PDF tools" />
<meta name="author" content="Dociva" /> <meta name="author" content="Dociva" />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />

View File

@@ -1,6 +1,6 @@
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo'; import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale, getSiteOrigin } from '@/utils/seo';
const SITE_NAME = 'Dociva'; const SITE_NAME = 'Dociva';
@@ -26,7 +26,7 @@ interface SEOHeadProps {
*/ */
export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) { export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) {
const { i18n } = useTranslation(); 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 canonicalUrl = `${origin}${path}`;
const socialImageUrl = buildSocialImageUrl(origin); const socialImageUrl = buildSocialImageUrl(origin);
const fullTitle = `${title}${SITE_NAME}`; const fullTitle = `${title}${SITE_NAME}`;

View File

@@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CheckCircle } from 'lucide-react'; import { CheckCircle } from 'lucide-react';
import { getToolSEO } from '@/config/seoData'; 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 FAQSection from './FAQSection';
import RelatedTools from './RelatedTools'; import RelatedTools from './RelatedTools';
import ToolRating from '@/components/shared/ToolRating'; 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 toolTitle = t(`tools.${seo.i18nKey}.title`);
const toolDesc = t(`tools.${seo.i18nKey}.description`); 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 path = `/tools/${slug}`;
const canonicalUrl = `${origin}${path}`; const canonicalUrl = `${origin}${path}`;
const socialImageUrl = buildSocialImageUrl(origin); const socialImageUrl = buildSocialImageUrl(origin);

View File

@@ -1,12 +1,13 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead'; 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 { Target, Cpu, Shield, Lock, Wrench } from 'lucide-react';
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
export default function AboutPage() { export default function AboutPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[]; const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[];
return ( return (
@@ -18,7 +19,7 @@ export default function AboutPage() {
jsonLd={generateWebPage({ jsonLd={generateWebPage({
name: t('pages.about.title'), name: t('pages.about.title'),
description: t('pages.about.metaDescription'), description: t('pages.about.metaDescription'),
url: `${window.location.origin}/about`, url: `${siteOrigin}/about`,
})} })}
/> />

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead'; 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 { BookOpen, Calendar, ArrowRight, Search, X } from 'lucide-react';
import { import {
BLOG_ARTICLES, BLOG_ARTICLES,
@@ -13,6 +13,7 @@ import {
export default function BlogPage() { export default function BlogPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || ''; const query = searchParams.get('q') || '';
const deferredQuery = useDeferredValue(query.trim().toLowerCase()); const deferredQuery = useDeferredValue(query.trim().toLowerCase());
@@ -46,7 +47,7 @@ export default function BlogPage() {
jsonLd={generateWebPage({ jsonLd={generateWebPage({
name: t('pages.blog.metaTitle'), name: t('pages.blog.metaTitle'),
description: t('pages.blog.metaDescription'), description: t('pages.blog.metaDescription'),
url: `${window.location.origin}/blog`, url: `${siteOrigin}/blog`,
})} })}
/> />

View File

@@ -9,7 +9,7 @@ import {
getLocalizedBlogArticle, getLocalizedBlogArticle,
normalizeBlogLocale, normalizeBlogLocale,
} from '@/content/blogArticles'; } from '@/content/blogArticles';
import { generateBlogPosting, generateBreadcrumbs, generateWebPage } from '@/utils/seo'; import { generateBlogPosting, generateBreadcrumbs, generateWebPage, getSiteOrigin } from '@/utils/seo';
import NotFoundPage from './NotFoundPage'; import NotFoundPage from './NotFoundPage';
export default function BlogPostPage() { export default function BlogPostPage() {
@@ -17,6 +17,7 @@ export default function BlogPostPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const locale = normalizeBlogLocale(i18n.language); const locale = normalizeBlogLocale(i18n.language);
const article = slug ? getBlogArticleBySlug(slug) : undefined; const article = slug ? getBlogArticleBySlug(slug) : undefined;
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
if (!article) { if (!article) {
return <NotFoundPage />; return <NotFoundPage />;
@@ -24,11 +25,11 @@ export default function BlogPostPage() {
const localizedArticle = getLocalizedBlogArticle(article, locale); const localizedArticle = getLocalizedBlogArticle(article, locale);
const path = `/blog/${localizedArticle.slug}`; const path = `/blog/${localizedArticle.slug}`;
const url = `${window.location.origin}${path}`; const url = `${siteOrigin}${path}`;
const breadcrumbs = generateBreadcrumbs([ const breadcrumbs = generateBreadcrumbs([
{ name: t('common.home'), url: window.location.origin }, { name: t('common.home'), url: siteOrigin },
{ name: t('common.blog'), url: `${window.location.origin}/blog` }, { name: t('common.blog'), url: `${siteOrigin}/blog` },
{ name: localizedArticle.title, url }, { name: localizedArticle.title, url },
]); ]);

View File

@@ -3,16 +3,18 @@ import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { generateWebPage } from '@/utils/seo'; import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import axios from 'axios'; import { getApiClient } from '@/services/api';
const CONTACT_EMAIL = 'support@dociva.io'; const CONTACT_EMAIL = 'support@dociva.io';
const API_BASE = import.meta.env.VITE_API_URL || ''; const API_BASE = import.meta.env.VITE_API_URL || '';
const api = getApiClient();
type Category = 'general' | 'bug' | 'feature'; type Category = 'general' | 'bug' | 'feature';
export default function ContactPage() { export default function ContactPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const [category, setCategory] = useState<Category>('general'); const [category, setCategory] = useState<Category>('general');
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -29,7 +31,7 @@ export default function ContactPage() {
const data = new FormData(form); const data = new FormData(form);
try { try {
await axios.post(`${API_BASE}/api/contact/submit`, { await api.post(`${API_BASE}/contact/submit`, {
name: data.get('name'), name: data.get('name'),
email: data.get('email'), email: data.get('email'),
category, category,
@@ -38,10 +40,10 @@ export default function ContactPage() {
}); });
setSubmitted(true); setSubmitted(true);
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.data?.error) { if (err instanceof Error) {
setError(err.response.data.error); setError(err.message);
} else { } else {
setError(t('pages.contact.errorMessage', 'Failed to send message. Please try again.')); setError(err.response.data.error);
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -79,7 +81,7 @@ export default function ContactPage() {
jsonLd={generateWebPage({ jsonLd={generateWebPage({
name: t('pages.contact.title'), name: t('pages.contact.title'),
description: t('pages.contact.metaDescription'), description: t('pages.contact.metaDescription'),
url: `${window.location.origin}/contact`, url: `${siteOrigin}/contact`,
})} })}
/> />

View File

@@ -1,6 +1,6 @@
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import SocialProofStrip from '@/components/shared/SocialProofStrip'; import SocialProofStrip from '@/components/shared/SocialProofStrip';
import { generateWebPage } from '@/utils/seo'; import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Code2, KeyRound, Rocket, Workflow } from 'lucide-react'; import { Code2, KeyRound, Rocket, Workflow } from 'lucide-react';
@@ -24,7 +24,7 @@ const ENDPOINT_GROUPS = [
export default function DevelopersPage() { export default function DevelopersPage() {
const { t } = useTranslation(); 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 \\ const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\
-H "X-API-Key: spdf_your_api_key" \\ -H "X-API-Key: spdf_your_api_key" \\
-F "file=@./sample.pdf"`; -F "file=@./sample.pdf"`;
@@ -40,7 +40,7 @@ export default function DevelopersPage() {
jsonLd={generateWebPage({ jsonLd={generateWebPage({
name: t('pages.developers.title'), name: t('pages.developers.title'),
description: t('pages.developers.metaDescription'), description: t('pages.developers.metaDescription'),
url: `${window.location.origin}/developers`, url: `${origin}/developers`,
})} })}
/> />

View File

@@ -2,7 +2,7 @@ import { useDeferredValue } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { generateOrganization } from '@/utils/seo'; import { generateOrganization, getSiteOrigin } from '@/utils/seo';
import { import {
FileText, FileText,
FileOutput, FileOutput,
@@ -86,6 +86,7 @@ const otherTools: ToolInfo[] = [
export default function HomePage() { export default function HomePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || ''; const query = searchParams.get('q') || '';
const deferredQuery = useDeferredValue(query.trim().toLowerCase()); const deferredQuery = useDeferredValue(query.trim().toLowerCase());
@@ -123,15 +124,15 @@ export default function HomePage() {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebSite', '@type': 'WebSite',
name: t('common.appName'), name: t('common.appName'),
url: window.location.origin, url: siteOrigin,
description: t('home.heroSub'), description: t('home.heroSub'),
potentialAction: { potentialAction: {
'@type': 'SearchAction', '@type': 'SearchAction',
target: `${window.location.origin}/?q={search_term_string}`, target: `${siteOrigin}/?q={search_term_string}`,
'query-input': 'required name=search_term_string', 'query-input': 'required name=search_term_string',
}, },
}, },
generateOrganization(window.location.origin), generateOrganization(siteOrigin),
]} ]}
/> />

View File

@@ -2,13 +2,14 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import SEOHead from '@/components/seo/SEOHead'; 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 { Check, X, Zap, Crown, Loader2 } from 'lucide-react';
import axios from 'axios';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import SocialProofStrip from '@/components/shared/SocialProofStrip'; import SocialProofStrip from '@/components/shared/SocialProofStrip';
import { getApiClient } from '@/services/api';
const API_BASE = import.meta.env.VITE_API_URL || ''; const API_BASE = import.meta.env.VITE_API_URL || '';
const api = getApiClient();
interface PlanFeature { interface PlanFeature {
key: string; key: string;
@@ -31,6 +32,7 @@ const FEATURES: PlanFeature[] = [
export default function PricingPage() { export default function PricingPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -41,11 +43,7 @@ export default function PricingPage() {
} }
setLoading(true); setLoading(true);
try { try {
const { data } = await axios.post( const { data } = await api.post(`${API_BASE}/stripe/create-checkout-session`, { billing });
`${API_BASE}/api/stripe/create-checkout-session`,
{ billing },
{ withCredentials: true },
);
if (data.url) window.location.href = data.url; if (data.url) window.location.href = data.url;
} catch { } catch {
// Stripe not configured yet — show message // Stripe not configured yet — show message
@@ -70,7 +68,7 @@ export default function PricingPage() {
jsonLd={generateWebPage({ jsonLd={generateWebPage({
name: t('pages.pricing.title', 'Pricing'), name: t('pages.pricing.title', 'Pricing'),
description: t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva.'), description: t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva.'),
url: `${window.location.origin}/pricing`, url: `${siteOrigin}/pricing`,
})} })}
/> />

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { generateWebPage } from '@/utils/seo'; import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
const LAST_UPDATED = '2026-03-06'; const LAST_UPDATED = '2026-03-06';
@@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io';
export default function PrivacyPage() { export default function PrivacyPage() {
const { t } = useTranslation(); 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 fileItems = t('pages.privacy.fileHandlingItems', { minutes: FILE_RETENTION_MINUTES, returnObjects: true }) as string[];
const thirdPartyItems = t('pages.privacy.thirdPartyItems', { returnObjects: true }) as string[]; const thirdPartyItems = t('pages.privacy.thirdPartyItems', { returnObjects: true }) as string[];
@@ -20,7 +21,7 @@ export default function PrivacyPage() {
jsonLd={generateWebPage({ jsonLd={generateWebPage({
name: t('pages.privacy.title'), name: t('pages.privacy.title'),
description: t('pages.privacy.metaDescription'), description: t('pages.privacy.metaDescription'),
url: `${window.location.origin}/privacy`, url: `${siteOrigin}/privacy`,
})} })}
/> />

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SEOHead from '@/components/seo/SEOHead'; import SEOHead from '@/components/seo/SEOHead';
import { generateWebPage } from '@/utils/seo'; import { generateWebPage, getSiteOrigin } from '@/utils/seo';
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
const LAST_UPDATED = '2026-03-06'; const LAST_UPDATED = '2026-03-06';
@@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io';
export default function TermsPage() { export default function TermsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const useItems = t('pages.terms.useItems', { returnObjects: true }) as string[]; const useItems = t('pages.terms.useItems', { returnObjects: true }) as string[];
const fileItems = t('pages.terms.fileItems', { minutes: FILE_RETENTION_MINUTES, 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({ jsonLd={generateWebPage({
name: t('pages.terms.title'), name: t('pages.terms.title'),
description: t('pages.terms.metaDescription'), description: t('pages.terms.metaDescription'),
url: `${window.location.origin}/terms`, url: `${siteOrigin}/terms`,
})} })}
/> />

View File

@@ -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<string, string>)[key] = value;
}
const csrfBootstrapClient = axios.create({
baseURL: '/api',
timeout: 15000,
withCredentials: true,
headers: {
Accept: 'application/json',
},
});
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -11,7 +67,23 @@ const api = axios.create({
// Request interceptor for logging // Request interceptor for logging
api.interceptors.request.use( 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) (error) => Promise.reject(error)
); );
@@ -318,6 +390,10 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatus> {
return response.data; return response.data;
} }
export function getApiClient() {
return api;
}
/** /**
* Send one message to the site assistant. * Send one message to the site assistant.
*/ */

View File

@@ -18,6 +18,7 @@ export interface LanguageAlternate {
} }
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg'; 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 }> = { const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = {
en: { hrefLang: 'en', ogLocale: 'en_US' }, 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 { export function buildSocialImageUrl(origin: string): string {
return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`; return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`;
} }

View File

@@ -8,7 +8,7 @@ server {
client_max_body_size 100M; client_max_body_size 100M;
# Redirect HTTP to HTTPS # Redirect HTTP to HTTPS
return 301 https://dociva.io$request_uri; return 308 https://dociva.io$request_uri;
} }
# Redirect www to non-www # Redirect www to non-www
@@ -21,7 +21,7 @@ server {
ssl_certificate_key /etc/nginx/ssl/privkey.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://dociva.io$request_uri; return 308 https://dociva.io$request_uri;
} }
server { server {
@@ -43,6 +43,7 @@ server {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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; 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 # SEO files — no cache, always fresh
@@ -71,6 +72,7 @@ server {
# Frontend static files # Frontend static files
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
add_header Link "<https://dociva.io$uri>; rel=canonical" always;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
# Cache static assets # Cache static assets