Skip to content

Commit 58c5f92

Browse files
committed
json extract helper
1 parent b783330 commit 58c5f92

File tree

3 files changed

+165
-168
lines changed

3 files changed

+165
-168
lines changed

apps/api/src/routes/custom-sql.ts

Lines changed: 107 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ const ALLOWED_OPERATIONS = [
3939
'UNION ALL',
4040
'JSONExtract',
4141
'JSONExtractString',
42+
'JSONExtractInt',
43+
'JSONExtractFloat',
44+
'JSONExtractBool',
4245
'JSONExtractRaw',
4346
'CASE',
4447
'WHEN',
@@ -141,6 +144,29 @@ const TABLE_PATTERN = /(?:FROM|JOIN)\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi;
141144
const SUBQUERY_PATTERN = /\(SELECT.*?\)/gi;
142145

143146
const QuerySecurityValidator = {
147+
transformPropertiesSyntax(query: string): string {
148+
// Match properties.X patterns and convert to JSONExtract calls
149+
const propertiesPattern =
150+
/\bproperties\.([a-zA-Z_][a-zA-Z0-9_]*(?::(string|int|float|bool|raw))?)\b/gi;
151+
152+
return query.replace(propertiesPattern, (_match, propertyWithType) => {
153+
const [propertyName, type] = propertyWithType.split(':');
154+
155+
switch (type) {
156+
case 'int':
157+
return `JSONExtractInt(properties, '${propertyName}')`;
158+
case 'float':
159+
return `JSONExtractFloat(properties, '${propertyName}')`;
160+
case 'bool':
161+
return `JSONExtractBool(properties, '${propertyName}')`;
162+
case 'raw':
163+
return `JSONExtractRaw(properties, '${propertyName}')`;
164+
default:
165+
return `JSONExtractString(properties, '${propertyName}')`;
166+
}
167+
});
168+
},
169+
144170
normalizeSQL(query: string): string {
145171
return query
146172
.replace(/\/\*[\s\S]*?\*\//g, '')
@@ -188,7 +214,7 @@ const QuerySecurityValidator = {
188214
}
189215
},
190216

191-
validateQueryStructure(normalizedQuery: string): void {
217+
validateQueryStart(normalizedQuery: string): void {
192218
const startsWithSelect = normalizedQuery.startsWith('SELECT');
193219
const startsWithWith = normalizedQuery.startsWith('WITH');
194220
const hasValidStart = startsWithSelect || startsWithWith;
@@ -198,7 +224,9 @@ const QuerySecurityValidator = {
198224
'INVALID_QUERY_START'
199225
);
200226
}
227+
},
201228

229+
validateQueryComplexity(normalizedQuery: string): void {
202230
const selectMatches = normalizedQuery.match(/SELECT/g);
203231
const selectCount = selectMatches ? selectMatches.length : 0;
204232
if (selectCount > 3) {
@@ -214,7 +242,9 @@ const QuerySecurityValidator = {
214242
'UNION_NOT_ALLOWED'
215243
);
216244
}
245+
},
217246

247+
validateQuerySyntax(normalizedQuery: string): void {
218248
if (normalizedQuery.includes('/*') || normalizedQuery.includes('--')) {
219249
throw new SQLValidationError(
220250
'Comments are not allowed in queries',
@@ -229,6 +259,15 @@ const QuerySecurityValidator = {
229259
);
230260
}
231261

262+
if (normalizedQuery.includes('\\') || normalizedQuery.includes('%')) {
263+
throw new SQLValidationError(
264+
'Escape sequences and URL encoding not allowed',
265+
'ENCODING_NOT_ALLOWED'
266+
);
267+
}
268+
},
269+
270+
validateParenthesesBalance(normalizedQuery: string): void {
232271
let parenCount = 0;
233272
for (const char of normalizedQuery) {
234273
if (char === '(') {
@@ -250,13 +289,13 @@ const QuerySecurityValidator = {
250289
'INVALID_SYNTAX'
251290
);
252291
}
292+
},
253293

254-
if (normalizedQuery.includes('\\') || normalizedQuery.includes('%')) {
255-
throw new SQLValidationError(
256-
'Escape sequences and URL encoding not allowed',
257-
'ENCODING_NOT_ALLOWED'
258-
);
259-
}
294+
validateQueryStructure(normalizedQuery: string): void {
295+
this.validateQueryStart(normalizedQuery);
296+
this.validateQueryComplexity(normalizedQuery);
297+
this.validateQuerySyntax(normalizedQuery);
298+
this.validateParenthesesBalance(normalizedQuery);
260299
},
261300

262301
validateClientAccess(query: string, clientId: string): string {
@@ -362,13 +401,21 @@ const QuerySecurityValidator = {
362401

363402
QuerySecurityValidator.validateAgainstAttackVectors(query);
364403

365-
const normalizedQuery = QuerySecurityValidator.normalizeSQL(query);
404+
// Transform properties.X syntax to JSONExtract calls before validation
405+
const transformedQuery =
406+
QuerySecurityValidator.transformPropertiesSyntax(query);
407+
408+
const normalizedQuery =
409+
QuerySecurityValidator.normalizeSQL(transformedQuery);
366410

367411
QuerySecurityValidator.validateForbiddenOperations(normalizedQuery);
368412
QuerySecurityValidator.validateQueryStructure(normalizedQuery);
369413
QuerySecurityValidator.validateAllowedTables(normalizedQuery);
370414

371-
return QuerySecurityValidator.validateClientAccess(query, clientId);
415+
return QuerySecurityValidator.validateClientAccess(
416+
transformedQuery,
417+
clientId
418+
);
372419
},
373420
};
374421

@@ -529,6 +576,19 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
529576
clientIdParameter: '{clientId:String}',
530577
required: 'All queries must use parameterized client filtering',
531578
},
579+
propertiesSyntax: {
580+
description:
581+
'Automatic JSONExtract transformation from properties.X syntax',
582+
syntax: 'properties.property_name[:type]',
583+
supportedTypes: ['string', 'int', 'float', 'bool', 'raw'],
584+
examples: [
585+
'properties.browser_name',
586+
'properties.user_id:int',
587+
'properties.is_active:bool',
588+
'properties.metadata:raw',
589+
],
590+
defaultType: 'string (JSONExtractString)',
591+
},
532592
},
533593
};
534594
})
@@ -540,11 +600,11 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
540600
name: 'Monthly Events Count',
541601
description: 'Get monthly event counts for your client',
542602
query: `
543-
SELECT
603+
SELECT
544604
toStartOfMonth(time) as month_start,
545605
count() as event_count
546-
FROM analytics.events
547-
WHERE
606+
FROM analytics.events
607+
WHERE
548608
time >= now() - INTERVAL 6 MONTH
549609
GROUP BY month_start
550610
ORDER BY month_start DESC
@@ -554,12 +614,12 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
554614
name: 'Top Pages by Views',
555615
description: 'Get most popular pages',
556616
query: `
557-
SELECT
617+
SELECT
558618
path,
559619
count() as page_views,
560620
uniq(session_id) as unique_sessions
561-
FROM analytics.events
562-
WHERE
621+
FROM analytics.events
622+
WHERE
563623
time >= now() - INTERVAL 30 DAY
564624
AND event_name = 'page_view'
565625
GROUP BY path
@@ -568,30 +628,51 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
568628
`.trim(),
569629
},
570630
{
571-
name: 'Browser Analytics',
572-
description: 'Analyze browser usage',
631+
name: 'Browser Analytics (with properties.X syntax)',
632+
description: 'Analyze browser usage using properties.X syntax',
573633
query: `
574-
SELECT
575-
browser_name,
634+
SELECT
635+
properties.browser_name,
576636
count() as events,
577637
uniq(anonymous_id) as unique_users
578-
FROM analytics.events
579-
WHERE
638+
FROM analytics.events
639+
WHERE
580640
time >= now() - INTERVAL 7 DAY
581-
AND browser_name IS NOT NULL
582-
GROUP BY browser_name
641+
AND properties.browser_name IS NOT NULL
642+
GROUP BY properties.browser_name
583643
ORDER BY events DESC
584644
`.trim(),
585645
},
646+
{
647+
name: 'User Analytics with Typed Properties',
648+
description: 'Analyze user behavior with typed property extraction',
649+
query: `
650+
SELECT
651+
properties.user_id:int as user_id,
652+
properties.is_premium:bool as is_premium,
653+
properties.session_duration:float as session_duration,
654+
count() as total_events
655+
FROM analytics.events
656+
WHERE
657+
time >= now() - INTERVAL 30 DAY
658+
AND properties.user_id:int IS NOT NULL
659+
GROUP BY
660+
properties.user_id:int,
661+
properties.is_premium:bool,
662+
properties.session_duration:float
663+
ORDER BY total_events DESC
664+
LIMIT 20
665+
`.trim(),
666+
},
586667
{
587668
name: 'Error Events Analysis',
588669
description: 'Analyze error events',
589670
query: `
590-
SELECT
671+
SELECT
591672
url,
592673
count() as error_count
593-
FROM analytics.errors
594-
WHERE
674+
FROM analytics.errors
675+
WHERE
595676
time >= now() - INTERVAL 7 DAY
596677
GROUP BY url
597678
ORDER BY error_count DESC

apps/dashboard/components/icon.tsx

Lines changed: 1 addition & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -227,126 +227,6 @@ export function OSIcon({
227227
);
228228
}
229229

230-
// Country code mapping for local flags
231-
const COUNTRY_CODE_MAP: Record<string, string> = {
232-
// Common country names to ISO 3166-1 alpha-2 codes
233-
'United States': 'us',
234-
'United Kingdom': 'gb',
235-
'Great Britain': 'gb',
236-
'England': 'gb',
237-
'Canada': 'ca',
238-
'Australia': 'au',
239-
'Germany': 'de',
240-
'France': 'fr',
241-
'Italy': 'it',
242-
'Spain': 'es',
243-
'Netherlands': 'nl',
244-
'Belgium': 'be',
245-
'Switzerland': 'ch',
246-
'Austria': 'at',
247-
'Sweden': 'se',
248-
'Norway': 'no',
249-
'Denmark': 'dk',
250-
'Finland': 'fi',
251-
'Portugal': 'pt',
252-
'Ireland': 'ie',
253-
'Poland': 'pl',
254-
'Czech Republic': 'cz',
255-
'Hungary': 'hu',
256-
'Slovakia': 'sk',
257-
'Slovenia': 'si',
258-
'Croatia': 'hr',
259-
'Bosnia and Herzegovina': 'ba',
260-
'Serbia': 'rs',
261-
'Montenegro': 'me',
262-
'Kosovo': 'xk',
263-
'Albania': 'al',
264-
'Greece': 'gr',
265-
'Bulgaria': 'bg',
266-
'Romania': 'ro',
267-
'Estonia': 'ee',
268-
'Latvia': 'lv',
269-
'Lithuania': 'lt',
270-
'Russia': 'ru',
271-
'Ukraine': 'ua',
272-
'Belarus': 'by',
273-
'Moldova': 'md',
274-
'Turkey': 'tr',
275-
'Japan': 'jp',
276-
'China': 'cn',
277-
'South Korea': 'kr',
278-
'India': 'in',
279-
'Brazil': 'br',
280-
'Mexico': 'mx',
281-
'Argentina': 'ar',
282-
'Colombia': 'co',
283-
'Chile': 'cl',
284-
'Peru': 'pe',
285-
'Venezuela': 've',
286-
'Ecuador': 'ec',
287-
'Bolivia': 'bo',
288-
'Paraguay': 'py',
289-
'Uruguay': 'uy',
290-
'South Africa': 'za',
291-
'Egypt': 'eg',
292-
'Nigeria': 'ng',
293-
'Kenya': 'ke',
294-
'Morocco': 'ma',
295-
'Tunisia': 'tn',
296-
'Algeria': 'dz',
297-
'Israel': 'il',
298-
'Saudi Arabia': 'sa',
299-
'UAE': 'ae',
300-
'United Arab Emirates': 'ae',
301-
'Iran': 'ir',
302-
'Iraq': 'iq',
303-
'Jordan': 'jo',
304-
'Lebanon': 'lb',
305-
'Syria': 'sy',
306-
'Yemen': 'ye',
307-
'Oman': 'om',
308-
'Kuwait': 'kw',
309-
'Qatar': 'qa',
310-
'Bahrain': 'bh',
311-
'Thailand': 'th',
312-
'Vietnam': 'vn',
313-
'Indonesia': 'id',
314-
'Malaysia': 'my',
315-
'Singapore': 'sg',
316-
'Philippines': 'ph',
317-
'Australia': 'au',
318-
'New Zealand': 'nz',
319-
};
320-
321-
function getCountryCode(countryName: string): string {
322-
// First try direct mapping
323-
const direct = COUNTRY_CODE_MAP[countryName];
324-
if (direct) return direct;
325-
326-
// Try case-insensitive match
327-
const lowerName = countryName.toLowerCase();
328-
for (const [name, code] of Object.entries(COUNTRY_CODE_MAP)) {
329-
if (name.toLowerCase() === lowerName) {
330-
return code;
331-
}
332-
}
333-
334-
// Try partial match
335-
for (const [name, code] of Object.entries(COUNTRY_CODE_MAP)) {
336-
if (name.toLowerCase().includes(lowerName) || lowerName.includes(name.toLowerCase())) {
337-
return code;
338-
}
339-
}
340-
341-
// If no match found, try to use the first 2 characters as country code
342-
const twoCharCode = countryName.toLowerCase().slice(0, 2);
343-
if (twoCharCode.length === 2) {
344-
return twoCharCode;
345-
}
346-
347-
return '';
348-
}
349-
350230
interface CountryFlagProps {
351231
country: string;
352232
size?: 'sm' | 'md' | 'lg' | number;
@@ -374,20 +254,6 @@ export function CountryFlag({
374254
);
375255
}
376256

377-
const countryCode = getCountryCode(country);
378-
379-
if (!countryCode) {
380-
return (
381-
fallback || (
382-
<div
383-
className={cn('flex h-4 w-6 items-center justify-center', className)}
384-
>
385-
<div className="h-4 w-4 text-muted-foreground">?</div>
386-
</div>
387-
)
388-
);
389-
}
390-
391257
return (
392258
<Image
393259
alt={`${country} flag`}
@@ -397,7 +263,7 @@ export function CountryFlag({
397263
const img = e.target as HTMLImageElement;
398264
img.style.display = 'none';
399265
}}
400-
src={`/flags/${countryCode}.svg`}
266+
src={`https://purecatamphetamine.github.io/country-flag-icons/3x2/${country.toUpperCase()}.svg`}
401267
width={24}
402268
/>
403269
);

0 commit comments

Comments
 (0)