diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java new file mode 100644 index 000000000..08362fa0b --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java @@ -0,0 +1,49 @@ +package stirling.software.SPDF.config; + +import java.io.IOException; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.service.WeeklyActiveUsersService; + +/** + * Filter to track browser IDs for Weekly Active Users (WAU) counting. + * Only active when security is disabled (no-login mode). + */ +@Component +@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false") +@RequiredArgsConstructor +@Slf4j +public class WAUTrackingFilter implements Filter { + + private final WeeklyActiveUsersService wauService; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (request instanceof HttpServletRequest httpRequest) { + // Extract browser ID from header + String browserId = httpRequest.getHeader("X-Browser-Id"); + + if (browserId != null && !browserId.trim().isEmpty()) { + // Record browser access + wauService.recordBrowserAccess(browserId); + } + } + + // Continue the filter chain + chain.doFilter(request, response); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 0823c29e9..fc578fdbc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer { "tauri://localhost", "http://tauri.localhost", "https://tauri.localhost") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") - .allowedHeaders("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN", + "X-Browser-Id") + .exposedHeaders( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type") .allowCredentials(true) .maxAge(3600); } else if (hasConfiguredOrigins) { @@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer { .toArray(new String[0]); registry.addMapping("/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") - .allowedHeaders("*") + .allowedOriginPatterns(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN", + "X-Browser-Id") + .exposedHeaders( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type") + .allowCredentials(true) + .maxAge(3600); + } else { + // Default to allowing all origins when nothing is configured + logger.info( + "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins."); + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN", + "X-Browser-Id") + .exposedHeaders( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type") .allowCredentials(true) .maxAge(3600); } - // If no origins are configured and not in Tauri mode, CORS is not enabled (secure by - // default) } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index ffbec5a7d..a4a169cd9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -154,6 +154,25 @@ public class ConfigController { // EE features not available, continue without them } + // Add version and machine info for update checking + try { + if (applicationContext.containsBean("appVersion")) { + configData.put( + "appVersion", applicationContext.getBean("appVersion", String.class)); + } + if (applicationContext.containsBean("machineType")) { + configData.put( + "machineType", applicationContext.getBean("machineType", String.class)); + } + if (applicationContext.containsBean("activeSecurity")) { + configData.put( + "activeSecurity", + applicationContext.getBean("activeSecurity", Boolean.class)); + } + } catch (Exception e) { + // Version/machine info not available + } + return ResponseEntity.ok(configData); } catch (Exception e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index 8f17e0baf..53e60a6b5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointInspector; import stirling.software.SPDF.config.StartupApplicationListener; +import stirling.software.SPDF.service.WeeklyActiveUsersService; import stirling.software.common.annotations.api.InfoApi; import stirling.software.common.model.ApplicationProperties; @@ -34,6 +35,7 @@ public class MetricsController { private final ApplicationProperties applicationProperties; private final MeterRegistry meterRegistry; private final EndpointInspector endpointInspector; + private final Optional wauService; private boolean metricsEnabled; @PostConstruct @@ -352,6 +354,35 @@ public class MetricsController { return ResponseEntity.ok(formatDuration(uptime)); } + @GetMapping("/wau") + @Operation( + summary = "Weekly Active Users statistics", + description = + "Returns WAU (Weekly Active Users) count and total unique browsers. " + + "Only available when security is disabled (no-login mode). " + + "Tracks unique browsers via client-generated UUID in localStorage.") + public ResponseEntity getWeeklyActiveUsers() { + if (!metricsEnabled) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled."); + } + + // Check if WAU service is available (only when security.enableLogin=false) + if (wauService.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("WAU tracking is only available when security is disabled (no-login mode)"); + } + + WeeklyActiveUsersService service = wauService.get(); + + Map wauStats = new HashMap<>(); + wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers()); + wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers()); + wauStats.put("daysOnline", service.getDaysOnline()); + wauStats.put("trackingSince", service.getStartTime().toString()); + + return ResponseEntity.ok(wauStats); + } + private String formatDuration(Duration duration) { long days = duration.toDays(); long hours = duration.toHoursPart(); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java new file mode 100644 index 000000000..ddf3a7b26 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java @@ -0,0 +1,100 @@ +package stirling.software.SPDF.service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Service for tracking Weekly Active Users (WAU) in no-login mode. + * Uses in-memory storage with automatic cleanup of old entries. + */ +@Service +@Slf4j +public class WeeklyActiveUsersService { + + // Map of browser ID -> last seen timestamp + private final Map activeBrowsers = new ConcurrentHashMap<>(); + + // Track total unique browsers seen (overall) + private long totalUniqueBrowsers = 0; + + // Application start time + private final Instant startTime = Instant.now(); + + /** + * Records a browser access with the current timestamp + * @param browserId Unique browser identifier from X-Browser-Id header + */ + public void recordBrowserAccess(String browserId) { + if (browserId == null || browserId.trim().isEmpty()) { + return; + } + + boolean isNewBrowser = !activeBrowsers.containsKey(browserId); + activeBrowsers.put(browserId, Instant.now()); + + if (isNewBrowser) { + totalUniqueBrowsers++; + log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers); + } + } + + /** + * Gets the count of unique browsers seen in the last 7 days + * @return Weekly Active Users count + */ + public long getWeeklyActiveUsers() { + cleanupOldEntries(); + return activeBrowsers.size(); + } + + /** + * Gets the total count of unique browsers ever seen + * @return Total unique browsers count + */ + public long getTotalUniqueBrowsers() { + return totalUniqueBrowsers; + } + + /** + * Gets the number of days the service has been running + * @return Days online + */ + public long getDaysOnline() { + return ChronoUnit.DAYS.between(startTime, Instant.now()); + } + + /** + * Gets the timestamp when tracking started + * @return Start time + */ + public Instant getStartTime() { + return startTime; + } + + /** + * Removes entries older than 7 days + */ + private void cleanupOldEntries() { + Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS); + activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo)); + } + + /** + * Manual cleanup trigger (can be called by scheduled task if needed) + */ + public void performCleanup() { + int sizeBefore = activeBrowsers.size(); + cleanupOldEntries(); + int sizeAfter = activeBrowsers.size(); + + if (sizeBefore != sizeAfter) { + log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter); + } + } +} diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index bc5202a28..980760e3a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -362,7 +362,15 @@ "defaultPdfEditorInactive": "Another application is set as default", "defaultPdfEditorChecking": "Checking...", "defaultPdfEditorSet": "Already Default", - "setAsDefault": "Set as Default" + "setAsDefault": "Set as Default", + "updates": { + "title": "Software Updates", + "description": "Check for updates and view version information", + "currentVersion": "Current Version", + "latestVersion": "Latest Version", + "checkForUpdates": "Check for Updates", + "viewDetails": "View Details" + } }, "hotkeys": { "title": "Keyboard Shortcuts", @@ -383,6 +391,37 @@ "searchPlaceholder": "Search tools..." } }, + "update": { + "modalTitle": "Update Available", + "current": "Current Version", + "latest": "Latest Version", + "latestStable": "Latest Stable", + "priorityLabel": "Priority", + "recommendedAction": "Recommended Action", + "breakingChangesDetected": "Breaking Changes Detected", + "breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.", + "migrationGuides": "Migration Guides", + "viewGuide": "View Guide", + "loadingDetailedInfo": "Loading detailed information...", + "close": "Close", + "viewAllReleases": "View All Releases", + "downloadLatest": "Download Latest", + "availableUpdates": "Available Updates", + "unableToLoadDetails": "Unable to load detailed information.", + "version": "Version", + "urgentUpdateAvailable": "Urgent Update", + "updateAvailable": "Update Available", + "releaseNotes": "Release Notes", + "priority": { + "urgent": "Urgent", + "normal": "Normal", + "minor": "Minor", + "low": "Low" + }, + "breakingChanges": "Breaking Changes", + "breakingChangesDefault": "This version contains breaking changes.", + "migrationGuide": "Migration Guide" + }, "changeCreds": { "title": "Change Credentials", "header": "Update Your Account Details", diff --git a/frontend/src/core/components/shared/UpdateModal.tsx b/frontend/src/core/components/shared/UpdateModal.tsx new file mode 100644 index 000000000..937f4426d --- /dev/null +++ b/frontend/src/core/components/shared/UpdateModal.tsx @@ -0,0 +1,415 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import DownloadIcon from '@mui/icons-material/Download'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; + +interface UpdateModalProps { + opened: boolean; + onClose: () => void; + currentVersion: string; + updateSummary: UpdateSummary; + machineInfo: MachineInfo; +} + +const UpdateModal: React.FC = ({ + opened, + onClose, + currentVersion, + updateSummary, + machineInfo, +}) => { + const { t } = useTranslation(); + const [fullUpdateInfo, setFullUpdateInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [expandedVersions, setExpandedVersions] = useState>(new Set([0])); + + useEffect(() => { + if (opened) { + setLoading(true); + setExpandedVersions(new Set([0])); + updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => { + setFullUpdateInfo(info); + setLoading(false); + }); + } + }, [opened, currentVersion, machineInfo]); + + const toggleVersion = (index: number) => { + setExpandedVersions((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const getPriorityColor = (priority: string): string => { + switch (priority?.toLowerCase()) { + case 'urgent': + return 'red'; + case 'normal': + return 'blue'; + case 'minor': + return 'cyan'; + case 'low': + return 'gray'; + default: + return 'gray'; + } + }; + + const getPriorityLabel = (priority: string): string => { + const key = priority?.toLowerCase(); + return t(`update.priority.${key}`, priority || 'Normal'); + }; + + const downloadUrl = updateService.getDownloadUrl(machineInfo); + + return ( + + {t('update.modalTitle', 'Update Available')} + + } + centered + size="xl" + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + styles={{ + body: { + maxHeight: '75vh', + overflowY: 'auto', + }, + }} + > + + {/* Version Summary Section */} + + + + + {t('update.current', 'Current Version')} + + + {currentVersion} + + + + + + {t('update.priorityLabel', 'Priority')} + + + {getPriorityLabel(updateSummary.max_priority)} + + + + + + {t('update.latest', 'Latest Version')} + + + {updateSummary.latest_version} + + + + + {updateSummary.latest_stable_version && ( + + + + {t('update.latestStable', 'Latest Stable')}: + + + {updateSummary.latest_stable_version} + + + + )} + + + {/* Recommended action */} + {updateSummary.recommended_action && ( + + + + + + {t('update.recommendedAction', 'Recommended Action')} + + + {updateSummary.recommended_action} + + + + + )} + + {/* Breaking changes warning */} + {updateSummary.any_breaking && ( + + + + + + {t('update.breakingChangesDetected', 'Breaking Changes Detected')} + + + {t( + 'update.breakingChangesMessage', + 'Some versions contain breaking changes. Please review the migration guides below before updating.' + )} + + + + + )} + + {/* Migration guides */} + {updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && ( + <> + + + + {t('update.migrationGuides', 'Migration Guides')} + + {updateSummary.migration_guides.map((guide, idx) => ( + + + + + {t('update.version', 'Version')} {guide.version} + + + {guide.notes} + + + + + + ))} + + + )} + + {/* Version details */} + + {loading ? ( +
+ + + + {t('update.loadingDetailedInfo', 'Loading detailed information...')} + + +
+ ) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? ( + + + + {t('update.availableUpdates', 'Available Updates')} + + + {fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'} + + + + {fullUpdateInfo.new_versions.map((version, index) => { + const isExpanded = expandedVersions.has(index); + return ( + + toggleVersion(index)} + > + + + + {t('update.version', 'Version')} + + + {version.version} + + + + {getPriorityLabel(version.priority)} + + + + + {isExpanded ? ( + + ) : ( + + )} + + + + + + + + + {version.announcement.title} + + + {version.announcement.message} + + + + {version.compatibility.breaking_changes && ( + + + + + {t('update.breakingChanges', 'Breaking Changes')} + + + + {version.compatibility.breaking_description || + t('update.breakingChangesDefault', 'This version contains breaking changes.')} + + {version.compatibility.migration_guide_url && ( + + )} + + )} + + + + + ); + })} + + + ) : null} + + {/* Action buttons */} + + + + + {downloadUrl && ( + + )} + +
+
+ ); +}; + +export default UpdateModal; diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index de9994705..e80a1fb07 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from 'react'; -import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core'; +import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePreferences } from '@app/contexts/PreferencesContext'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import type { ToolPanelMode } from '@app/constants/toolPanel'; import LocalIcon from '@app/components/shared/LocalIcon'; +import { updateService, UpdateSummary } from '@app/services/updateService'; +import UpdateModal from '@app/components/shared/UpdateModal'; const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4; const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed'; @@ -22,12 +24,44 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => // Check localStorage on mount return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true'; }); + const [updateSummary, setUpdateSummary] = useState(null); + const [updateModalOpened, setUpdateModalOpened] = useState(false); + const [checkingUpdate, setCheckingUpdate] = useState(false); // Sync local state with preference changes useEffect(() => { setFileLimitInput(preferences.autoUnzipFileLimit); }, [preferences.autoUnzipFileLimit]); + // Check for updates on mount + useEffect(() => { + if (config?.appVersion && config?.machineType) { + checkForUpdate(); + } + }, [config?.appVersion, config?.machineType]); + + const checkForUpdate = async () => { + if (!config?.appVersion || !config?.machineType) { + return; + } + + setCheckingUpdate(true); + const machineInfo = { + machineType: config.machineType, + activeSecurity: config.activeSecurity ?? false, + licenseType: config.license ?? 'NORMAL', + }; + + const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo); + if (summary) { + const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0; + if (isNewerVersion) { + setUpdateSummary(summary); + } + } + setCheckingUpdate(false); + }; + // Check if login is disabled const loginDisabled = !config?.enableLogin; @@ -170,6 +204,108 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => + + {/* Update Check Section */} + {config?.appVersion && ( + + +
+ +
+ + {t('settings.general.updates.title', 'Software Updates')} + + + {t('settings.general.updates.description', 'Check for updates and view version information')} + +
+ {updateSummary && ( + + {updateSummary.max_priority === 'urgent' + ? t('update.urgentUpdateAvailable', 'Urgent Update') + : t('update.updateAvailable', 'Update Available')} + + )} +
+
+ + +
+ + {t('settings.general.updates.currentVersion', 'Current Version')}:{' '} + + {config.appVersion} + + + {updateSummary && ( + + {t('settings.general.updates.latestVersion', 'Latest Version')}:{' '} + + {updateSummary.latest_version} + + + )} +
+ + + {updateSummary && ( + + )} + +
+ + {updateSummary?.any_breaking && ( + + + {t( + 'update.breakingChangesMessage', + 'Some versions contain breaking changes. Please review the migration guides before updating.' + )} + + + )} +
+
+ )} + + {/* Update Modal */} + {updateSummary && config?.appVersion && config?.machineType && ( + setUpdateModalOpened(false)} + currentVersion={config.appVersion} + updateSummary={updateSummary} + machineInfo={{ + machineType: config.machineType, + activeSecurity: config.activeSecurity ?? false, + licenseType: config.license ?? 'NORMAL', + }} + /> + )} ); }; diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index ad47b415b..2f672da26 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -39,6 +39,9 @@ export interface AppConfig { license?: string; SSOAutoLogin?: boolean; serverCertificateEnabled?: boolean; + appVersion?: string; + machineType?: string; + activeSecurity?: boolean; error?: string; } diff --git a/frontend/src/core/services/apiClientSetup.ts b/frontend/src/core/services/apiClientSetup.ts index f7de717a3..07bc6782b 100644 --- a/frontend/src/core/services/apiClientSetup.ts +++ b/frontend/src/core/services/apiClientSetup.ts @@ -1,5 +1,14 @@ import { AxiosInstance } from 'axios'; +import { getBrowserId } from '@app/utils/browserIdentifier'; -export function setupApiInterceptors(_client: AxiosInstance): void { - // Core version: no interceptors to add +export function setupApiInterceptors(client: AxiosInstance): void { + // Add browser ID header for WAU tracking + client.interceptors.request.use( + (config) => { + const browserId = getBrowserId(); + config.headers['X-Browser-Id'] = browserId; + return config; + }, + (error) => Promise.reject(error) + ); } diff --git a/frontend/src/core/services/updateService.ts b/frontend/src/core/services/updateService.ts new file mode 100644 index 000000000..1cba4838f --- /dev/null +++ b/frontend/src/core/services/updateService.ts @@ -0,0 +1,186 @@ +export interface UpdateSummary { + latest_version: string; + latest_stable_version?: string; + max_priority: 'urgent' | 'normal' | 'minor' | 'low'; + recommended_action?: string; + any_breaking: boolean; + migration_guides?: Array<{ + version: string; + notes: string; + url: string; + }>; +} + +export interface VersionUpdate { + version: string; + priority: 'urgent' | 'normal' | 'minor' | 'low'; + announcement: { + title: string; + message: string; + }; + compatibility: { + breaking_changes: boolean; + breaking_description?: string; + migration_guide_url?: string; + }; +} + +export interface FullUpdateInfo { + latest_version: string; + latest_stable_version?: string; + new_versions: VersionUpdate[]; +} + +export interface MachineInfo { + machineType: string; + activeSecurity: boolean; + licenseType: string; +} + +export class UpdateService { + private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates'; + + /** + * Compare two version strings + * @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ + compareVersions(version1: string, version2: string): number { + const v1 = version1.split('.'); + const v2 = version2.split('.'); + + for (let i = 0; i < v1.length || i < v2.length; i++) { + const n1 = parseInt(v1[i]) || 0; + const n2 = parseInt(v2[i]) || 0; + + if (n1 > n2) { + return 1; + } else if (n1 < n2) { + return -1; + } + } + + return 0; + } + + /** + * Get download URL based on machine type and security settings + */ + getDownloadUrl(machineInfo: MachineInfo): string | null { + // Only show download for non-Docker installations + if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') { + return null; + } + + const baseUrl = 'https://files.stirlingpdf.com/'; + + // Determine file based on machine type and security + if (machineInfo.machineType === 'Server-jar') { + return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar'); + } + + // Client installations + if (machineInfo.machineType.startsWith('Client-')) { + const os = machineInfo.machineType.replace('Client-', ''); // win, mac, unix + const type = machineInfo.activeSecurity ? '-server-security' : '-server'; + + if (os === 'unix') { + return baseUrl + os + type + '.jar'; + } else if (os === 'win') { + return baseUrl + os + '-installer.exe'; + } else if (os === 'mac') { + return baseUrl + os + '-installer.dmg'; + } + } + + return null; + } + + /** + * Fetch update summary from API + */ + async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise { + // Map Java License enum to API types + let type = 'normal'; + if (machineInfo.licenseType === 'PRO') { + type = 'pro'; + } else if (machineInfo.licenseType === 'ENTERPRISE') { + type = 'enterprise'; + } + + const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`; + console.log('Fetching update summary from:', url); + + try { + const response = await fetch(url); + console.log('Response status:', response.status); + + if (response.status === 200) { + const data = await response.json(); + return data as UpdateSummary; + } else { + console.error('Failed to fetch update summary from Supabase:', response.status); + return null; + } + } catch (error) { + console.error('Failed to fetch update summary from Supabase:', error); + return null; + } + } + + /** + * Fetch full update information with detailed version info + */ + async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise { + // Map Java License enum to API types + let type = 'normal'; + if (machineInfo.licenseType === 'PRO') { + type = 'pro'; + } else if (machineInfo.licenseType === 'ENTERPRISE') { + type = 'enterprise'; + } + + const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`; + console.log('Fetching full update info from:', url); + + try { + const response = await fetch(url); + console.log('Full update response status:', response.status); + + if (response.status === 200) { + const data = await response.json(); + return data as FullUpdateInfo; + } else { + console.error('Failed to fetch full update info from Supabase:', response.status); + return null; + } + } catch (error) { + console.error('Failed to fetch full update info from Supabase:', error); + return null; + } + } + + /** + * Get current version from GitHub build.gradle as fallback + */ + async getCurrentVersionFromGitHub(): Promise { + const url = 'https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle'; + + try { + const response = await fetch(url); + if (response.status === 200) { + const text = await response.text(); + const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/; + const match = versionRegex.exec(text); + if (match) { + return match[1]; + } + } + throw new Error('Version number not found'); + } catch (error) { + console.error('Failed to fetch latest version from build.gradle:', error); + return ''; + } + } +} + +export const updateService = new UpdateService(); diff --git a/frontend/src/core/styles/zIndex.ts b/frontend/src/core/styles/zIndex.ts index aeb69e9b5..1057bf2f2 100644 --- a/frontend/src/core/styles/zIndex.ts +++ b/frontend/src/core/styles/zIndex.ts @@ -15,7 +15,7 @@ export const Z_INDEX_HOVER_ACTION_MENU = 100; export const Z_INDEX_SELECTION_BOX = 1000; export const Z_INDEX_DROP_INDICATOR = 1001; export const Z_INDEX_DRAG_BADGE = 1001; -// Modal that appears on top of config modal (e.g., restart confirmation) +// Modal that appears on top of config modal (e.g., restart confirmation, update modal) export const Z_INDEX_OVER_CONFIG_MODAL = 2000; // Toast notifications and error displays - Always on top (higher than rainbow theme at 10000) diff --git a/frontend/src/core/utils/browserIdentifier.ts b/frontend/src/core/utils/browserIdentifier.ts new file mode 100644 index 000000000..b41914a5e --- /dev/null +++ b/frontend/src/core/utils/browserIdentifier.ts @@ -0,0 +1,46 @@ +/** + * Browser identifier utility for anonymous usage tracking + * Generates and persists a unique UUID in localStorage for WAU tracking + */ + +const BROWSER_ID_KEY = 'stirling_browser_id'; + +/** + * Gets or creates a unique browser identifier + * Used for Weekly Active Users (WAU) tracking in no-login mode + */ +export function getBrowserId(): string { + try { + // Try to get existing ID from localStorage + let browserId = localStorage.getItem(BROWSER_ID_KEY); + + if (!browserId) { + // Generate new UUID v4 + browserId = generateUUID(); + localStorage.setItem(BROWSER_ID_KEY, browserId); + } + + return browserId; + } catch (error) { + // Fallback to session-based ID if localStorage is unavailable + console.warn('localStorage unavailable, using session-based ID', error); + return `session_${generateUUID()}`; + } +} + +/** + * Generates a UUID v4 + */ +function generateUUID(): string { + // Use crypto.randomUUID if available (modern browsers) + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Fallback to manual UUID generation + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}