feat: website/send api is completed

This commit is contained in:
moonrailgun 2023-09-03 19:28:53 +08:00
parent 421ee8edc1
commit 08ef1fea91
16 changed files with 968 additions and 19 deletions

2
.gitignore vendored
View File

@ -1,4 +1,6 @@
.env .env
public/tracker.js
geo
# Logs # Logs
logs logs

View File

@ -9,5 +9,11 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script> <script type="module" src="/src/client/main.tsx"></script>
<script>
const el = document.createElement('script');
el.src = location.origin + '/tracker.js';
el.setAttribute('data-website-id', '53b9a4c1-4a4e-4ec9-8d42-2a2941825bd2'); // For test
document.head.append(el);
</script>
</body> </body>
</html> </html>

View File

@ -5,8 +5,9 @@
"scripts": { "scripts": {
"dev": "nodemon src/server/main.ts -w src/server", "dev": "nodemon src/server/main.ts -w src/server",
"start": "NODE_ENV=production ts-node src/server/main.ts", "start": "NODE_ENV=production ts-node src/server/main.ts",
"build": "vite build && pnpm build:tracker", "build": "vite build && pnpm build:tracker && pnpm build:geo",
"build:tracker": "ts-node scripts/build-tracker.ts", "build:tracker": "ts-node scripts/build-tracker.ts",
"build:geo": "ts-node scripts/build-geo.ts",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:studio": "prisma studio" "db:studio": "prisma studio"
@ -15,18 +16,23 @@
"@ant-design/charts": "^1.4.2", "@ant-design/charts": "^1.4.2",
"@ant-design/icons": "^5.2.5", "@ant-design/icons": "^5.2.5",
"@prisma/client": "^5.2.0", "@prisma/client": "^5.2.0",
"@types/uuid": "^9.0.3",
"antd": "^5.8.5", "antd": "^5.8.5",
"axios": "^1.5.0", "axios": "^1.5.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"compose-middleware": "^5.0.1", "compose-middleware": "^5.0.1",
"compression": "^1.7.4", "compression": "^1.7.4",
"dayjs": "^1.11.9",
"detect-browser": "^5.3.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"is-localhost-ip": "^2.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"maxmind": "^4.3.11",
"nanoid": "^3.3.6", "nanoid": "^3.3.6",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@ -34,11 +40,14 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router": "^6.15.0", "react-router": "^6.15.0",
"react-router-dom": "^6.15.0", "react-router-dom": "^6.15.0",
"request-ip": "^3.3.0",
"socket.io": "^4.7.2", "socket.io": "^4.7.2",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite-express": "^0.10.0" "uuid": "^9.0.0",
"vite-express": "^0.10.0",
"yup": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.3", "@types/bcryptjs": "^2.4.3",
@ -51,12 +60,15 @@
"@types/passport-jwt": "^3.0.9", "@types/passport-jwt": "^3.0.9",
"@types/react": "^18.2.21", "@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/request-ip": "^0.0.38",
"@types/tar": "^6.1.5",
"@vitejs/plugin-react": "^4.0.4", "@vitejs/plugin-react": "^4.0.4",
"autoprefixer": "^10.4.15", "autoprefixer": "^10.4.15",
"nodemon": "^2.0.22", "nodemon": "^2.0.22",
"postcss": "^8.4.29", "postcss": "^8.4.29",
"prisma": "^5.2.0", "prisma": "^5.2.0",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tar": "^6.1.15",
"vite": "^4.4.9" "vite": "^4.4.9"
} }
} }

View File

@ -10,6 +10,9 @@ dependencies:
'@prisma/client': '@prisma/client':
specifier: ^5.2.0 specifier: ^5.2.0
version: 5.2.0(prisma@5.2.0) version: 5.2.0(prisma@5.2.0)
'@types/uuid':
specifier: ^9.0.3
version: 9.0.3
antd: antd:
specifier: ^5.8.5 specifier: ^5.8.5
version: 5.8.5(react-dom@18.2.0)(react@18.2.0) version: 5.8.5(react-dom@18.2.0)(react@18.2.0)
@ -28,6 +31,12 @@ dependencies:
compression: compression:
specifier: ^1.7.4 specifier: ^1.7.4
version: 1.7.4 version: 1.7.4
dayjs:
specifier: ^1.11.9
version: 1.11.9
detect-browser:
specifier: ^5.3.0
version: 5.3.0
dotenv: dotenv:
specifier: ^16.3.1 specifier: ^16.3.1
version: 16.3.1 version: 16.3.1
@ -40,12 +49,18 @@ dependencies:
express-validator: express-validator:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1 version: 7.0.1
is-localhost-ip:
specifier: ^2.0.0
version: 2.0.0
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.2 version: 9.0.2
lodash-es: lodash-es:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
maxmind:
specifier: ^4.3.11
version: 4.3.11
nanoid: nanoid:
specifier: ^3.3.6 specifier: ^3.3.6
version: 3.3.6 version: 3.3.6
@ -67,6 +82,9 @@ dependencies:
react-router-dom: react-router-dom:
specifier: ^6.15.0 specifier: ^6.15.0
version: 6.15.0(react-dom@18.2.0)(react@18.2.0) version: 6.15.0(react-dom@18.2.0)(react@18.2.0)
request-ip:
specifier: ^3.3.0
version: 3.3.0
socket.io: socket.io:
specifier: ^4.7.2 specifier: ^4.7.2
version: 4.7.2 version: 4.7.2
@ -79,9 +97,15 @@ dependencies:
typescript: typescript:
specifier: ^4.9.5 specifier: ^4.9.5
version: 4.9.5 version: 4.9.5
uuid:
specifier: ^9.0.0
version: 9.0.0
vite-express: vite-express:
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.0 version: 0.10.0
yup:
specifier: ^1.2.0
version: 1.2.0
devDependencies: devDependencies:
'@types/bcryptjs': '@types/bcryptjs':
@ -114,6 +138,12 @@ devDependencies:
'@types/react-dom': '@types/react-dom':
specifier: ^18.2.7 specifier: ^18.2.7
version: 18.2.7 version: 18.2.7
'@types/request-ip':
specifier: ^0.0.38
version: 0.0.38
'@types/tar':
specifier: ^6.1.5
version: 6.1.5
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.0.4 specifier: ^4.0.4
version: 4.0.4(vite@4.4.9) version: 4.0.4(vite@4.4.9)
@ -132,6 +162,9 @@ devDependencies:
tailwindcss: tailwindcss:
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.3.3(ts-node@10.9.1) version: 3.3.3(ts-node@10.9.1)
tar:
specifier: ^6.1.15
version: 6.1.15
vite: vite:
specifier: ^4.4.9 specifier: ^4.4.9
version: 4.4.9(@types/node@18.17.12) version: 4.4.9(@types/node@18.17.12)
@ -1951,6 +1984,12 @@ packages:
csstype: 3.1.2 csstype: 3.1.2
dev: true dev: true
/@types/request-ip@0.0.38:
resolution: {integrity: sha512-1yeq8UuK/tUBqLXRY24gjeFvrSNaGNcOcZLQjHlnuw8iu+qE/vTQ64TUcLWorr607NKLfFakdoYEXXHXrLLKCw==}
dependencies:
'@types/node': 18.17.12
dev: true
/@types/scheduler@0.16.3: /@types/scheduler@0.16.3:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
dev: true dev: true
@ -1970,6 +2009,17 @@ packages:
'@types/node': 18.17.12 '@types/node': 18.17.12
dev: true dev: true
/@types/tar@6.1.5:
resolution: {integrity: sha512-qm2I/RlZij5RofuY7vohTpYNaYcrSQlN2MyjucQc7ZweDwaEWkdN/EeNh6e9zjK6uEm6PwjdMXkcj05BxZdX1Q==}
dependencies:
'@types/node': 18.17.12
minipass: 4.2.8
dev: true
/@types/uuid@9.0.3:
resolution: {integrity: sha512-taHQQH/3ZyI3zP8M/puluDEIEvtQHVYcC6y3N8ijFtAd28+Ey/G4sg1u2gB01S8MwybLOKAp9/yCMu/uR5l3Ug==}
dev: false
/@vitejs/plugin-react@4.0.4(vite@4.4.9): /@vitejs/plugin-react@4.0.4(vite@4.4.9):
resolution: {integrity: sha512-7wU921ABnNYkETiMaZy7XqpueMnpu5VxvVps13MjmCo+utBdD79sZzrApHawHtVX66cCJQQTXFcjH0y9dSUK8g==} resolution: {integrity: sha512-7wU921ABnNYkETiMaZy7XqpueMnpu5VxvVps13MjmCo+utBdD79sZzrApHawHtVX66cCJQQTXFcjH0y9dSUK8g==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@ -2368,6 +2418,11 @@ packages:
fsevents: 2.3.3 fsevents: 2.3.3
dev: true dev: true
/chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
dev: true
/clamp@1.0.1: /clamp@1.0.1:
resolution: {integrity: sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==} resolution: {integrity: sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==}
dev: false dev: false
@ -3147,6 +3202,13 @@ packages:
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
dev: false dev: false
/fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
dependencies:
minipass: 3.3.6
dev: true
/fs.realpath@1.0.0: /fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -3482,6 +3544,11 @@ packages:
is-extglob: 2.1.1 is-extglob: 2.1.1
dev: true dev: true
/is-localhost-ip@2.0.0:
resolution: {integrity: sha512-vlgs2cSgMOfnKU8c1ewgKPyum9rVrjjLLW2HBdL5i0iAJjOs8NY55ZBd/hqUTaYR0EO9CKZd3hVSC2HlIbygTQ==}
engines: {node: '>=12'}
dev: false
/is-negative-zero@2.0.2: /is-negative-zero@2.0.2:
resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3784,6 +3851,14 @@ packages:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
dev: false dev: false
/maxmind@4.3.11:
resolution: {integrity: sha512-tJDrKbUzN6PSA88tWgg0L2R4Ln00XwecYQJPFI+RvlF2k1sx6VQYtuQ1SVxm8+bw5tF7GWV4xyb+3/KyzEpPUw==}
engines: {node: '>=12', npm: '>=6'}
dependencies:
mmdb-lib: 2.0.2
tiny-lru: 11.0.1
dev: false
/mdn-data@2.0.14: /mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
dev: false dev: false
@ -3842,6 +3917,37 @@ packages:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
dev: false dev: false
/minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
dependencies:
yallist: 4.0.0
dev: true
/minipass@4.2.8:
resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==}
engines: {node: '>=8'}
dev: true
/minipass@5.0.0:
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
engines: {node: '>=8'}
dev: true
/minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
dependencies:
minipass: 3.3.6
yallist: 4.0.0
dev: true
/mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
dev: true
/ml-array-max@1.2.4: /ml-array-max@1.2.4:
resolution: {integrity: sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==} resolution: {integrity: sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==}
dependencies: dependencies:
@ -3869,6 +3975,11 @@ packages:
ml-array-rescale: 1.3.7 ml-array-rescale: 1.3.7
dev: false dev: false
/mmdb-lib@2.0.2:
resolution: {integrity: sha512-shi1I+fCPQonhTi7qyb6hr7hi87R7YS69FlfJiMFuJ12+grx0JyL56gLNzGTYXPU7EhAPkMLliGeyHer0K+AVA==}
engines: {node: '>=10', npm: '>=6'}
dev: false
/moment@2.29.4: /moment@2.29.4:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
dev: false dev: false
@ -4207,6 +4318,10 @@ packages:
react-is: 16.13.1 react-is: 16.13.1
dev: false dev: false
/property-expr@2.0.5:
resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==}
dev: false
/protocol-buffers-schema@3.6.0: /protocol-buffers-schema@3.6.0:
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
dev: false dev: false
@ -5017,6 +5132,10 @@ packages:
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
dev: false dev: false
/request-ip@3.3.0:
resolution: {integrity: sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==}
dev: false
/resize-observer-polyfill@1.5.1: /resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
dev: false dev: false
@ -5484,6 +5603,18 @@ packages:
through: 2.3.8 through: 2.3.8
dev: false dev: false
/tar@6.1.15:
resolution: {integrity: sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==}
engines: {node: '>=10'}
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 5.0.0
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
dev: true
/thenify-all@1.6.0: /thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -5511,6 +5642,15 @@ packages:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: false dev: false
/tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
dev: false
/tiny-lru@11.0.1:
resolution: {integrity: sha512-iNgFugVuQgBKrqeO/mpiTTgmBsTP0WL6yeuLfLs/Ctf0pI/ixGqIRm8sDCwMcXGe9WWvt2sGXI5mNqZbValmJg==}
engines: {node: '>=12'}
dev: false
/tinycolor2@1.6.0: /tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
dev: false dev: false
@ -5604,6 +5744,11 @@ packages:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: false dev: false
/type-fest@2.19.0:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
dev: false
/type-is@1.6.18: /type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -5725,6 +5870,11 @@ packages:
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
dev: false dev: false
/uuid@9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
hasBin: true
dev: false
/v8-compile-cache-lib@3.0.1: /v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@ -5857,7 +6007,6 @@ packages:
/yallist@4.0.0: /yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
dev: false
/yaml@2.3.2: /yaml@2.3.2:
resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==} resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==}
@ -5876,3 +6025,12 @@ packages:
/yn@3.1.1: /yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
/yup@1.2.0:
resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==}
dependencies:
property-expr: 2.0.5
tiny-case: 1.0.3
toposort: 2.0.2
type-fest: 2.19.0
dev: false

View File

@ -8,16 +8,16 @@ generator client {
} }
model User { model User {
id String @id @unique @default(uuid()) @db.Uuid id String @id @unique @default(uuid()) @db.Uuid
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
role String @db.VarChar(50) role String @db.VarChar(50)
createdAt DateTime? @default(now()) @db.Timestamptz(6) createdAt DateTime? @default(now()) @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @db.Timestamptz(6) updatedAt DateTime? @updatedAt @db.Timestamptz(6)
deletedAt DateTime? @db.Timestamptz(6) deletedAt DateTime? @db.Timestamptz(6)
currentWorkspaceId String? @db.Uuid currentWorkspaceId String? @db.Uuid
currentWorkspace Workspace? @relation(fields: [currentWorkspaceId], references: [id]) currentWorkspace Workspace? @relation(fields: [currentWorkspaceId], references: [id])
workspaces WorkspacesOnUsers[] workspaces WorkspacesOnUsers[]
} }
@ -101,7 +101,7 @@ model WebsiteSession {
} }
model WebsiteEvent { model WebsiteEvent {
id String @id() @db.Uuid id String @id() @default(uuid()) @db.Uuid
websiteId String @db.Uuid websiteId String @db.Uuid
sessionId String @db.Uuid sessionId String @db.Uuid
createdAt DateTime? @default(now()) @db.Timestamptz(6) createdAt DateTime? @default(now()) @db.Timestamptz(6)
@ -130,7 +130,7 @@ model WebsiteEvent {
} }
model WebsiteEventData { model WebsiteEventData {
id String @id() @db.Uuid id String @id() @default(uuid()) @db.Uuid
websiteId String @db.Uuid websiteId String @db.Uuid
websiteEventId String @db.Uuid websiteEventId String @db.Uuid
eventKey String @db.VarChar(500) eventKey String @db.VarChar(500)
@ -151,10 +151,9 @@ model WebsiteEventData {
} }
model WebsiteSessionData { model WebsiteSessionData {
id String @id() @db.Uuid id String @id() @default(uuid()) @db.Uuid
websiteId String @db.Uuid websiteId String @db.Uuid
sessionId String @db.Uuid sessionId String @db.Uuid
sessionKey String @db.VarChar(500)
stringValue String? @db.VarChar(500) stringValue String? @db.VarChar(500)
numberValue Decimal? @db.Decimal(19, 4) numberValue Decimal? @db.Decimal(19, 4)
dateValue DateTime? @db.Timestamptz(6) dateValue DateTime? @db.Timestamptz(6)

55
scripts/build-geo.ts Normal file
View File

@ -0,0 +1,55 @@
require('dotenv').config();
import fs from 'fs';
import path from 'path';
import https from 'https';
import zlib from 'zlib';
import tar from 'tar';
if (process.env.VERCEL) {
console.log('Vercel environment detected. Skipping geo setup.');
process.exit(0);
}
const db = 'GeoLite2-City';
let url = `https://raw.githubusercontent.com/GitSquared/node-geolite2-redist/master/redist/${db}.tar.gz`;
if (process.env.MAXMIND_LICENSE_KEY) {
url =
`https://download.maxmind.com/app/geoip_download` +
`?edition_id=${db}&license_key=${process.env.MAXMIND_LICENSE_KEY}&suffix=tar.gz`;
}
const dest = path.resolve(__dirname, '../geo');
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest);
}
const download = (url: string): Promise<NodeJS.WritableStream> =>
new Promise((resolve) => {
https.get(url, (res) => {
resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t()));
});
});
download(url).then(
(res) =>
new Promise<void>((resolve, reject) => {
res.on('entry', (entry) => {
if (entry.path.endsWith('.mmdb')) {
const filename = path.join(dest, path.basename(entry.path));
entry.pipe(fs.createWriteStream(filename));
console.log('Saved geo database:', filename);
}
});
res.on('error', (e) => {
reject(e);
});
res.on('finish', () => {
resolve();
});
})
);

View File

@ -13,6 +13,7 @@ vite
formats: ['iife'], formats: ['iife'],
}, },
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, '../public'),
}, },
}) })
.then((res) => { .then((res) => {

View File

@ -9,8 +9,9 @@ interface HealthBarProps {
export const HealthBar: React.FC<HealthBarProps> = React.memo((props) => { export const HealthBar: React.FC<HealthBarProps> = React.memo((props) => {
return ( return (
<div className="flex"> <div className="flex">
{props.beats.map((beat) => ( {props.beats.map((beat, i) => (
<div <div
key={i}
title={beat.title} title={beat.title}
className={clsx( className={clsx(
'rounded-full w-1 h-4 m-0.5 hover:scale-150 transition-transform', 'rounded-full w-1 h-4 m-0.5 hover:scale-150 transition-transform',

View File

@ -3,8 +3,9 @@ import express from 'express';
import 'express-async-errors'; import 'express-async-errors';
import ViteExpress from 'vite-express'; import ViteExpress from 'vite-express';
import compression from 'compression'; import compression from 'compression';
import { userRouter } from './router/user';
import passport from 'passport'; import passport from 'passport';
import { userRouter } from './router/user';
import { websiteRouter } from './router/website';
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
@ -18,6 +19,7 @@ app.use(passport.initialize());
app.disable('x-powered-by'); app.disable('x-powered-by');
app.use('/api/user', userRouter); app.use('/api/user', userRouter);
app.use('/api/website', websiteRouter);
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
res.status(500); res.status(500);

246
src/server/model/website.ts Normal file
View File

@ -0,0 +1,246 @@
import { Website, WebsiteSession } from '@prisma/client';
import { flattenJSON, hashUuid, isUuid } from '../utils/common';
import { prisma } from './_client';
import { Request } from 'express';
import { getClientInfo } from '../utils/detect';
import {
DATA_TYPE,
EVENT_NAME_LENGTH,
EVENT_TYPE,
URL_LENGTH,
} from '../utils/const';
import type { DynamicData } from '../utils/types';
export interface WebsiteEventPayload {
data?: object;
hostname: string;
language?: string;
referrer?: string;
screen?: string;
title?: string;
url?: string;
website: string;
name?: string;
}
export async function findSession(req: Request): Promise<{
id: any;
websiteId: string;
hostname: string;
browser: string;
os: any;
device: string;
screen: string;
language: string;
country: any;
subdivision1: any;
subdivision2: any;
city: any;
workspaceId: string;
}> {
// Verify payload
const { payload } = req.body;
const {
website: websiteId,
hostname,
screen,
language,
} = payload as WebsiteEventPayload;
// Check the hostname value for legality to eliminate dirty data
const validHostnameRegex = /^[\w-.]+$/;
if (typeof hostname === 'string' && !validHostnameRegex.test(hostname)) {
throw new Error('Invalid hostname.');
}
if (!isUuid(websiteId)) {
throw new Error('Invalid website ID.');
}
// Find website
const website = await loadWebsite(websiteId);
if (!website) {
throw new Error(`Website not found: ${websiteId}.`);
}
const {
userAgent,
browser,
os,
ip,
country,
subdivision1,
subdivision2,
city,
device,
} = await getClientInfo(req, payload);
const sessionId = hashUuid(websiteId, hostname!, ip, userAgent!);
// Find session
let session = await loadSession(sessionId);
// Create a session if not found
if (!session) {
try {
session = await prisma.websiteSession.create({
data: {
id: sessionId,
websiteId,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
subdivision2,
city,
},
});
} catch (e: any) {
if (!e.message.toLowerCase().includes('unique constraint')) {
throw e;
}
}
}
const res: any = { ...session!, workspaceId: website.workspaceId };
return res;
}
async function loadWebsite(websiteId: string): Promise<Website | null> {
const website = await prisma.website.findUnique({
where: {
id: websiteId,
},
});
if (!website || website.deletedAt) {
return null;
}
return website;
}
async function loadSession(sessionId: string): Promise<WebsiteSession | null> {
const session = await prisma.websiteSession.findUnique({
where: {
id: sessionId,
},
});
if (!session) {
return null;
}
return session;
}
export async function saveWebsiteEvent(data: {
sessionId: string;
websiteId: string;
urlPath: string;
urlQuery?: string;
referrerPath?: string;
referrerQuery?: string;
referrerDomain?: string;
pageTitle?: string;
eventName?: string;
eventData?: any;
}) {
const {
websiteId,
sessionId,
urlPath,
urlQuery,
referrerPath,
referrerQuery,
referrerDomain,
eventName,
eventData,
pageTitle,
} = data;
const websiteEvent = await prisma.websiteEvent.create({
data: {
websiteId,
sessionId,
urlPath: urlPath?.substring(0, URL_LENGTH),
urlQuery: urlQuery?.substring(0, URL_LENGTH),
referrerPath: referrerPath?.substring(0, URL_LENGTH),
referrerQuery: referrerQuery?.substring(0, URL_LENGTH),
referrerDomain: referrerDomain?.substring(0, URL_LENGTH),
pageTitle,
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
},
});
if (eventData) {
const jsonKeys = flattenJSON(eventData);
// id, websiteEventId, eventStringValue
const flattendData = jsonKeys.map((a) => ({
websiteEventId: websiteEvent.id,
websiteId,
eventKey: a.key,
stringValue:
a.dynamicDataType === DATA_TYPE.number
? parseFloat(a.value).toFixed(4)
: a.dynamicDataType === DATA_TYPE.date
? a.value.split('.')[0] + 'Z'
: a.value.toString(),
numberValue: a.dynamicDataType === DATA_TYPE.number ? a.value : null,
dateValue:
a.dynamicDataType === DATA_TYPE.date ? new Date(a.value) : null,
dataType: a.dynamicDataType,
}));
await prisma.websiteEventData.createMany({
data: flattendData,
});
}
return websiteEvent;
}
export async function saveWebsiteSessionData(data: {
websiteId: string;
sessionId: string;
sessionData: DynamicData;
}) {
const { websiteId, sessionId, sessionData } = data;
const jsonKeys = flattenJSON(sessionData);
const flattendData = jsonKeys.map((a) => ({
websiteId,
sessionId,
key: a.key,
stringValue:
a.dynamicDataType === DATA_TYPE.number
? parseFloat(a.value).toFixed(4)
: a.dynamicDataType === DATA_TYPE.date
? a.value.split('.')[0] + 'Z'
: a.value.toString(),
numberValue: a.dynamicDataType === DATA_TYPE.number ? a.value : null,
dateValue: a.dynamicDataType === DATA_TYPE.date ? new Date(a.value) : null,
dataType: a.dynamicDataType,
}));
return prisma.$transaction([
prisma.websiteSessionData.deleteMany({
where: {
sessionId,
},
}),
prisma.websiteSessionData.createMany({
data: flattendData,
}),
]);
}

View File

@ -0,0 +1,106 @@
import { Router } from 'express';
import { body, validate } from '../middleware/validate';
import * as yup from 'yup';
import { COLLECTION_TYPE, HOSTNAME_REGEX } from '../utils/const';
import {
findSession,
saveWebsiteEvent,
saveWebsiteSessionData,
} from '../model/website';
export const websiteRouter = Router();
websiteRouter.post(
'/send',
validate(
body('payload')
.exists()
.withMessage('payload should be existed')
.isObject()
.custom(async (input) => {
return yup
.object()
.shape({
data: yup.object(),
hostname: yup.string().matches(HOSTNAME_REGEX).max(100),
language: yup.string().max(35),
referrer: yup.string().max(500),
screen: yup.string().max(11),
title: yup.string().max(500),
url: yup.string().max(500),
website: yup.string().uuid().required(),
name: yup.string().max(50),
})
.required()
.validate(input)
.catch((err) => {});
}),
body('type')
.exists()
.withMessage('type should be existed')
.isString()
.matches(/event|identify/i)
),
async (req, res) => {
// https://github1s.com/umami-software/umami/blob/master/src/pages/api/send.ts
const { type, payload } = req.body;
const {
url,
referrer,
name: eventName,
data: eventData,
title: pageTitle,
} = payload;
const session = await findSession(req);
if (type === COLLECTION_TYPE.event) {
let [urlPath, urlQuery] = url?.split('?') || [];
let [referrerPath, referrerQuery] = referrer?.split('?') || [];
let referrerDomain;
if (!urlPath) {
urlPath = '/';
}
if (referrerPath?.startsWith('http')) {
const refUrl = new URL(referrer);
referrerPath = refUrl.pathname;
referrerQuery = refUrl.search.substring(1);
referrerDomain = refUrl.hostname.replace(/www\./, '');
}
if (process.env.REMOVE_TRAILING_SLASH) {
urlPath = urlPath.replace(/.+\/$/, '');
}
await saveWebsiteEvent({
urlPath,
urlQuery,
referrerPath,
referrerQuery,
referrerDomain,
pageTitle,
eventName,
eventData,
...session,
sessionId: session.id,
});
}
if (type === COLLECTION_TYPE.identify) {
if (!eventData) {
throw new Error('Data required');
}
await saveWebsiteSessionData({
...session,
sessionData: eventData,
sessionId: session.id,
});
}
res.send();
}
);

131
src/server/utils/common.ts Normal file
View File

@ -0,0 +1,131 @@
import { v4, v5, validate } from 'uuid';
import crypto from 'crypto';
import { DATA_TYPE } from './const';
import { DynamicDataType } from './types';
import dayjs from 'dayjs';
export function isUuid(value: string) {
return validate(value);
}
export function safeDecodeURIComponent(
s: string | string[] | undefined | null
): string | undefined | null {
if (s === undefined || s === null) {
return s;
}
if (Array.isArray(s)) {
s = String(s);
}
try {
return decodeURIComponent(s);
} catch (e) {
return s;
}
}
function hash(...args: string[]) {
return crypto.createHash('sha512').update(args.join('')).digest('hex');
}
export function hashUuid(...args: string[]) {
if (!args.length) {
return v4();
}
return v5(hash(...args), v5.DNS);
}
export function isValidDate(input: any) {
return dayjs(input).isValid();
}
export function flattenJSON(
eventData: { [key: string]: any },
keyValues: {
key: string;
value: any;
dynamicDataType: DynamicDataType;
}[] = [],
parentKey = ''
): { key: string; value: any; dynamicDataType: DynamicDataType }[] {
return Object.keys(eventData).reduce(
(acc, key) => {
const value = eventData[key];
const type = typeof eventData[key];
// nested object
if (
value &&
type === 'object' &&
!Array.isArray(value) &&
!isValidDate(value)
) {
flattenJSON(value, acc.keyValues, getKeyName(key, parentKey));
} else {
createKey(getKeyName(key, parentKey), value, acc);
}
return acc;
},
{ keyValues, parentKey }
).keyValues;
}
function getKeyName(key: string, parentKey?: string) {
if (!parentKey) {
return key;
}
return `${parentKey}.${key}`;
}
function createKey(
key: string,
value: any,
acc: { keyValues: any[]; parentKey: string }
) {
const type = getDataType(value);
let dynamicDataType = null;
switch (type) {
case 'number':
dynamicDataType = DATA_TYPE.number;
break;
case 'string':
dynamicDataType = DATA_TYPE.string;
break;
case 'boolean':
dynamicDataType = DATA_TYPE.boolean;
value = value ? 'true' : 'false';
break;
case 'date':
dynamicDataType = DATA_TYPE.date;
break;
case 'object':
dynamicDataType = DATA_TYPE.array;
value = JSON.stringify(value);
break;
default:
dynamicDataType = DATA_TYPE.string;
break;
}
acc.keyValues.push({ key, value, dynamicDataType });
}
function getDataType(value: any): string {
let type: string = typeof value;
if (
(type === 'string' && isValidDate(value)) ||
isValidDate(dayjs(value).toISOString())
) {
type = 'date';
}
return type;
}

View File

@ -7,3 +7,62 @@ export const ROLES = {
owner: 'owner', owner: 'owner',
readOnly: 'readOnly', readOnly: 'readOnly',
} as const; } as const;
export const HOSTNAME_REGEX =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/;
export const COLLECTION_TYPE = {
event: 'event',
identify: 'identify',
};
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;
export const URL_LENGTH = 500;
export const EVENT_NAME_LENGTH = 50;
export const EVENT_TYPE = {
pageView: 1,
customEvent: 2,
} as const;
export const DATA_TYPE = {
string: 1,
number: 2,
boolean: 3,
date: 4,
array: 5,
} as const;

157
src/server/utils/detect.ts Normal file
View File

@ -0,0 +1,157 @@
import { Request } from 'express';
import type { WebsiteEventPayload } from '../model/website';
import { getClientIp } from 'request-ip';
import isLocalhost from 'is-localhost-ip';
import { safeDecodeURIComponent } from './common';
import { browserName, detectOS, OperatingSystem } from 'detect-browser';
import {
DESKTOP_OS,
DESKTOP_SCREEN_WIDTH,
LAPTOP_SCREEN_WIDTH,
MOBILE_OS,
MOBILE_SCREEN_WIDTH,
} from './const';
import path from 'path';
import maxmind, { Reader, CityResponse } from 'maxmind';
let lookup: Reader<CityResponse>;
export async function getClientInfo(
req: Request,
payload: WebsiteEventPayload
) {
const userAgent = req.headers['user-agent'];
const ip = getIpAddress(req);
const location = await getLocation(ip, req);
const country = location?.country;
const subdivision1 = location?.subdivision1;
const subdivision2 = location?.subdivision2;
const city = location?.city;
const browser = browserName(userAgent ?? '');
const os = detectOS(userAgent ?? '');
const device = getDevice(payload.screen, os);
return {
userAgent,
browser,
os,
ip,
country,
subdivision1,
subdivision2,
city,
device,
};
}
export function getIpAddress(req: Request): string {
// Custom header
if (
process.env.CLIENT_IP_HEADER &&
req.headers[process.env.CLIENT_IP_HEADER]
) {
return String(req.headers[process.env.CLIENT_IP_HEADER]);
}
// Cloudflare
else if (req.headers['cf-connecting-ip']) {
return String(req.headers['cf-connecting-ip']);
}
return getClientIp(req)!;
}
export async function getLocation(ip: string, req: Request) {
// Ignore local ips
if (await isLocalhost(ip)) {
return;
}
// Cloudflare headers
if (req.headers['cf-ipcountry']) {
const country = safeDecodeURIComponent(req.headers['cf-ipcountry']);
const subdivision1 = safeDecodeURIComponent(req.headers['cf-region-code']);
const city = safeDecodeURIComponent(req.headers['cf-ipcity']);
return {
country,
subdivision1: getRegionCode(country, subdivision1),
city,
};
}
// Vercel headers
if (req.headers['x-vercel-ip-country']) {
const country = safeDecodeURIComponent(req.headers['x-vercel-ip-country']);
const subdivision1 = safeDecodeURIComponent(
req.headers['x-vercel-ip-country-region']
);
const city = safeDecodeURIComponent(req.headers['x-vercel-ip-city']);
return {
country,
subdivision1: getRegionCode(country, subdivision1),
city,
};
}
// Database lookup
if (!lookup) {
const dir = path.join(process.cwd(), 'geo');
lookup = await maxmind.open(path.resolve(dir, 'GeoLite2-City.mmdb'));
}
const result = lookup.get(ip);
if (result) {
return {
country: result.country?.iso_code ?? result?.registered_country?.iso_code,
subdivision1: result.subdivisions?.[0]?.iso_code,
subdivision2: result.subdivisions?.[1]?.names?.en,
city: result.city?.names?.en,
};
}
}
function getRegionCode(
country: string | null | undefined,
region: string | null | undefined
) {
if (!country || !region) {
return undefined;
}
return region.includes('-') ? region : `${country}-${region}`;
}
export function getDevice(
screen: string | undefined,
os: OperatingSystem | null
) {
if (!screen) return;
if (!os) return;
const [width] = screen.split('x').map((n) => Number(n));
if (DESKTOP_OS.includes(os)) {
if (os === 'Chrome OS' || width < DESKTOP_SCREEN_WIDTH) {
return 'laptop';
}
return 'desktop';
} else if (MOBILE_OS.includes(os)) {
if (os === 'Amazon OS' || width > MOBILE_SCREEN_WIDTH) {
return 'tablet';
}
return 'mobile';
}
if (width >= DESKTOP_SCREEN_WIDTH) {
return 'desktop';
} else if (width >= LAPTOP_SCREEN_WIDTH) {
return 'laptop';
} else if (width >= MOBILE_SCREEN_WIDTH) {
return 'tablet';
} else {
return 'mobile';
}
}

14
src/server/utils/types.ts Normal file
View File

@ -0,0 +1,14 @@
import { DATA_TYPE } from './const';
type ObjectValues<T> = T[keyof T];
export type DynamicDataType = ObjectValues<typeof DATA_TYPE>;
export interface DynamicData {
[key: string]:
| number
| string
| DynamicData
| number[]
| string[]
| DynamicData[];
}

View File

@ -28,7 +28,7 @@
const root = hostUrl const root = hostUrl
? hostUrl.replace(/\/$/, '') ? hostUrl.replace(/\/$/, '')
: currentScript.src.split('/').slice(0, -1).join('/'); : currentScript.src.split('/').slice(0, -1).join('/');
const endpoint = `${root}/api/send`; const endpoint = `${root}/api/website/send`;
const screen = `${width}x${height}`; const screen = `${width}x${height}`;
const eventRegex = /data-tianji-event-([\w-_]+)/; const eventRegex = /data-tianji-event-([\w-_]+)/;
const eventNameAttribute = _data + 'tianji-event'; const eventNameAttribute = _data + 'tianji-event';