From 8ffe53536d25e471878ba17b2dda404233c4de60 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:35:35 +0000 Subject: [PATCH] activeUsers (#4936) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../SPDF/config/WAUTrackingFilter.java | 49 +++++++++ .../software/SPDF/config/WebMvcConfig.java | 26 ++++- .../controller/web/MetricsController.java | 31 ++++++ .../service/WeeklyActiveUsersService.java | 100 ++++++++++++++++++ frontend/src/core/services/apiClientSetup.ts | 13 ++- frontend/src/core/utils/browserIdentifier.ts | 46 ++++++++ 6 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java create mode 100644 frontend/src/core/utils/browserIdentifier.ts 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 b6be63ec5..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) { @@ -73,7 +89,8 @@ public class WebMvcConfig implements WebMvcConfigurer { "Origin", "X-API-KEY", "X-CSRF-TOKEN", - "X-XSRF-TOKEN") + "X-XSRF-TOKEN", + "X-Browser-Id") .exposedHeaders( "WWW-Authenticate", "X-Total-Count", @@ -98,7 +115,8 @@ public class WebMvcConfig implements WebMvcConfigurer { "Origin", "X-API-KEY", "X-CSRF-TOKEN", - "X-XSRF-TOKEN") + "X-XSRF-TOKEN", + "X-Browser-Id") .exposedHeaders( "WWW-Authenticate", "X-Total-Count", 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/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/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); + }); +}