diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88a922e34..3d9d99feb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,15 +18,19 @@ jobs: db-type: postgresql steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' - env: - DATABASE_TYPE: ${{ matrix.db-type }} - - run: npm install --global pnpm - - run: pnpm install - - run: pnpm test - - run: pnpm build + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - run: pnpm install + - run: pnpm test + - run: pnpm build diff --git a/.gitignore b/.gitignore index 51613b095..6ea838d81 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ node_modules .pnp .pnp.js -.pnpm-store # testing /coverage diff --git a/README.md b/README.md index fcbe856f1..cf84d7624 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ A detailed getting started guide can be found at [umami.is/docs](https://umami.i ```bash git clone https://github.com/umami-software/umami.git cd umami -pnpm install +npm install ``` ### Configure Umami @@ -64,7 +64,7 @@ mysql://username:mypassword@localhost:3306/mydb ### Build the Application ```bash -pnpm run build +npm run build ``` _The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**._ @@ -72,7 +72,7 @@ _The build step will create tables in your database if you are installing for th ### Start the Application ```bash -pnpm run start +npm run start ``` _By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly._ @@ -107,8 +107,8 @@ To get the latest features, simply do a pull, install any new dependencies, and ```bash git pull -pnpm install -pnpm run build +npm install +npm run build ``` To update the Docker image, simply pull the new images and rebuild: diff --git a/src/lang/es-ES.json b/src/lang/es-ES.json index 3e74ed353..c17122bd5 100644 --- a/src/lang/es-ES.json +++ b/src/lang/es-ES.json @@ -134,7 +134,7 @@ "label.last-days": "Últimos {x} días", "label.last-hours": "Últimas {x} horas", "label.last-months": "Últimos {x} meses", - "label.last-seen": "Visto por última vez", + "label.last-seen": "Última vez visto", "label.leave": "Abandonar", "label.leave-team": "Abandonar equipo", "label.less-than": "Menor que", @@ -210,9 +210,8 @@ "label.reset-website": "Reiniciar analíticas", "label.retention": "Retención", "label.retention-description": "Medir la frecuencia con la que los usuarios vuelven a tu sitio web.", - "label.revenue": "Ganancias", - "label.revenue-description": "Analice sus ganancias a lo largo del tiempo.", - "label.revenue-property": "Propiedad de ganancias", + "label.revenue": "Ingresos", + "label.revenue-description": "Consulte sus ingresos a lo largo del tiempo.", "label.role": "Rol", "label.run-query": "Ejecutar consulta", "label.save": "Guardar", @@ -224,6 +223,7 @@ "label.select-role": "Seleccionar rol", "label.select-website": "Seleccionar sitio web", "label.session": "Sesión", + "label.session-data": "Datos de sesión", "label.sessions": "Sesiones", "label.settings": "Ajustes", "label.share": "Compartir", @@ -259,19 +259,18 @@ "label.total": "Total", "label.total-records": "Total de registros", "label.tracking-code": "Código de rastreo", - "label.transactions": "Transacciones", + "label.transactions": "Transactions", "label.transfer": "Transferir", "label.transfer-website": "Transferir sitio web", "label.true": "Verdadero", "label.type": "Tipo", "label.unique": "Único", "label.unique-visitors": "Visitantes únicos", - "label.uniqueCustomers": "Clientes únicos", + "label.uniqueCustomers": "Unique Customers", "label.unknown": "Desconocida", "label.untitled": "Sin título", "label.update": "Actualizar", "label.user": "Usuario", - "label.user-property": "Propiedad de usuario", "label.username": "Nombre de usuario", "label.users": "Usuarios", "label.utm": "UTM", diff --git a/src/lang/sl-SI.json b/src/lang/sl-SI.json index 5834881f5..422957485 100644 --- a/src/lang/sl-SI.json +++ b/src/lang/sl-SI.json @@ -32,21 +32,27 @@ "label.cities": "Mesta", "label.city": "Mesto", "label.clear-all": "Počisti vse", + "label.cohort": "Kohorta", "label.compare": "Primerjaj", + "label.compare-dates": "Primerjaj datume", "label.confirm": "Potrdi", "label.confirm-password": "Potrdi geslo", "label.contains": "Vsebuje", "label.content": "Vsebina", "label.continue": "Nadaljuj", - "label.count": "Število", + "label.conversion": "Konverzija", + "label.conversion-rate": "Stopnja konverzije", + "label.conversion-step": "Korak konverzije", + "label.count": "Števec", "label.countries": "Države", "label.country": "Država", - "label.create": "Ustvari", + "label.create": "Create", "label.create-report": "Ustvari poročilo", "label.create-team": "Ustvari ekipo", "label.create-user": "Ustvari uporabnika", "label.created": "Ustvarjeno", "label.created-by": "Ustvaril", + "label.currency": "Valuta", "label.current": "Trenutno", "label.current-password": "Trenutno geslo", "label.custom-range": "Obdobje po meri", @@ -77,14 +83,16 @@ "label.edit": "Uredi", "label.edit-dashboard": "Uredi nadzorno ploščo", "label.edit-member": "Uredi člana", - "label.enable-share-url": "Omogoči povezavo za deljenje", + "label.email": "Email", + "label.enable-share-url": "Uredi povezavo za deljenje", "label.end-step": "Končni korak", - "label.entry": "Vstopni URL", + "label.entry": "Vhodni URL", "label.event": "Dogodek", "label.event-data": "Podatki dogodka", "label.event-name": "Ime dogodka", "label.events": "Dogodki", - "label.exit": "Izhodni URL", + "label.exists": "Obstaja", + "label.exit": "Exit URL", "label.false": "Napačno", "label.field": "Polje", "label.fields": "Polja", @@ -92,18 +100,22 @@ "label.filter-combined": "Skupaj", "label.filter-raw": "Neobdelano", "label.filters": "Filtri", - "label.first-seen": "Prvič viden", + "label.first-click": "Prvi klik", + "label.first-seen": "First seen", "label.funnel": "Prodajni lijak", - "label.funnel-description": "Razumite stopnjo konverzije in osipa uporabnikov.", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Lijaki", "label.goal": "Cilj", "label.goals": "Cilji", "label.goals-description": "Spremljajte svoje cilje za oglede strani in dogodke.", "label.greater-than": "Večje od", "label.greater-than-equals": "Večje ali enako kot", - "label.host": "Gostitelj", - "label.hosts": "Gostitelji", + "label.grouped": "Združeno", + "label.hostname": "Ime gostitelja", + "label.includes": "Vključuje", + "label.insight": "Vpogled", "label.insights": "Vpogled", - "label.insights-description": "Poglobite se v podatke z uporabo segmentov in filtrov.", + "label.insights-description": "Dive deeper into your data by using segments and filters.", "label.is": "Je", "label.is-false": "Je napačno", "label.is-not": "Ni", @@ -112,8 +124,9 @@ "label.is-true": "Je res", "label.join": "Pridruži se", "label.join-team": "Pridruži se ekipi", - "label.journey": "Uporabniška pot", + "label.journey": "Potovanje", "label.journey-description": "Razumite, kako uporabniki krmarijo po vašem spletnem mestu.", + "label.journeys": "Potovanja", "label.language": "Jezik", "label.languages": "Jeziki", "label.laptop": "Prenosni računalnik", @@ -121,7 +134,7 @@ "label.last-days": "Zadnjih {x} dni", "label.last-hours": "Zadnjih {x} ur", "label.last-months": "Zadnjih {x} mesecev", - "label.last-seen": "Nazadnje viden", + "label.last-seen": "Zadnjič videno", "label.leave": "Zapusti", "label.leave-team": "Zapusti ekipo", "label.less-than": "Manjše kot", @@ -129,9 +142,11 @@ "label.links": "Povezave", "label.login": "Prijava", "label.logout": "Odjava", - "label.manage": "Upravljaj", - "label.manager": "Upravitelj", + "label.manage": "Manage", + "label.manager": "Manager", "label.max": "Največ", + "label.maximize": "Razširi", + "label.medium": "Srednje", "label.member": "Član", "label.members": "Člani", "label.min": "Najmanj", @@ -167,6 +182,7 @@ "label.password": "Geslo", "label.path": "Pot", "label.paths": "Poti", + "label.pixels": "Pikslov", "label.powered-by": "Poganja {name}", "label.previous": "Prejšnji", "label.previous-period": "Prejšnje obdobje", @@ -187,44 +203,48 @@ "label.regions": "Regije", "label.remaining": "Preostalo", "label.remove": "Odstrani", - "label.remove-member": "Odstrani člana", + "label.remove-member": "Remove member", "label.reports": "Poročila", "label.required": "Zahtevano", "label.reset": "Ponastavi", "label.reset-website": "Ponastavi statistiko", "label.retention": "Ohranjanje uporabnikov", - "label.retention-description": "Merite uporabnikovo zadržanost s sledenjem, kako pogosto se vračajo.", - "label.revenue": "Prihodki", - "label.revenue-description": "Preglejte svoje prihodke skozi čas.", - "label.revenue-property": "Lastnost prihodkov", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Prihodek", + "label.revenue-description": "Oglejte si svoj prihodek skozi čas.", "label.role": "Vloga", "label.run-query": "Izvedi poizvedbo", "label.save": "Shrani", "label.screens": "Zasloni", - "label.search": "Išči", - "label.select": "Izberi", + "label.search": "Search", + "label.select": "Select", "label.select-date": "Izberi datum", - "label.select-role": "Izberi vlogo", + "label.select-filter": "Izberi filter", + "label.select-role": "Select role", "label.select-website": "Izberi spletno mesto", "label.session": "Seja", + "label.session-data": "Podatki seje", "label.sessions": "Seje", "label.settings": "Nastavitve", "label.share": "Deli", "label.share-url": "Deli povezavo", "label.single-day": "En dan", - "label.start-step": "Začetni korak", - "label.steps": "Koraki", + "label.sms": "SMS", + "label.sources": "Viri", + "label.start-step": "Start Step", + "label.steps": "Steps", "label.sum": "Seštevek", "label.tablet": "Tablični računalnik", "label.tag": "Oznaka", "label.tags": "Oznake", "label.team": "Ekipa", "label.team-id": "ID ekipe", - "label.team-manager": "Upravitelj ekipe", + "label.team-manager": "Vodja ekipe", "label.team-member": "Član ekipe", "label.team-name": "Ime ekipe", "label.team-owner": "Lastnik ekipe", - "label.team-view-only": "Ekipa samo za ogled", + "label.team-settings": "Nastavitve ekipe", + "label.team-view-only": "Team view only", "label.team-websites": "Spletna mesta ekipe", "label.teams": "Ekipe", "label.terms": "Pogoji", @@ -266,17 +286,18 @@ "label.visits": "Visits", "label.website": "Spletno mesto", "label.website-id": "ID spletnega mesta", - "label.websites": "Spletna mesta", + "label.websites": "Spletnih mest", "label.window": "Okno", "label.yesterday": "Včeraj", - "message.action-confirmation": "Za potrditev v spodnje polje vnesite {confirmation}.", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", "message.active-users": "{x} trenutni {x, plural, one {obiskovalec} other {obiskovalcev}}", - "message.collected-data": "Zbrani podatki", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", "message.confirm-delete": "Ste prepričani, da želite izbrisati {target}?", "message.confirm-leave": "Ste prepričani, da želite zapustiti {target}?", - "message.confirm-remove": "Ali ste prepričani, da želite odstraniti {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", "message.confirm-reset": "Ste prepričani, da želite ponastaviti statistiko {target}?", - "message.delete-team-warning": "Brisanje ekipe bo izbrisalo tudi vsa spletna mesta ekipe.", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", "message.delete-website-warning": "Izbrisani bodo tudi vsi pripadajoči podatki.", "message.error": "Nekaj je šlo narobe.", "message.event-log": "{event} na {url}", @@ -306,12 +327,12 @@ "message.team-not-found": "Ekipa ni bila najdena.", "message.team-websites-info": "Spletne strani si lahko ogleda vsak član ekipe.", "message.tracking-code": "Koda za sledenje", - "message.transfer-team-website-to-user": "Želite prenesti to spletno mesto v svoj račun?", - "message.transfer-user-website-to-team": "Izberite ekipo, na katero želite prenesti to spletno mesto.", - "message.transfer-website": "Prenesite lastništvo spletnega mesta na svoj račun ali drugo ekipo.", - "message.triggered-event": "Sprožen dogodek", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", "message.user-deleted": "Uporabnik je izbrisan.", - "message.viewed-page": "Ogledana stran", - "message.visitor-log": "Obiskovalec iz {country} uporablja {browser} na {os} {device}", - "message.visitors-dropped-off": "Osip obiskovalcev" + "message.viewed-page": "Viewed page", + "message.visitor-log": "Obiskovalec iz {country} uporablja {browser} na {os} {device}" } diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts index fcf706af1..0723eb331 100644 --- a/src/lib/__tests__/detect.test.ts +++ b/src/lib/__tests__/detect.test.ts @@ -1,7 +1,6 @@ import * as detect from '../detect'; const IP = '127.0.0.1'; -const BAD_IP = '127.127.127.127'; test('getIpAddress: Custom header', () => { process.env.CLIENT_IP_HEADER = 'x-custom-ip-header'; @@ -17,12 +16,6 @@ test('getIpAddress: Standard header', () => { expect(detect.getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); }); -test('getIpAddress: CloudFlare header is lower priority than standard header', () => { - expect( - detect.getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP })), - ).toEqual(IP); -}); - test('getIpAddress: No header', () => { expect(detect.getIpAddress(new Headers())).toEqual(null); }); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 136f85115..11d35aeeb 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -126,12 +126,12 @@ export const DATA_TYPES = { export const ROLES = { admin: 'admin', - teamManager: 'team-manager', - teamMember: 'team-member', - teamOwner: 'team-owner', - teamViewOnly: 'team-view-only', user: 'user', viewOnly: 'view-only', + teamOwner: 'team-owner', + teamManager: 'team-manager', + teamMember: 'team-member', + teamViewOnly: 'team-view-only', } as const; export const PERMISSIONS = { @@ -215,11 +215,40 @@ export const SHARE_ID_REGEX = /^[a-zA-Z0-9]{8,50}$/; export const DATETIME_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3}(Z|\+[0-9]{2}:[0-9]{2})?)?$/; +export const DESKTOP_SCREEN_WIDTH = 1920; +export const LAPTOP_SCREEN_WIDTH = 1024; +export const MOBILE_SCREEN_WIDTH = 479; + export const URL_LENGTH = 500; export const PAGE_TITLE_LENGTH = 500; export const EVENT_NAME_LENGTH = 50; -export const UTM_PARAMS = ['utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term']; +export const UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; + +export const DESKTOP_OS = [ + 'BeOS', + 'Chrome OS', + 'Linux', + 'Mac OS', + 'Open BSD', + 'OS/2', + 'QNX', + 'Sun OS', + 'Windows 10', + 'Windows 2000', + 'Windows 3.11', + 'Windows 7', + 'Windows 8', + 'Windows 8.1', + 'Windows 95', + 'Windows 98', + 'Windows ME', + 'Windows Server 2003', + 'Windows Vista', + 'Windows XP', +]; + +export const MOBILE_OS = ['Amazon OS', 'Android OS', 'BlackBerry OS', 'iOS', 'Windows Mobile']; export const OS_NAMES = { 'Android OS': 'Android', @@ -232,8 +261,8 @@ export const OS_NAMES = { export const BROWSERS = { android: 'Android', aol: 'AOL', - bb10: 'BlackBerry 10', beaker: 'Beaker', + bb10: 'BlackBerry 10', chrome: 'Chrome', 'chromium-webview': 'Chrome (webview)', crios: 'Chrome (iOS)', @@ -255,247 +284,371 @@ export const BROWSERS = { phantomjs: 'PhantomJS', safari: 'Safari', samsung: 'Samsung', - searchbot: 'Searchbot', silk: 'Silk', + searchbot: 'Searchbot', yandexbrowser: 'Yandex', } as const; +export const IP_ADDRESS_HEADERS = [ + 'cf-connecting-ip', + 'x-client-ip', + 'x-forwarded-for', + 'do-connecting-ip', + 'fastly-client-ip', + 'true-client-ip', + 'x-real-ip', + 'x-cluster-client-ip', + 'x-forwarded', + 'forwarded', + 'x-appengine-user-ip', +]; + export const SOCIAL_DOMAINS = [ - 'bsky.app', 'facebook.com', 'fb.com', - 'ig.com', 'instagram.com', - 'linkedin.', - 'news.ycombinator.com', - 'pinterest.', - 'reddit.', - 'snapchat.', - 't.co', - 'threads.net', - 'tiktok.', + 'ig.com', 'twitter.com', + 't.co', 'x.com', + 'linkedin.', + 'tiktok.', + 'reddit.', + 'threads.net', + 'bsky.app', + 'news.ycombinator.com', + 'snapchat.', + 'pinterest.', ]; export const SEARCH_DOMAINS = [ - 'baidu.com', - 'bing.com', - 'chatgpt.com', - 'duckduckgo.com', - 'ecosia.org', 'google.', + 'bing.com', 'msn.com', - 'perplexity.ai', + 'duckduckgo.com', 'search.brave.com', 'yandex.', + 'baidu.com', + 'ecosia.org', + 'chatgpt.com', + 'perplexity.ai', ]; export const SHOPPING_DOMAINS = [ - 'alibaba.com', - 'aliexpress.com', 'amazon.', - 'bestbuy.com', 'ebay.com', - 'etsy.com', - 'newegg.com', - 'target.com', 'walmart.com', + 'alibab.com', + 'aliexpress.com', + 'etsy.com', + 'bestbuy.com', + 'target.com', + 'newegg.com', ]; export const EMAIL_DOMAINS = [ 'gmail.', - 'hotmail.', 'mail.yahoo.', 'outlook.', - 'proton.me', + 'hotmail.', 'protonmail.', + 'proton.me', ]; -export const VIDEO_DOMAINS = ['twitch.', 'youtube.']; +export const VIDEO_DOMAINS = ['youtube.', 'twitch.']; export const PAID_AD_PARAMS = [ - 'ad_id=', - 'aid=', - 'dclid=', - 'epik=', - 'fbclid=', + 'utm_source=google', 'gclid=', - 'li_fat_id=', + 'fbclid=', 'msclkid=', - 'ob_click_id=', - 'pc_id=', - 'rdt_cid=', - 'scid=', - 'ttclid=', + 'dclid=', 'twclid=', + 'li_fat_id=', + 'epik=', + 'ttclid=', + 'scid=', + 'aid=', + 'pc_id=', + 'ad_id=', + 'rdt_cid=', + 'ob_click_id=', 'utm_medium=cpc', 'utm_medium=paid', 'utm_medium=paid_social', - 'utm_source=google', ]; export const GROUPED_DOMAINS = [ - { name: 'Baidu', domain: 'baidu.com', match: 'baidu.' }, + { name: 'Google', domain: 'google.com', match: 'google.' }, + { name: 'Facebook', domain: 'facebook.com', match: 'facebook.' }, + { name: 'Reddit', domain: 'reddit.com', match: 'reddit.' }, + { name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' }, + { name: 'GitHub', domain: 'github.com', match: 'github.' }, + { name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' }, { name: 'Bing', domain: 'bing.com', match: 'bing.' }, { name: 'Brave', domain: 'brave.com', match: 'brave.' }, - { name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' }, { name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' }, - { name: 'Facebook', domain: 'facebook.com', match: 'facebook.' }, - { name: 'GitHub', domain: 'github.com', match: 'github.' }, - { name: 'Google', domain: 'google.com', match: 'google.' }, - { name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' }, - { name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] }, - { name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' }, - { name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' }, - { name: 'Reddit', domain: 'reddit.com', match: 'reddit.' }, - { name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' }, { name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] }, + { name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] }, + { name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' }, + { name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' }, + { name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' }, { name: 'Yahoo', domain: 'yahoo.com', match: 'yahoo.' }, { name: 'Yandex', domain: 'yandex.ru', match: 'yandex.' }, + { name: 'Baidu', domain: 'baidu.com', match: 'baidu.' }, ]; export const MAP_FILE = '/datamaps.world.json'; export const ISO_COUNTRIES = { - ANT: 'AN', - ARE: 'AE', - BLM: 'BL', - CHE: 'CH', - ESH: 'EH', - ESP: 'ES', - FSM: 'FM', - GBR: 'GB', + AFG: 'AF', + ALA: 'AX', + ALB: 'AL', + DZA: 'DZ', + ASM: 'AS', + AND: 'AD', + AGO: 'AO', + AIA: 'AI', + ATA: 'AQ', + ATG: 'AG', + ARG: 'AR', + ARM: 'AM', + ABW: 'AW', + AUS: 'AU', + AUT: 'AT', + AZE: 'AZ', + BHS: 'BS', + BHR: 'BH', + BGD: 'BD', + BRB: 'BB', + BLR: 'BY', + BEL: 'BE', + BLZ: 'BZ', + BEN: 'BJ', + BMU: 'BM', + BTN: 'BT', + BOL: 'BO', + BIH: 'BA', + BWA: 'BW', + BVT: 'BV', + BRA: 'BR', + VGB: 'VG', + IOT: 'IO', + BRN: 'BN', + BGR: 'BG', + BFA: 'BF', + BDI: 'BI', + KHM: 'KH', + CMR: 'CM', + CAN: 'CA', + CPV: 'CV', + CYM: 'KY', + CAF: 'CF', + TCD: 'TD', + CHL: 'CL', + CHN: 'CN', + HKG: 'HK', + MAC: 'MO', + CXR: 'CX', + CCK: 'CC', + COL: 'CO', + COM: 'KM', + COG: 'CG', + COD: 'CD', + COK: 'CK', + CRI: 'CR', + CIV: 'CI', + HRV: 'HR', + CUB: 'CU', + CYP: 'CY', + CZE: 'CZ', + DNK: 'DK', + DJI: 'DJ', + DMA: 'DM', + DOM: 'DO', + ECU: 'EC', + EGY: 'EG', + SLV: 'SV', + GNQ: 'GQ', + ERI: 'ER', + EST: 'EE', + ETH: 'ET', + FLK: 'FK', + FRO: 'FO', + FJI: 'FJ', + FIN: 'FI', + FRA: 'FR', + GUF: 'GF', + PYF: 'PF', + ATF: 'TF', + GAB: 'GA', + GMB: 'GM', + GEO: 'GE', + DEU: 'DE', + GHA: 'GH', + GIB: 'GI', + GRC: 'GR', + GRL: 'GL', + GRD: 'GD', + GLP: 'GP', + GUM: 'GU', + GTM: 'GT', + GGY: 'GG', + GIN: 'GN', + GNB: 'GW', + GUY: 'GY', + HTI: 'HT', + HMD: 'HM', + VAT: 'VA', + HND: 'HN', + HUN: 'HU', + ISL: 'IS', + IND: 'IN', + IDN: 'ID', + IRN: 'IR', + IRQ: 'IQ', + IRL: 'IE', + IMN: 'IM', + ISR: 'IL', + ITA: 'IT', JAM: 'JM', + JPN: 'JP', JEY: 'JE', JOR: 'JO', - JPN: 'JP', KAZ: 'KZ', KEN: 'KE', - KGZ: 'KG', KIR: 'KI', - KNA: 'KN', + PRK: 'KP', KOR: 'KR', KWT: 'KW', + KGZ: 'KG', LAO: 'LA', + LVA: 'LV', LBN: 'LB', + LSO: 'LS', LBR: 'LR', LBY: 'LY', - LCA: 'LC', LIE: 'LI', - LKA: 'LK', - LSO: 'LS', LTU: 'LT', LUX: 'LU', - LVA: 'LV', - MAF: 'MF', - MAR: 'MA', - MCO: 'MC', - MDA: 'MD', - MDG: 'MG', - MDV: 'MV', - MEX: 'MX', - MHL: 'MH', MKD: 'MK', - MLI: 'ML', - MLT: 'MT', - MMR: 'MM', - MNE: 'ME', - MNG: 'MN', - MNP: 'MP', - MOZ: 'MZ', - MRT: 'MR', - MSR: 'MS', - MTQ: 'MQ', - MUS: 'MU', + MDG: 'MG', MWI: 'MW', MYS: 'MY', + MDV: 'MV', + MLI: 'ML', + MLT: 'MT', + MHL: 'MH', + MTQ: 'MQ', + MRT: 'MR', + MUS: 'MU', MYT: 'YT', + MEX: 'MX', + FSM: 'FM', + MDA: 'MD', + MCO: 'MC', + MNG: 'MN', + MNE: 'ME', + MSR: 'MS', + MAR: 'MA', + MOZ: 'MZ', + MMR: 'MM', NAM: 'NA', - NCL: 'NC', - NER: 'NE', - NFK: 'NF', - NGA: 'NG', - NIC: 'NI', - NIU: 'NU', - NLD: 'NL', - NOR: 'NO', - NPL: 'NP', NRU: 'NR', + NPL: 'NP', + NLD: 'NL', + ANT: 'AN', + NCL: 'NC', NZL: 'NZ', + NIC: 'NI', + NER: 'NE', + NGA: 'NG', + NIU: 'NU', + NFK: 'NF', + MNP: 'MP', + NOR: 'NO', OMN: 'OM', PAK: 'PK', + PLW: 'PW', + PSE: 'PS', PAN: 'PA', - PCN: 'PN', + PNG: 'PG', + PRY: 'PY', PER: 'PE', PHL: 'PH', - PLW: 'PW', - PNG: 'PG', + PCN: 'PN', POL: 'PL', - PRI: 'PR', - PRK: 'KP', PRT: 'PT', - PRY: 'PY', - PSE: 'PS', + PRI: 'PR', QAT: 'QA', REU: 'RE', ROU: 'RO', RUS: 'RU', RWA: 'RW', - SAU: 'SA', - SDN: 'SD', - SEN: 'SN', - SGP: 'SG', - SGS: 'GS', + BLM: 'BL', SHN: 'SH', - SJM: 'SJ', - SLB: 'SB', - SLE: 'SL', - SMR: 'SM', - SOM: 'SO', + KNA: 'KN', + LCA: 'LC', + MAF: 'MF', SPM: 'PM', - SRB: 'RS', - SSD: 'SS', + VCT: 'VC', + WSM: 'WS', + SMR: 'SM', STP: 'ST', - SUR: 'SR', + SAU: 'SA', + SEN: 'SN', + SRB: 'RS', + SYC: 'SC', + SLE: 'SL', + SGP: 'SG', SVK: 'SK', SVN: 'SI', - SWE: 'SE', + SLB: 'SB', + SOM: 'SO', + ZAF: 'ZA', + SGS: 'GS', + SSD: 'SS', + ESP: 'ES', + LKA: 'LK', + SDN: 'SD', + SUR: 'SR', + SJM: 'SJ', SWZ: 'SZ', - SYC: 'SC', + SWE: 'SE', + CHE: 'CH', SYR: 'SY', - TCA: 'TC', - TGO: 'TG', - THA: 'TH', + TWN: 'TW', TJK: 'TJ', - TKL: 'TK', - TKM: 'TM', + TZA: 'TZ', + THA: 'TH', TLS: 'TL', + TGO: 'TG', + TKL: 'TK', TON: 'TO', TTO: 'TT', TUN: 'TN', TUR: 'TR', + TKM: 'TM', + TCA: 'TC', TUV: 'TV', - TWN: 'TW', - TZA: 'TZ', UGA: 'UG', UKR: 'UA', + ARE: 'AE', + GBR: 'GB', + USA: 'US', UMI: 'UM', URY: 'UY', - USA: 'US', UZB: 'UZ', - VCT: 'VC', - VEN: 'VE', - VIR: 'VI', - VNM: 'VN', VUT: 'VU', + VEN: 'VE', + VNM: 'VN', + VIR: 'VI', WLF: 'WF', - WSM: 'WS', - XKX: 'XK', + ESH: 'EH', YEM: 'YE', - ZAF: 'ZA', ZMB: 'ZM', ZWE: 'ZW', + XKX: 'XK', }; export const CURRENCIES = [ diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 2a8b5c8de..e15e5b16e 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -3,96 +3,18 @@ import { browserName, detectOS } from 'detect-browser'; import isLocalhost from 'is-localhost-ip'; import ipaddr from 'ipaddr.js'; import maxmind from 'maxmind'; +import { + DESKTOP_OS, + DESKTOP_SCREEN_WIDTH, + IP_ADDRESS_HEADERS, + LAPTOP_SCREEN_WIDTH, + MOBILE_OS, + MOBILE_SCREEN_WIDTH, +} from './constants'; import { safeDecodeURIComponent } from '@/lib/url'; const MAXMIND = 'maxmind'; -export const DESKTOP_OS = [ - 'BeOS', - 'Chrome OS', - 'Linux', - 'Mac OS', - 'Open BSD', - 'OS/2', - 'QNX', - 'Sun OS', - 'Windows 10', - 'Windows 2000', - 'Windows 3.11', - 'Windows 7', - 'Windows 8', - 'Windows 8.1', - 'Windows 95', - 'Windows 98', - 'Windows ME', - 'Windows Server 2003', - 'Windows Vista', - 'Windows XP', -]; - -export const MOBILE_OS = ['Amazon OS', 'Android OS', 'BlackBerry OS', 'iOS', 'Windows Mobile']; - -export const DESKTOP_SCREEN_WIDTH = 1920; -export const LAPTOP_SCREEN_WIDTH = 1024; -export const MOBILE_SCREEN_WIDTH = 479; - -// The order here is important and influences how IPs are detected by lib/detect.ts -// Please do not change the order unless you know exactly what you're doing - read https://developers.cloudflare.com/fundamentals/reference/http-headers/ -export const IP_ADDRESS_HEADERS = [ - 'x-client-ip', - 'x-forwarded-for', - 'cf-connecting-ip', // This should be *after* x-forwarded-for, so that x-forwarded-for is respected if present - 'do-connecting-ip', - 'fastly-client-ip', - 'true-client-ip', - 'x-real-ip', - 'x-cluster-client-ip', - 'x-forwarded', - 'forwarded', - 'x-appengine-user-ip', - 'x-nf-client-connection-ip', - 'x-real-ip', -]; - -const PROVIDER_HEADERS = [ - // Cloudflare headers - { - countryHeader: 'cf-ipcountry', - regionHeader: 'cf-region-code', - cityHeader: 'cf-ipcity', - }, - // Vercel headers - { - countryHeader: 'x-vercel-ip-country', - regionHeader: 'x-vercel-ip-country-region', - cityHeader: 'x-vercel-ip-city', - }, - // CloudFront headers - { - countryHeader: 'cloudfront-viewer-country', - regionHeader: 'cloudfront-viewer-country-region', - cityHeader: 'cloudfront-viewer-city', - }, -]; - -function stripPort(ip) { - if (ip.startsWith('[')) { - const endBracket = ip.indexOf(']'); - if (endBracket !== -1) { - return ip.slice(0, endBracket + 1); - } - } - - const idx = ip.lastIndexOf(':'); - if (idx !== -1) { - if (ip.includes('.') || /^[a-zA-Z0-9.-]+$/.test(ip.slice(0, idx))) { - return ip.slice(0, idx); - } - } - - return ip; -} - export function getIpAddress(headers: Headers) { const customHeader = process.env.CLIENT_IP_HEADER; @@ -172,19 +94,30 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI } if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) { - for (const provider of PROVIDER_HEADERS) { - const countryHeader = headers.get(provider.countryHeader); - if (countryHeader) { - const country = decodeHeader(countryHeader); - const region = decodeHeader(headers.get(provider.regionHeader)); - const city = decodeHeader(headers.get(provider.cityHeader)); + // Cloudflare headers + if (headers.get('cf-ipcountry')) { + const country = decodeHeader(headers.get('cf-ipcountry')); + const region = decodeHeader(headers.get('cf-region-code')); + const city = decodeHeader(headers.get('cf-ipcity')); - return { - country, - region: getRegionCode(country, region), - city, - }; - } + return { + country, + region: getRegionCode(country, region), + city, + }; + } + + // Vercel headers + if (headers.get('x-vercel-ip-country')) { + const country = decodeHeader(headers.get('x-vercel-ip-country')); + const region = decodeHeader(headers.get('x-vercel-ip-country-region')); + const city = decodeHeader(headers.get('x-vercel-ip-city')); + + return { + country, + region: getRegionCode(country, region), + city, + }; } } @@ -197,7 +130,7 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI ); } - const result = globalThis[MAXMIND]?.get(stripPort(ip)); + const result = globalThis[MAXMIND]?.get(ip?.split(':')[0]); if (result) { const country = result.country?.iso_code ?? result?.registered_country?.iso_code; diff --git a/src/queries/sql/reports/getRevenue.ts b/src/queries/sql/reports/getRevenue.ts index 1a0e03321..d73d90c23 100644 --- a/src/queries/sql/reports/getRevenue.ts +++ b/src/queries/sql/reports/getRevenue.ts @@ -201,23 +201,22 @@ async function clickhouseQuery( }[] >( ` - select - website_event.country as name, - sum(website_revenue.revenue) as value - from website_revenue - join website_event - on website_event.website_id = website_revenue.website_id - and website_event.session_id = website_revenue.session_id - and website_event.event_id = website_revenue.event_id - and website_event.website_id = {websiteId:UUID} - and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} - ${cohortQuery} - where website_revenue.website_id = {websiteId:UUID} - and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} - and website_revenue.currency = {currency:String} - ${filterQuery} - group by website_event.country - order by value desc + select + website_event.country as name, + sum(website_revenue.revenue) as value + from website_revenue + join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.currency = {currency:String} + ${filterQuery} + group by website_event.country `, queryParams, );