Skip to content

Commit 53c22e6

Browse files
committed
Merge branch 'main' into feat/ga4-importer
2 parents da37588 + da22a9b commit 53c22e6

30 files changed

Lines changed: 6632 additions & 4193 deletions

backend/apps/cloud/src/analytics/analytics.service.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import utc from 'dayjs/plugin/utc'
2626
import dayjsTimezone from 'dayjs/plugin/timezone'
2727
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
2828
import ipRangeCheck from 'ip-range-check'
29-
import validator from 'validator'
3029
import {
3130
Injectable,
3231
BadRequestException,
@@ -110,6 +109,15 @@ dayjs.extend(utc)
110109
dayjs.extend(dayjsTimezone)
111110
dayjs.extend(isSameOrBefore)
112111

112+
const isValidLocale = (lc: string): boolean => {
113+
try {
114+
Intl.getCanonicalLocales(lc.replace(/_/g, '-'))
115+
return true
116+
} catch {
117+
return false
118+
}
119+
}
120+
113121
// 2 minutes
114122
const LIVE_SESSION_THRESHOLD_SECONDS = 120
115123

@@ -465,7 +473,7 @@ export class AnalyticsService {
465473

466474
// validate locale ('lc' param)
467475
if (!_isEmpty(lc)) {
468-
if (validator.isLocale(lc)) {
476+
if (isValidLocale(lc)) {
469477
// uppercase the locale after '-' char, so for example both 'en-gb' and 'en-GB' in result will be 'en-GB'
470478
const lcParted = _split(lc, '-')
471479

backend/apps/cloud/src/app.module.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { ConfigModule } from '@nestjs/config'
44
import { APP_FILTER } from '@nestjs/core'
55
import { SentryModule, SentryGlobalFilter } from '@sentry/nestjs/setup'
66
import { ScheduleModule } from '@nestjs/schedule'
7-
import { NestjsFormDataModule } from 'nestjs-form-data'
87
import { MailerModule as NodeMailerModule } from '@nestjs-modules/mailer'
98

109
import { I18nModule } from 'nestjs-i18n'
@@ -72,7 +71,6 @@ const modules = [
7271
}),
7372
I18nModule.forRootAsync(getI18nConfig()),
7473
ScheduleModule.forRoot(),
75-
NestjsFormDataModule.config({ isGlobal: true }),
7674
BullModule.forRoot({
7775
connection: {
7876
host: process.env.REDIS_HOST,

backend/apps/cloud/src/auth/auth.service.ts

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@nestjs/common'
1010
import { ConfigService } from '@nestjs/config'
1111
import { JwtService } from '@nestjs/jwt'
12-
import axios from 'axios'
12+
1313
import { genSalt, hash, compare } from 'bcrypt'
1414
import { getCountry } from 'countries-and-timezones'
1515
import { OAuth2Client } from 'google-auth-library'
@@ -116,30 +116,27 @@ export class AuthService {
116116
const lastChars = sha1Hash.slice(5)
117117

118118
try {
119-
const response = await axios.get(
119+
const response = await fetch(
120120
`https://api.pwnedpasswords.com/range/${firstFiveChars}`,
121121
{
122-
timeout: 5000,
122+
signal: AbortSignal.timeout(5000),
123123
headers: {
124-
// Helps against traffic analysis; supported by HIBP.
125124
'Add-Padding': 'true',
126125
'User-Agent': 'Swetrix',
127126
},
128127
},
129128
)
130129

131-
if (response.status !== 200) {
130+
if (!response.ok) {
132131
console.error(
133132
`[ERROR][AuthService -> checkIfLeaked]: Failed to get pwned passwords for ${firstFiveChars}: ${response.status}`,
134133
)
135134
return false
136135
}
137136

138-
// Response lines look like: "<HASH_SUFFIX>:<COUNT>"
137+
const body = await response.text()
139138
const needle = `${lastChars}:`
140-
return String(response.data)
141-
.split('\n')
142-
.some((line) => line.startsWith(needle))
139+
return body.split('\n').some((line) => line.startsWith(needle))
143140
} catch (error) {
144141
console.error(
145142
`[ERROR][AuthService -> checkIfLeaked]: ${error} (prefix ${firstFiveChars})`,
@@ -980,39 +977,42 @@ export class AuthService {
980977
let tokenInfo
981978

982979
try {
983-
const response = await axios.post(
980+
const tokenRes = await fetch(
984981
'https://github.com/login/oauth/access_token',
985982
{
986-
client_id: GITHUB_OAUTH2_CLIENT_ID,
987-
client_secret: GITHUB_OAUTH2_CLIENT_SECRET,
988-
code,
989-
},
990-
{
983+
method: 'POST',
991984
headers: {
985+
'Content-Type': 'application/json',
992986
Accept: 'application/json',
993987
},
988+
body: JSON.stringify({
989+
client_id: GITHUB_OAUTH2_CLIENT_ID,
990+
client_secret: GITHUB_OAUTH2_CLIENT_SECRET,
991+
code,
992+
}),
994993
},
995994
)
996995

997-
token = response.data.access_token
996+
const tokenData = await tokenRes.json()
997+
token = tokenData.access_token
998998
} catch (reason) {
999999
console.error(
1000-
`[ERROR][AuthService -> processGithubCode -> axios.post]: ${reason}`,
1000+
`[ERROR][AuthService -> processGithubCode -> fetch (token)]: ${reason}`,
10011001
)
10021002
throw new BadRequestException('Invalid Github code supplied')
10031003
}
10041004

10051005
try {
1006-
const response = await axios.get('https://api.github.com/user', {
1006+
const userRes = await fetch('https://api.github.com/user', {
10071007
headers: {
10081008
Authorization: `token ${token}`,
10091009
},
10101010
})
10111011

1012-
tokenInfo = response.data
1012+
tokenInfo = await userRes.json()
10131013
} catch (reason) {
10141014
console.error(
1015-
`[ERROR][AuthService -> processGithubCode -> axios.get]: ${reason}`,
1015+
`[ERROR][AuthService -> processGithubCode -> fetch (user)]: ${reason}`,
10161016
)
10171017
throw new BadRequestException('Invalid Github token')
10181018
}
@@ -1022,13 +1022,13 @@ export class AuthService {
10221022

10231023
if (!email) {
10241024
try {
1025-
const response = await axios.get('https://api.github.com/user/emails', {
1025+
const emailsRes = await fetch('https://api.github.com/user/emails', {
10261026
headers: {
10271027
Authorization: `token ${token}`,
10281028
},
10291029
})
10301030

1031-
const emails = response.data
1031+
const emails = await emailsRes.json()
10321032

10331033
if (_isEmpty(emails)) {
10341034
console.error(
@@ -1040,7 +1040,7 @@ export class AuthService {
10401040
email = _find(emails, (e) => e.primary).email
10411041
} catch (reason) {
10421042
console.error(
1043-
`[ERROR][AuthService -> processGithubCode -> axios.get (emails)]: ${reason}`,
1043+
`[ERROR][AuthService -> processGithubCode -> fetch (emails)]: ${reason}`,
10441044
)
10451045
throw new BadRequestException('Invalid Github token')
10461046
}

backend/apps/cloud/src/common/utils.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import net from 'net'
55
import { Reader, CityResponse } from 'maxmind'
66
import { HttpException } from '@nestjs/common'
77
import timezones from 'countries-and-timezones'
8-
import randomstring from 'randomstring'
8+
99
import _sample from 'lodash/sample'
1010
import _toNumber from 'lodash/toNumber'
1111
import _replace from 'lodash/replace'
@@ -184,18 +184,18 @@ export const calculateRelativePercentage = (
184184
return _round((1 - newVal / oldVal) * -100, round)
185185
}
186186

187-
export const generateRecoveryCode = () =>
188-
randomstring.generate({
189-
length: 30,
190-
charset: 'alphabetic',
191-
capitalization: 'uppercase',
192-
})
187+
const ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
188+
189+
export const generateRecoveryCode = () => generateRandomId(ALPHA_UPPER, 30)
193190

194191
export const millisecondsToSeconds = (milliseconds: number) =>
195192
milliseconds / 1000
196193

194+
const ALPHANUMERIC =
195+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
196+
197197
export const generateRandomString = (length: number): string =>
198-
randomstring.generate(length)
198+
generateRandomId(ALPHANUMERIC, length)
199199

200200
/**
201201
* This is used to determine if the current node is the primary node.

backend/apps/cloud/src/data-import/ga4-import.service.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
} from '@nestjs/common'
66
import { ConfigService } from '@nestjs/config'
77
import { OAuth2Client } from 'google-auth-library'
8-
import axios from 'axios'
98
import CryptoJS from 'crypto-js'
109

1110
import { isDevelopment, PRODUCTION_ORIGIN, redis } from '../common/constants'
@@ -158,14 +157,28 @@ export class Ga4ImportService {
158157
await redis.expire(REDIS_TOKEN_PREFIX + `${uid}:${pid}`, REDIS_TOKEN_TTL)
159158

160159
try {
161-
const { data } = await axios.get(
160+
const url = new URL(
162161
'https://analyticsadmin.googleapis.com/v1beta/accountSummaries',
163-
{
164-
headers: { Authorization: `Bearer ${token}` },
165-
params: { pageSize: 200 },
166-
},
167162
)
163+
url.searchParams.set('pageSize', '200')
168164

165+
const response = await fetch(url, {
166+
headers: { Authorization: `Bearer ${token}` },
167+
})
168+
169+
if (response.status === 403) {
170+
const body = await response.json()
171+
throw new BadRequestException(
172+
body?.error?.message ||
173+
'Access denied by Google. Please ensure the "Google Analytics Admin API" is enabled in your Google Cloud project and that your Google account has access to at least one GA4 property.',
174+
)
175+
}
176+
177+
if (!response.ok) {
178+
throw new Error(`Google API responded with status ${response.status}`)
179+
}
180+
181+
const data = await response.json()
169182
const properties: Ga4Property[] = []
170183

171184
for (const account of data.accountSummaries || []) {
@@ -179,13 +192,7 @@ export class Ga4ImportService {
179192

180193
return properties
181194
} catch (error) {
182-
const status = error?.response?.status
183-
if (status === 403) {
184-
throw new BadRequestException(
185-
error?.response?.data?.error?.message ||
186-
'Access denied by Google. Please ensure the "Google Analytics Admin API" is enabled in your Google Cloud project and that your Google account has access to at least one GA4 property.',
187-
)
188-
}
195+
if (error instanceof BadRequestException) throw error
189196

190197
throw new InternalServerErrorException(
191198
'Failed to fetch Google Analytics properties. Please try again.',

backend/apps/cloud/src/integrations/discord/discord.module.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { HttpModule } from '@nestjs/axios'
21
import { Module } from '@nestjs/common'
32
import { DiscordService } from './discord.service'
43

54
@Module({
6-
imports: [HttpModule],
75
providers: [DiscordService],
86
exports: [DiscordService],
97
})
Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
1-
import { HttpService } from '@nestjs/axios'
2-
import { firstValueFrom } from 'rxjs'
31
import { Injectable, Logger } from '@nestjs/common'
42
import { WebhookAbcService } from '../webhook-abc/webhook-abc.service'
53

64
@Injectable()
75
export class DiscordService extends WebhookAbcService {
86
private readonly logger = new Logger(DiscordService.name)
97

10-
constructor(private readonly httpService: HttpService) {
11-
super()
12-
}
13-
148
async sendWebhook(webhookUrl: string, message: unknown): Promise<void> {
159
try {
16-
// Defense-in-depth: DTO validation should already enforce Discord webhook URL format.
17-
// Still, validate basic URL properties here to reduce SSRF surface area.
1810
const url = new URL(webhookUrl)
1911
const host = url.hostname.toLowerCase()
2012
if (
@@ -37,24 +29,27 @@ export class DiscordService extends WebhookAbcService {
3729
}
3830
}
3931

40-
// Discord content max length is 2000 chars; keep some buffer.
4132
if (content.length > 1900) {
4233
content = `${content.slice(0, 1900)}…`
4334
}
4435

4536
const payload = { content }
46-
await firstValueFrom(
47-
this.httpService.post(webhookUrl, payload, {
48-
timeout: 10_000,
49-
maxRedirects: 0,
50-
}),
51-
)
37+
const res = await fetch(webhookUrl, {
38+
method: 'POST',
39+
headers: { 'Content-Type': 'application/json' },
40+
body: JSON.stringify(payload),
41+
signal: AbortSignal.timeout(10_000),
42+
redirect: 'error',
43+
})
44+
45+
if (!res.ok) {
46+
this.logger.error(
47+
`Error sending Discord webhook (status ${res.status})`,
48+
)
49+
}
5250
} catch (error) {
53-
const status = (error as any)?.response?.status
5451
const message = (error as any)?.message || String(error)
55-
this.logger.error(
56-
`Error sending Discord webhook${status ? ` (status ${status})` : ''}: ${message}`,
57-
)
52+
this.logger.error(`Error sending Discord webhook: ${message}`)
5853
}
5954
}
6055
}

backend/apps/cloud/src/integrations/slack/slack.module.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { Module } from '@nestjs/common'
2-
import { HttpModule } from '@nestjs/axios'
32
import { SlackService } from './slack.service'
43

54
@Module({
6-
imports: [HttpModule],
75
providers: [SlackService],
86
exports: [SlackService],
97
})

0 commit comments

Comments
 (0)