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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
]}
/>

View File

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

View File

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

View File

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