Cookie changes

This commit is contained in:
Connor Yoh 2025-10-15 15:36:59 +01:00
parent af57ae02dd
commit 32c0fc0f1c
16 changed files with 382 additions and 34 deletions

View File

@ -320,6 +320,8 @@ public class ApplicationProperties {
private String tessdataDir;
private Boolean enableAlphaFunctionality;
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Datasource datasource;
private Boolean disableSanitize;
private int maxDPI;
@ -332,6 +334,18 @@ public class ApplicationProperties {
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
}
public boolean isPosthogEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
}
public boolean isScarfEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnableScarf() == null || this.getEnableScarf());
}
}
@Data

View File

@ -56,7 +56,7 @@ public class PostHogService {
}
private void captureSystemInfo() {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
if (!applicationProperties.getSystem().isPosthogEnabled()) {
return;
}
try {
@ -67,7 +67,7 @@ public class PostHogService {
}
public void captureEvent(String eventName, Map<String, Object> properties) {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
if (!applicationProperties.getSystem().isPosthogEnabled()) {
return;
}
@ -325,6 +325,14 @@ public class PostHogService {
properties,
"system_enableAnalytics",
applicationProperties.getSystem().isAnalyticsEnabled());
addIfNotEmpty(
properties,
"system_enablePosthog",
applicationProperties.getSystem().isPosthogEnabled());
addIfNotEmpty(
properties,
"system_enableScarf",
applicationProperties.getSystem().isScarfEnabled());
// Capture UI properties
addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName());

View File

@ -6,7 +6,7 @@ import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
@ -29,7 +29,7 @@ public class SettingsController {
@AutoJobPostMapping("/update-enable-analytics")
@Hidden
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
.body(

View File

@ -65,6 +65,8 @@ public class ConfigController {
applicationProperties.getSystem().getEnableAlphaFunctionality());
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());

View File

@ -105,7 +105,9 @@ system:
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion

View File

@ -3092,6 +3092,10 @@
"title": "Analytics",
"description": "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with."
}
},
"services": {
"posthog": "PostHog Analytics",
"scarf": "Scarf Pixel"
}
},
"removeMetadata": {
@ -3617,6 +3621,29 @@
"title": "Attachment Results"
}
},
"analytics": {
"modal": {
"title": "Configure Analytics",
"description": "Choose whether to enable analytics for Stirling PDF. If enabled, users can control individual services (PostHog and Scarf) through the cookie preferences.",
"whatWeCollect": "What we collect:",
"collect": {
"system": "Operating system and Java version",
"config": "CPU/memory configuration and deployment type",
"features": "Aggregate feature usage counts",
"pages": "Page visits (via tracking pixel)"
},
"whatWeDoNotCollect": "What we do NOT collect:",
"notCollect": {
"documents": "Document content or file data",
"pii": "Personally identifiable information (PII)",
"ip": "IP addresses"
},
"privacy": "All analytics data is hosted on EU servers and respects your privacy.",
"enable": "Enable Analytics",
"disable": "Disable Analytics",
"note": "This choice can be changed later by editing the settings.yml file."
}
},
"termsAndConditions": "Terms & Conditions",
"logOut": "Log out"
}

View File

@ -9,6 +9,7 @@ import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
import { useScarfTracking } from "./hooks/useScarfTracking";
// Import global styles
import "./styles/tailwind.css";
@ -38,6 +39,9 @@ const LoadingFallback = () => (
);
export default function App() {
// Initialize scarf tracking (mounts once at app startup)
useScarfTracking();
return (
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>

View File

@ -6,6 +6,7 @@ import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useViewer } from '../../contexts/ViewerContext';
import { useAppConfig } from '../../hooks/useAppConfig';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@ -20,6 +21,7 @@ import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
const { config } = useAppConfig();
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
@ -180,7 +182,7 @@ export default function Workbench() {
{renderMainContent()}
</Box>
<Footer analyticsEnabled />
<Footer analyticsEnabled={config?.enableAnalytics === true} />
</Box>
);
}

View File

@ -0,0 +1,127 @@
import { Modal, Stack, Button, Text, Title, Paper, List } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '../../styles/zIndex';
import apiClient from '../../services/apiClient';
interface AdminAnalyticsChoiceModalProps {
opened: boolean;
onClose?: () => void;
}
export default function AdminAnalyticsChoiceModal({ opened }: AdminAnalyticsChoiceModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
// Reload the page to apply new settings
window.location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
setLoading(false);
}
};
const handleEnable = () => {
handleChoice(true);
};
const handleDisable = () => {
handleChoice(false);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
size="lg"
centered
zIndex={Z_ANALYTICS_MODAL}
>
<Stack gap="md">
<Title order={2}>{t('analytics.modal.title', 'Configure Analytics')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.modal.description', 'Choose whether to enable analytics for Stirling PDF. If enabled, users can control individual services (PostHog and Scarf) through the cookie preferences.')}
</Text>
<Paper p="md" withBorder>
<Stack gap="xs">
<Text fw={600} size="sm">
{t('analytics.modal.whatWeCollect', 'What we collect:')}
</Text>
<List size="sm" spacing="xs">
<List.Item>{t('analytics.modal.collect.system', 'Operating system and Java version')}</List.Item>
<List.Item>{t('analytics.modal.collect.config', 'CPU/memory configuration and deployment type')}</List.Item>
<List.Item>{t('analytics.modal.collect.features', 'Aggregate feature usage counts')}</List.Item>
<List.Item>{t('analytics.modal.collect.pages', 'Page visits (via tracking pixel)')}</List.Item>
</List>
</Stack>
</Paper>
<Paper p="md" withBorder>
<Stack gap="xs">
<Text fw={600} size="sm">
{t('analytics.modal.whatWeDoNotCollect', 'What we do NOT collect:')}
</Text>
<List size="sm" spacing="xs">
<List.Item>{t('analytics.modal.notCollect.documents', 'Document content or file data')}</List.Item>
<List.Item>{t('analytics.modal.notCollect.pii', 'Personally identifiable information (PII)')}</List.Item>
<List.Item>{t('analytics.modal.notCollect.ip', 'IP addresses')}</List.Item>
</List>
</Stack>
</Paper>
<Text size="sm" fs="italic">
{t('analytics.modal.privacy', 'All analytics data is hosted on EU servers and respects your privacy.')}
</Text>
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Stack gap="sm">
<Button
onClick={handleEnable}
loading={loading}
fullWidth
size="md"
>
{t('analytics.modal.enable', 'Enable Analytics')}
</Button>
<Button
onClick={handleDisable}
loading={loading}
fullWidth
size="md"
variant="subtle"
c="gray"
>
{t('analytics.modal.disable', 'Disable Analytics')}
</Button>
</Stack>
<Text size="xs" c="dimmed" ta="center">
{t('analytics.modal.note', 'This choice can be changed later by editing the settings.yml file.')}
</Text>
</Stack>
</Modal>
);
}

View File

@ -10,7 +10,9 @@ export interface AppConfig {
languages?: string[];
enableLogin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
@ -45,15 +47,16 @@ export function useAppConfig(): UseAppConfigReturn {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/config/app-config');
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
}
const data: AppConfig = await response.json();
setConfig(data);
console.warn('Fetched app config:', data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);

View File

@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '../constants/app';
import { useAppConfig } from './useAppConfig';
declare global {
interface Window {
CookieConsent: {
CookieConsent?: {
run: (config: any) => void;
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
};
}
}
@ -15,8 +18,11 @@ interface CookieConsentConfig {
analyticsEnabled?: boolean;
}
export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConfig = {}) => {
export const useCookieConsent = ({
analyticsEnabled = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
@ -30,7 +36,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 100);
return;
}
@ -130,7 +136,24 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
necessary: {
readOnly: true
},
analytics: {}
analytics: {
services: {
...(config?.enablePosthog !== false && {
posthog: {
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
onAccept: () => console.log('PostHog service accepted'),
onReject: () => console.log('PostHog service rejected')
}
}),
...(config?.enableScarf !== false && {
scarf: {
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
onAccept: () => console.log('Scarf service accepted'),
onReject: () => console.log('Scarf service rejected')
}
})
}
}
},
language: {
default: "en",
@ -184,7 +207,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
// Force show after initialization
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 200);
} catch (error) {
@ -212,15 +235,23 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
document.head.removeChild(customCSS);
}
};
}, [analyticsEnabled, t]);
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent.show(true);
window.CookieConsent?.show(true);
}
};
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
return false;
}
return window.CookieConsent.acceptedService(service, category);
}, []);
return {
showCookiePreferences
showCookiePreferences,
isServiceAccepted
};
};

View File

@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { useAppConfig } from './useAppConfig';
import { useCookieConsent } from './useCookieConsent';
import { setScarfConfig, firePixel } from '../utils/scarfTracking';
/**
* Hook for initializing Scarf tracking
*
* This hook should be mounted once during app initialization (e.g., in index.tsx).
* It configures the scarf tracking utility with current config and consent state,
* and sets up event listeners to auto-fire pixels when consent is granted.
*
* After initialization, firePixel() can be called from anywhere in the app,
* including non-React utility functions like urlRouting.ts.
*/
export function useScarfTracking() {
const { config } = useAppConfig();
const { isServiceAccepted } = useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Update scarf config whenever config or consent changes
useEffect(() => {
console.warn('[useScarfTracking] Updating scarf config:', { enableScarf: config?.enableScarf, isServiceAccepted });
if (config && config.enableScarf !== undefined) {
setScarfConfig(config.enableScarf, isServiceAccepted);
}
}, [config?.enableScarf, isServiceAccepted]);
// Listen to cookie consent changes and auto-fire pixel when consent is granted
useEffect(() => {
const handleConsentChange = () => {
console.warn('[useScarfTracking] Consent changed, checking scarf service acceptance');
if (isServiceAccepted('scarf', 'analytics')) {
firePixel(window.location.pathname);
}
};
window.addEventListener('cc:onConsent', handleConsentChange);
window.addEventListener('cc:onChange', handleConsentChange);
return () => {
window.removeEventListener('cc:onConsent', handleConsentChange);
window.removeEventListener('cc:onChange', handleConsentChange);
};
}, [isServiceAccepted]);
}

View File

@ -29,22 +29,21 @@ posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
defaults: '2025-05-24',
capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
debug: false,
opt_out_capturing_by_default: false, // We handle opt-out via cookie consent
opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent
});
function updatePosthogConsent(){
if(typeof(posthog) == "undefined") {
return;
}
const optIn = (window.CookieConsent as any).acceptedCategory('analytics');
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated analytics consent: ", optIn? "opted in" : "opted out");
if(typeof(posthog) == "undefined" || !posthog.__loaded) {
return;
}
const optIn = (window.CookieConsent as any)?.acceptedService?.('posthog', 'analytics') || false;
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out");
}
window.addEventListener("cc:onConsent", updatePosthogConsent);
window.addEventListener("cc:onChange", updatePosthogConsent);

View File

@ -6,6 +6,7 @@ import { useSidebarContext } from "../contexts/SidebarContext";
import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { BASE_PATH, getBaseUrl } from "../constants/app";
import { useMediaQuery } from "@mantine/hooks";
import { useAppConfig } from "../hooks/useAppConfig";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "../components/tools/ToolPanel";
@ -17,6 +18,7 @@ import LocalIcon from "../components/shared/LocalIcon";
import { useFilesModalContext } from "../contexts/FilesModalContext";
import AppConfigModal from "../components/shared/AppConfigModal";
import ToolPanelModePrompt from "../components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "../components/shared/AdminAnalyticsChoiceModal";
import "./HomePage.css";
@ -42,11 +44,20 @@ export default function HomePage() {
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
const isMobile = useMediaQuery("(max-width: 1024px)");
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
if (config && config.enableAnalytics === null) {
setShowAnalyticsModal(true);
}
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
@ -151,6 +162,7 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<AdminAnalyticsChoiceModal opened={showAnalyticsModal} />
<ToolPanelModePrompt />
{isMobile ? (
<div className="mobile-layout">

View File

@ -3,7 +3,8 @@
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
export const Z_INDEX_AUTOMATE_MODAL = 1100;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
export const Z_ANALYTICS_MODAL = 1301;

View File

@ -1,14 +1,74 @@
/**
* Scarf analytics pixel tracking utility
*
* This module provides a firePixel function that can be called from anywhere,
* including non-React utility functions. Configuration and consent state are
* injected via setScarfConfig() which should be called from a React hook
* during app initialization.
*
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
* The initialization hook (useScarfTracking) is mounted in App.tsx.
*
* For testing: Use resetScarfConfig() to clear module state between tests.
*/
// Module-level state
let configured: boolean = false;
let enableScarf: boolean | null = null;
let isServiceAccepted: ((service: string, category: string) => boolean) | null = null;
let lastFiredPathname: string | null = null;
let lastFiredTime = 0;
/**
* Configure scarf tracking with app config and consent checker
* Should be called from a React hook during app initialization (see useScarfTracking)
*
* @param scarfEnabled - Whether scarf tracking is enabled globally
* @param consentChecker - Function to check if user has accepted scarf service
*/
export function setScarfConfig(
scarfEnabled: boolean | null,
consentChecker: (service: string, category: string) => boolean
): void {
configured = true;
enableScarf = scarfEnabled;
isServiceAccepted = consentChecker;
}
/**
* Fire scarf pixel for analytics tracking
* Only fires if pathname is different from last call or enough time has passed
* Only fires if:
* - Scarf tracking has been initialized via setScarfConfig()
* - Scarf is globally enabled in config
* - User has accepted scarf service via cookie consent
* - Pathname has changed or enough time has passed since last fire
*
* @param pathname - The pathname to track (usually window.location.pathname)
*/
export function firePixel(pathname: string): void {
console.warn('[scarfTracking]Firing with Current config:', { enableScarf, isServiceAccepted });
// Dev-mode warning if called before initialization
if (!configured) {
console.warn(
'[scarfTracking] firePixel() called before setScarfConfig(). ' +
'Ensure useScarfTracking() hook is mounted in App.tsx.'
);
return;
}
// Check if Scarf is globally disabled
if (enableScarf === false) {
return;
}
// Check if consent checker is available and scarf service is accepted
if (!isServiceAccepted || !isServiceAccepted('scarf', 'analytics')) {
return;
}
const now = Date.now();
// Only fire if pathname changed or it's been at least 1 second since last fire
// Only fire if pathname changed or it's been at least 250ms since last fire
if (pathname === lastFiredPathname && now - lastFiredTime < 250) {
return;
}
@ -22,5 +82,16 @@ export function firePixel(pathname: string): void {
const img = new Image();
img.referrerPolicy = "no-referrer-when-downgrade";
img.src = url;
console.warn('[scarfTracking] Firing pixel for', pathname);
}
/**
* Reset scarf tracking configuration and state
* Useful for testing to ensure clean state between test runs
*/
export function resetScarfConfig(): void {
enableScarf = null;
isServiceAccepted = null;
lastFiredPathname = null;
lastFiredTime = 0;
}