@@ -5,6 +5,7 @@ import { readFileSync } from 'fs';
5
5
import { resolve } from 'path' ;
6
6
import { getLocale } from "./getLocale" ;
7
7
import { filterHolidaysFromMetrics , isHoliday , parseUtcDate } from '@/utils/dateUtils' ;
8
+ import { createHash } from 'crypto' ;
8
9
9
10
const cache = new Map < string , CacheData > ( ) ;
10
11
@@ -24,6 +25,50 @@ class MetricsError extends Error {
24
25
}
25
26
}
26
27
28
+ /**
29
+ * Builds a cache key for metrics data that is bound to the caller's Authorization header (hashed) + path + query.
30
+ * Exported for unit testing.
31
+ */
32
+ type QueryParamValue = string | string [ ] | undefined ;
33
+ type QueryParams = Record < string , QueryParamValue > ;
34
+
35
+ export function buildMetricsCacheKey ( path : string , query : QueryParams , authHeader : string ) : string {
36
+ // Split existing query params from provided path (if any)
37
+ const [ rawPath , existingQueryString ] = path . split ( '?' ) ;
38
+ const merged = new Map < string , string > ( ) ;
39
+
40
+ // Add existing params first
41
+ if ( existingQueryString ) {
42
+ const existingParams = new URLSearchParams ( existingQueryString ) ;
43
+ existingParams . forEach ( ( value , key ) => {
44
+ merged . set ( key , value ) ;
45
+ } ) ;
46
+ }
47
+
48
+ // Merge in provided query object (overrides existing)
49
+ Object . entries ( query ) . forEach ( ( [ k , v ] ) => {
50
+ if ( v === undefined || v === null ) return ;
51
+ if ( Array . isArray ( v ) ) {
52
+ if ( v . length === 0 ) return ;
53
+ merged . set ( k , v . join ( ',' ) ) ;
54
+ } else if ( v !== '' ) {
55
+ merged . set ( k , v ) ;
56
+ }
57
+ } ) ;
58
+
59
+ // Build stable, sorted query string
60
+ const sortedKeys = Array . from ( merged . keys ( ) ) . sort ( ) ;
61
+ const finalParams = new URLSearchParams ( ) ;
62
+ sortedKeys . forEach ( k => {
63
+ const val = merged . get ( k ) ;
64
+ if ( val !== undefined ) finalParams . set ( k , val ) ;
65
+ } ) ;
66
+ const finalQueryString = finalParams . toString ( ) ;
67
+
68
+ const authFingerprint = createHash ( 'sha256' ) . update ( authHeader ) . digest ( 'hex' ) . slice ( 0 , 16 ) ; // short fingerprint
69
+ return `${ authFingerprint } :${ rawPath } ${ finalQueryString ? `?${ finalQueryString } ` : '' } ` ;
70
+ }
71
+
27
72
export async function getMetricsData ( event : H3Event < EventHandlerRequest > ) : Promise < CopilotMetrics [ ] > {
28
73
const logger = console ;
29
74
const query = getQuery ( event ) ;
@@ -52,26 +97,33 @@ export async function getMetricsData(event: H3Event<EventHandlerRequest>): Promi
52
97
return usageData ;
53
98
}
54
99
55
- if ( cache . has ( event . path ) ) {
56
- const cachedData = cache . get ( event . path ) ;
57
- if ( cachedData && cachedData . valid_until > Date . now ( ) / 1000 ) {
58
- logger . info ( `Returning cached data for ${ event . path } ` ) ;
100
+ // Authorization must be validated BEFORE any cache lookup to prevent leakage of cached data
101
+ const authHeader = event . context . headers . get ( 'Authorization' ) ;
102
+ if ( ! authHeader ) {
103
+ logger . error ( 'No Authentication provided' ) ;
104
+ throw new MetricsError ( 'No Authentication provided' , 401 ) ;
105
+ }
106
+
107
+ // Build auth-bound cache key
108
+ const path = event . path || '/api/metrics' ; // fallback path (should always exist in practice)
109
+ const cacheKey = buildMetricsCacheKey ( path , query as QueryParams , authHeader ) ;
110
+
111
+ // Attempt cache lookup with auth fingerprint validation
112
+ const cachedData = cache . get ( cacheKey ) ;
113
+ if ( cachedData ) {
114
+ if ( cachedData . valid_until > Date . now ( ) / 1000 ) {
115
+ logger . info ( `Returning cached data for ${ cacheKey } ` ) ;
59
116
return cachedData . data ;
60
117
} else {
61
- logger . info ( `Cached data for ${ event . path } is expired, fetching new data` ) ;
62
- cache . delete ( event . path ) ;
118
+ logger . info ( `Cached data for ${ cacheKey } is expired or fingerprint mismatch , fetching new data` ) ;
119
+ cache . delete ( cacheKey ) ;
63
120
}
64
121
}
65
122
66
- if ( ! event . context . headers . has ( 'Authorization' ) ) {
67
- logger . error ( 'No Authentication provided' ) ;
68
- throw new MetricsError ( 'No Authentication provided' , 401 ) ;
69
- }
70
-
71
123
logger . info ( `Fetching metrics data from ${ apiUrl } ` ) ;
72
124
73
125
try {
74
- const response = await $fetch ( apiUrl , {
126
+ const response = await $fetch ( apiUrl , {
75
127
headers : event . context . headers
76
128
} ) as unknown [ ] ;
77
129
@@ -81,10 +133,12 @@ export async function getMetricsData(event: H3Event<EventHandlerRequest>): Promi
81
133
const filteredUsageData = filterHolidaysFromMetrics ( usageData , options . excludeHolidays || false , options . locale ) ;
82
134
// metrics is the old API format
83
135
const validUntil = Math . floor ( Date . now ( ) / 1000 ) + 5 * 60 ; // Cache for 5 minutes
84
- cache . set ( event . path , { data : filteredUsageData , valid_until : validUntil } ) ;
136
+ cache . set ( cacheKey , { data : filteredUsageData , valid_until : validUntil } ) ;
85
137
return filteredUsageData ;
86
138
} catch ( error : unknown ) {
87
139
logger . error ( 'Error fetching metrics data:' , error ) ;
140
+ // Clear any cached data for this request to prevent stale data on retry
141
+ cache . delete ( cacheKey ) ;
88
142
const errorMessage = error instanceof Error ? error . message : String ( error ) ;
89
143
const statusCode = ( error && typeof error === 'object' && 'statusCode' in error )
90
144
? ( error as { statusCode : number } ) . statusCode
@@ -125,17 +179,12 @@ function updateMockDataDates(originalData: CopilotMetrics[], since?: string, unt
125
179
}
126
180
127
181
// Update dates in the dataset, copying existing entries when needed
128
- const result = dateRange . map ( ( date , index ) => {
129
- // Use existing data entries, cycling through them
182
+ const result : CopilotMetrics [ ] = dateRange . map ( ( date , index ) => {
130
183
const dataIndex = index % originalData . length ;
131
- const entry = { ...originalData [ dataIndex ] } ;
132
-
133
- // Update the date
134
- entry . date = date . toISOString ( ) . split ( 'T' ) [ 0 ] ;
135
-
136
- return entry ;
184
+ const src = originalData [ dataIndex ] ;
185
+ const newDate = date . toISOString ( ) . split ( 'T' ) [ 0 ] ;
186
+ return { ...src , date : newDate } ;
137
187
} ) ;
138
-
139
188
return result ;
140
189
}
141
190
0 commit comments