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:
@@ -5,6 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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="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="author" content="Dociva" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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`,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 <NotFoundPage />;
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<Category>('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`,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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`,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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),
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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`,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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`,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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`,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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({
|
||||
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<TaskStatus> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export function getApiClient() {
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one message to the site assistant.
|
||||
*/
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user