Major bootstrap.

- Migrated frontend to Vue.

- Migrated frontend map to OL API.

- Extended environment variables.

- README.

- Country information/flag integration.

- Implemented generic db/repo.
This commit is contained in:
Fabio Manganiello 2025-02-22 16:31:43 +01:00
parent 03deaa9cd8
commit 5dfde74ccf
40 changed files with 7309 additions and 223 deletions

View file

@ -1,2 +1,58 @@
DATABASE_URL=postgres://user:password@host:port/dbname
PORT=3000
###
### Backend configuration
###
# Bind address for the backend (default: localhost)
BACKEND_ADDRESS=127.0.0.1
# Listen port for the backend (default: 3000)
BACKEND_PORT=3000
# Database URL (required)
DB_URL=postgres://user:password@host:port/dbname
# Database dialect (default: inferred from the URL)
# DB_DIALECT=postgres
# Name of the table that contains the location points (required)
DB_LOCATION_TABLE=gpsdata
## Database mappings
# The name of the column that contains the primary key of each location point
DB_LOCATION__ID=id
# The name of the column that contains the timestamp of each location point
DB_LOCATION__TIMESTAMP=timestamp
# The name of the column that contains the latitude of each location point
DB_LOCATION__LATITUDE=latitude
# The name of the column that contains the longitude of each location point
DB_LOCATION__LONGITUDE=longitude
# The name of the column that contains the altitude of each location point.
# Comment or leave empty if the altitude is not available.
DB_LOCATION__ALTITUDE=altitude
# The name of the column that contains the address of each location point.
# Comment or leave empty if the address is not available.
DB_LOCATION__ADDRESS=address
# The name of the column that contains the city/locality name of each location point
# Comment or leave empty if the locality is not available.
DB_LOCATION__LOCALITY=locality
# The name of the column that contains the country code of each location point
# Comment or leave empty if the country code is not available.
DB_LOCATION__COUNTRY=country
# The name of the column that contains the postal code of each location point
# Comment or leave empty if the postal code is not available.
DB_LOCATION__POSTAL_CODE=postal_code
###
### Frontend configuration
###
VITE_API_BASE_URL=http://localhost:${BACKEND_PORT}
VITE_API_PATH=/api

27
.gitignore vendored
View file

@ -1,2 +1,27 @@
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.env
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.vim
*.tsbuildinfo

58
app.js
View file

@ -1,58 +0,0 @@
const express = require('express');
const dotenv = require('dotenv');
const { Sequelize, DataTypes } = require('sequelize');
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
// PostgreSQL connection using Sequelize
const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialect: 'postgres',
logging: false
});
// Define GPS model
const GpsData = sequelize.define('GpsData', {
latitude: {
type: DataTypes.FLOAT,
allowNull: false
},
longitude: {
type: DataTypes.FLOAT,
allowNull: false
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
}
}, {
tableName: 'location_history',
timestamps: false
});
// Middleware
app.use(express.static('public'));
app.set('view engine', 'ejs');
// View route
app.get('/', async (req, res) => {
res.render('index')
});
// API route
app.get('/gpsdata', async (req, res) => {
const limit = req.query.limit || 100;
const apiResponse = await GpsData.findAll({
limit: limit,
offset: 0,
});
const gpsData = apiResponse.map((p) => p.dataValues);
res.json(gpsData);
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

9
frontend/.editorconfig Normal file
View file

@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
frontend/.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

View file

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

63
frontend/README.md Normal file
View file

@ -0,0 +1,63 @@
# GPSTracker
##### Track your GPS data, from any data source
GPSTracker is a simple Webapp that consists of:
- A backend that:
- Can read GPS data from any compatible data source (supported: `postgres`, `mysql`, `mariadb`, `mongodb`, `sqlite`,
`snowflake`), with arbitrary complex filtering, and expose them over a simple Web API.
- [[*TODO*]] Can ingest GPS data points from HTTP, MQTT, Websocket or Kafka.
- A frontend to display GPS data points and provides advanced filtering.
## Building the application
```sh
# Backend
npm install
npm run build
# Frontend
cd frontend
npm install
npm run build
```
## Configuration
See `.env.example` for a reference. Copy it to `.env` and modify it accordingly.
## Running the application
### Local installation
```sh
npm run start
```
### Docker
[[*TODO*]]
## Project Setup
### Compile and Hot-Reload for Development
#### Backend
```sh
npm run dev
```
#### Frontend
```sh
cd frontend
npm run dev
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

2
frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
declare const __API_PATH__: string;

24
frontend/eslint.config.ts Normal file
View file

@ -0,0 +1,24 @@
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPS Tracker</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5659
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
frontend/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "gpstracker",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"countries-list": "^3.1.1",
"ol": "^10.4.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.13.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.4.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.20.1",
"eslint-plugin-vue": "^9.32.0",
"jiti": "^2.4.2",
"npm-run-all2": "^7.0.2",
"prettier": "^3.5.1",
"sass-embedded": "^1.85.0",
"typescript": "~5.7.3",
"vite": "^6.1.0",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.2"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width: 32px  |  Height: 32px  |  Size: 4.2 KiB

24
frontend/src/App.vue Normal file
View file

@ -0,0 +1,24 @@
<template>
<!--
<header>
<div class="wrapper">
<nav>
<RouterLink to="/">Home</RouterLink>
</nav>
</div>
</header>
-->
<RouterView />
</template>
<script lang="ts">
import { RouterLink, RouterView } from 'vue-router'
export default {
components: {
RouterLink,
RouterView,
},
}
</script>

View file

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

(image error) Size: 276 B

View file

@ -0,0 +1,31 @@
@import './base.css';
#app {
max-width: 1280px;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: flex;
}
}

View file

@ -0,0 +1,171 @@
<template>
<main>
<div class="loading" v-if="loading">Loading...</div>
<div class="map-wrapper" v-else>
<div id="map">
<PointInfo :point="selectedPoint" ref="popup" @close="selectedPoint = null" />
</div>
</div>
</main>
</template>
<script lang="ts">
import Feature from 'ol/Feature';
import GPSPoint from '../models/GPSPoint';
import Map from 'ol/Map';
import OSM from 'ol/source/OSM';
import Overlay from 'ol/Overlay';
import Point from 'ol/geom/Point';
import PointInfo from './PointInfo.vue';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import { Circle, Fill, Style } from 'ol/style';
import { useGeographic } from 'ol/proj';
import { type Nullable } from '../models/Types';
// @ts-ignore
const baseURL = __API_PATH__
useGeographic()
export default {
components: {
PointInfo,
},
data() {
return {
gpsPoints: [] as GPSPoint[],
loading: false,
map: null as Nullable<Map>,
popup: null as Nullable<Overlay>,
selectedPoint: null as Nullable<GPSPoint>,
}
},
methods: {
async fetchData() {
this.loading = true
try {
const response = await fetch(`${baseURL}/gpsdata`)
return (await response.json())
.map((gps: any) => {
return new GPSPoint(gps)
})
} catch (error) {
console.error(error)
} finally {
this.loading = false
}
},
createMap(gpsPoints: GPSPoint[]) {
const points = gpsPoints.map((gps: GPSPoint) => {
const point = new Point([gps.longitude, gps.latitude])
return point
})
const pointFeatures = points.map((point: Point) => new Feature(point))
const view = new View(this.getCenterAndZoom())
const map = new Map({
target: 'map',
layers: [
new TileLayer({
source: new OSM(),
}),
new VectorLayer({
source: new VectorSource({
features: pointFeatures,
}),
style: new Style({
image: new Circle({
radius: 5,
fill: new Fill({ color: 'red' }),
}),
}),
}),
],
view: view
})
// @ts-expect-error
this.$refs.popup.bindPopup(map)
this.bindClick(map)
return map
},
getCenterAndZoom() {
if (!this.gpsPoints?.length) {
return {
center: [0, 0],
zoom: 2,
}
}
let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity]
this.gpsPoints.forEach((gps: GPSPoint) => {
minX = Math.min(minX, gps.longitude)
minY = Math.min(minY, gps.latitude)
maxX = Math.max(maxX, gps.longitude)
maxY = Math.max(maxY, gps.latitude)
})
const center = [(minX + maxX) / 2, (minY + maxY) / 2]
const zoom = Math.max(2, Math.min(18, 18 - Math.log2(Math.max(maxX - minX, maxY - minY))))
return { center, zoom }
},
bindClick(map: Map) {
map.on('click', (event) => {
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
if (feature) {
const point = this.gpsPoints.find((gps: GPSPoint) => {
const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
return gps.longitude === longitude && gps.latitude === latitude
})
if (point) {
this.selectedPoint = point
// @ts-expect-error
this.$refs.popup.setPosition(event.coordinate)
}
} else {
this.selectedPoint = null
}
})
},
},
async mounted() {
this.gpsPoints = await this.fetchData()
this.map = this.createMap(this.gpsPoints)
},
}
</script>
<style lang="scss" scoped>
@import "ol/ol.css";
html,
body {
margin: 0;
height: 100%;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
:deep(.ol-viewport) {
.ol-attribution {
position: absolute !important;
bottom: 0 !important;
right: 0 !important;
}
}
</style>

View file

@ -0,0 +1,113 @@
<template>
<div class="popup" :class="{ hidden: !point }" ref="popup">
<div class="popup-content" v-if="point">
<button @click="$emit('close')">Close</button>
<div class="point-info">
<h2 class="address" v-if="point.address">{{ point.address }}</h2>
<h2 class="latlng" v-else>{{ point.latitude }}, {{ point.longitude }}</h2>
<p class="latlng" v-if="point.address">{{ point.latitude }}, {{ point.longitude }}</p>
<p class="locality" v-if="point.locality">{{ point.locality }}</p>
<p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p>
<p class="country" v-if="country">
<span class="flag" v-if="countryFlag">{{ countryFlag }}&nbsp; </span>
<span class="name">{{ country.name }}</span>,&nbsp;
<span class="continent">{{ country.continent }}</span>
</p>
<p class="timestamp" v-if="timeString">{{ timeString }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import GPSPoint from '../models/GPSPoint';
import Map from 'ol/Map';
import Overlay from 'ol/Overlay';
import type { TCountryCode } from 'countries-list';
import { getCountryData, getEmojiFlag } from 'countries-list';
export default {
emit: ['close'],
props: {
point: {
type: [GPSPoint, null],
},
},
data() {
return {
popup: null as Overlay | null,
}
},
computed: {
country() {
const cc = this.point?.country as string | undefined
if (cc?.length) {
return getCountryData(cc.toUpperCase() as TCountryCode)
}
return null
},
countryFlag() {
return this.country ? getEmojiFlag(this.country.iso2 as TCountryCode) : null
},
timeString(): string | null {
return this.point?.timestamp ? new Date(this.point.timestamp).toLocaleString() : null
},
},
methods: {
bindPopup(map: Map) {
this.popup = new Overlay({
element: this.$refs.popup as HTMLElement,
autoPan: true,
})
// @ts-ignore
map.addOverlay(this.popup)
},
setPosition(coordinates: number[]) {
if (this.popup) {
this.popup.setPosition(coordinates)
}
},
},
}
</script>
<style lang="scss" scoped>
@import "ol/ol.css";
.popup {
position: absolute;
background: var(--color-background);
min-width: 20em;
padding: 1em;
border-radius: 1em;
box-shadow: 2px 2px 2px 2px var(--color-border);
&.hidden {
padding: 0;
border-radius: 0;
box-shadow: none;
width: 0;
height: 0;
min-width: 0;
pointer-events: none;
}
p.latlng {
font-size: 0.8em;
}
.timestamp {
color: var(--color-heading);
font-weight: bold;
font-size: 0.9em;
}
}
</style>

11
frontend/src/main.ts Normal file
View file

@ -0,0 +1,11 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,23 @@
class GPSPoint {
public latitude: number;
public longitude: number;
public altitude: number;
public address: string;
public locality: string;
public country: string;
public postalCode: string;
public timestamp: Date;
constructor(public data: any) {
this.latitude = data.latitude;
this.longitude = data.longitude;
this.altitude = data.altitude;
this.address = data.address;
this.locality = data.locality;
this.country = data.country;
this.postalCode = data.postalCode;
this.timestamp = data.timestamp;
}
}
export default GPSPoint;

View file

@ -0,0 +1 @@
export type Nullable<T> = T | null;

View file

@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
//{
// path: '/about',
// name: 'about',
// // route level code-splitting
// // this generates a separate chunk (About.[hash].js) for this route
// // which is lazy-loaded when the route is visited.
// component: () => import('../views/AboutView.vue'),
//},
],
})
export default router

View file

@ -0,0 +1,15 @@
<script lang="ts">
import Map from '../components/Map.vue';
export default {
components: {
Map,
},
};
</script>
<template>
<main>
<Map />
</main>
</template>

View file

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View file

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

44
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,44 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig((env) => {
const envDir = '../';
const envars = loadEnv(env.mode, envDir);
const serverURL = new URL(
envars.VITE_API_SERVER_URL ?? 'http://localhost:3000'
);
const serverAPIPath = envars.VITE_API_PATH ?? '/api';
return {
envDir: envDir,
// make the API path globally available in the client
define: {
__API_PATH__: JSON.stringify(serverAPIPath),
},
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
port: 5173,
proxy: {
// proxy requests with the API path to the server
// <http://localhost:5173/api> -> <http://localhost:3000/api>
[serverAPIPath]: serverURL.origin,
},
},
}
})

557
package-lock.json generated
View file

@ -1,18 +1,57 @@
{
"name": "gps-tracker",
"name": "server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gps-tracker",
"name": "server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"dotenv": "^16.0.0",
"ejs": "^3.1.6",
"express": "^4.17.1",
"pg": "^8.7.1",
"sequelize": "^6.6.5"
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.3",
"sequelize": "^6.37.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.4",
"nodemon": "^3.1.9",
"typescript": "^5.7.3"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
"integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/debug": {
@ -24,6 +63,46 @@
"@types/ms": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz",
"integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz",
"integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -39,6 +118,43 @@
"undici-types": "~6.20.0"
}
},
"node_modules/@types/qs": {
"version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
"integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
}
},
"node_modules/@types/validator": {
"version": "13.12.2",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz",
@ -58,19 +174,18 @@
"node": ">= 0.6"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"color-convert": "^2.0.1"
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
"node": ">= 8"
}
},
"node_modules/array-flatten": {
@ -79,18 +194,26 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@ -119,12 +242,26 @@
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -163,44 +300,36 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">=10"
"node": ">= 8.10.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
"url": "https://paulmillr.com/funding/"
},
"engines": {
"node": ">=7.0.0"
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": {
@ -239,6 +368,19 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -305,21 +447,6 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -420,34 +547,17 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=10"
"node": ">=8"
}
},
"node_modules/finalhandler": {
@ -486,6 +596,21 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -532,6 +657,19 @@
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -545,12 +683,13 @@
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
"node": ">=4"
}
},
"node_modules/has-symbols": {
@ -605,6 +744,13 @@
"node": ">=0.10.0"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
"node_modules/inflection": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz",
@ -629,22 +775,50 @@
"node": ">= 0.10"
}
},
"node_modules/jake": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
"integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
"license": "Apache-2.0",
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"async": "^3.2.3",
"chalk": "^4.0.2",
"filelist": "^1.0.4",
"minimatch": "^3.1.2"
},
"bin": {
"jake": "bin/cli.js"
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=10"
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/lodash": {
@ -726,6 +900,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@ -770,6 +945,79 @@
"node": ">= 0.6"
}
},
"node_modules/nodemon": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
"integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/nodemon/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/nodemon/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -898,6 +1146,19 @@
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -950,6 +1211,13 @@
"node": ">= 0.10"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@ -989,6 +1257,19 @@
"node": ">= 0.8"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/retry-as-promised": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz",
@ -1259,6 +1540,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@ -1278,15 +1572,29 @@
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=8"
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": {
@ -1304,6 +1612,16 @@
"integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==",
"license": "MIT"
},
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -1317,6 +1635,27 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",

View file

@ -1,16 +1,40 @@
{
"name": "gps-tracker",
"name": "server",
"version": "1.0.0",
"description": "A web interface to render GPS data points on a map",
"main": "app.js",
"description": "",
"main": "main.js",
"scripts": {
"start": "node app.js"
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node dist/main.js",
"dev": "nodemon",
"build": "tsc"
},
"nodemonConfig": {
"watch": [
"src"
],
"ignore": [
"dist"
],
"exec": "tsc && node ./dist/main.js",
"ext": "ts,js,json"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"dotenv": "^16.0.0",
"ejs": "^3.1.6",
"express": "^4.17.1",
"pg": "^8.7.1",
"sequelize": "^6.6.5"
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.3",
"sequelize": "^6.37.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.4",
"nodemon": "^3.1.9",
"typescript": "^5.7.3"
}
}

View file

@ -1,3 +0,0 @@
#map {
height: 100vh;
}

View file

@ -1,26 +0,0 @@
async function getGpsData() {
return await fetch('/gpsdata')
}
document.addEventListener('DOMContentLoaded', () => {
const map = L.map('map').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
getGpsData().then((response) => {
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
response.json().then((gpsData) => {
console.log(gpsData);
gpsData.forEach(point => {
L.marker([point.latitude, point.longitude]).addTo(map)
.bindPopup(`Latitude: ${point.latitude}, Longitude: ${point.longitude}`)
.openPopup();
});
});
});
});

158
src/db/Db.ts Normal file
View file

@ -0,0 +1,158 @@
import { Sequelize, DataTypes, Dialect } from 'sequelize';
export class Db {
private readonly url: string;
private readonly locationTable: string;
public readonly locationTableColumns: object;
private readonly dialect: Dialect;
private readonly sequelize: Sequelize;
private static readonly envColumnPrefix = 'DB_LOCATION__';
private constructor(
opts: {
url: string,
locationTable: string,
locationTableColumns: object,
dialect: Dialect,
}
) {
this.url = opts.url;
this.locationTable = opts.locationTable;
this.locationTableColumns = opts.locationTableColumns
this.dialect = opts.dialect as Dialect;
this.sequelize = new Sequelize(this.url, {
dialect: this.dialect,
logging: process.env.DEBUG === 'true' ? console.log : false
});
}
public static fromEnv(): Db {
const opts: any = {}
opts.url = process.env.DB_URL;
opts.locationTable = process.env.DB_LOCATION_TABLE;
opts.dialect = process.env.DB_DIALECT || opts.url.split(':')[0];
if (!opts.url?.length) {
console.error('No DB_URL provided');
process.exit(1);
}
if (!opts.locationTable?.length) {
console.error('No LOCATION_TABLE provided');
process.exit(1);
}
const requiredColumns = ['id', 'timestamp', 'latitude', 'longitude']
.reduce((acc: any, name: string) => {
acc[name] = true
return acc;
}, {});
opts.locationTableColumns = [
'id',
'timestamp',
'latitude',
'longitude',
'altitude',
'address',
'locality',
'country',
'postal_code'
].reduce((acc: any, name: string) => {
acc[name] = process.env[this.prefixed(name)];
if (!acc[name]?.length && requiredColumns[name]) {
// Default to the name of the required field
acc[name] = name;
}
return acc;
}, {});
return new Db(opts);
}
private static prefixed(name: string): string {
return `${Db.envColumnPrefix}${name.toUpperCase()}`;
}
public GpsData() {
const typeDef: any = {};
// @ts-expect-error
typeDef[this.locationTableColumns['id']] = {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
};
// @ts-expect-error
typeDef[this.locationTableColumns['latitude']] = {
type: DataTypes.FLOAT,
allowNull: false
};
// @ts-expect-error
typeDef[this.locationTableColumns['longitude']] = {
type: DataTypes.FLOAT,
allowNull: false
};
// @ts-expect-error
const altitudeCol: string = this.locationTableColumns['altitude'];
if (altitudeCol?.length) {
typeDef[altitudeCol] = {
type: DataTypes.FLOAT,
allowNull: true
};
}
// @ts-expect-error
const addressCol: string = this.locationTableColumns['address'];
if (addressCol?.length) {
typeDef[addressCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
const localityCol: string = this.locationTableColumns['locality'];
if (localityCol?.length) {
typeDef[localityCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
const countryCol: string = this.locationTableColumns['country'];
if (countryCol?.length) {
typeDef[countryCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
const postalCodeCol: string = this.locationTableColumns['postal_code'];
if (postalCodeCol?.length) {
typeDef[postalCodeCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
typeDef[this.locationTableColumns['timestamp']] = {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
};
return this.sequelize.define('GpsData', typeDef, {
tableName: this.locationTable,
timestamps: false
});
}
}

47
src/main.ts Normal file
View file

@ -0,0 +1,47 @@
import cors from 'cors';
import express from 'express';
import dotenv from 'dotenv';
import { Db } from './db/Db';
import { LocationRequest } from './models/LocationRequest';
import { LocationRepository } from './repo/LocationRepository';
dotenv.config();
const app = express();
const db = Db.fromEnv();
const address = process.env.BACKEND_ADDRESS || '127.0.0.1';
const port = process.env.BACKEND_PORT || 3000;
// Middleware
app.use(cors());
app.use(express.static('frontend/dist'));
// API route
app.get('/api/gpsdata', async (req, res) => {
console.log(`[${req.ip}] GET ${req.originalUrl}`);
let query: any = {};
try {
query = new LocationRequest(req.query);
} catch (error) {
const e = `Error parsing query: ${error}`;
console.warn(e);
res.status(400).send(e);
return;
}
try {
const gpsData = await new LocationRepository(db).getHistory(query);
res.json(gpsData);
} catch (error) {
const e = `Error fetching data: ${error}`;
console.error(e);
res.status(500).send(e);
}
});
// @ts-ignore
app.listen(port, address, () => {
console.log(`Server is running on port ${address}:${port}`);
});

25
src/models/GPSPoint.ts Normal file
View file

@ -0,0 +1,25 @@
class GPSPoint {
public id: number;
public latitude: number;
public longitude: number;
public altitude: number | null;
public address: string | null;
public locality: string | null;
public country: string | null;
public postalCode: string | null;
public timestamp: Date;
constructor(record: any) {
this.id = record.id;
this.latitude = record.latitude;
this.longitude = record.longitude;
this.altitude = record.altitude;
this.address = record.address;
this.locality = record.locality;
this.country = record.country;
this.postalCode = record.postalCode;
this.timestamp = record.timestamp;
}
}
export { GPSPoint };

View file

@ -0,0 +1,39 @@
import { Nullable } from './Types';
class LocationRequest {
limit: Nullable<number> = 10;
offset: Nullable<number> = null;
constructor(req: any) {
if (req.limit != null) {
this.limit = parseInt(req.limit);
if (isNaN(this.limit)) {
throw new TypeError('Invalid limit');
}
}
if (req.offset != null) {
this.offset = parseInt(req.offset);
if (isNaN(this.offset)) {
throw new TypeError('Invalid offset');
}
}
}
public toMap(): any {
let map: any = {};
if (this.limit != null) {
map.limit = this.limit;
}
if (this.offset != null) {
map.offset = this.offset;
}
map.order = [['created_at', 'DESC']];
return map;
}
}
export { LocationRequest };

3
src/models/Types.ts Normal file
View file

@ -0,0 +1,3 @@
type Nullable<T> = T | null;
export { Nullable };

View file

@ -0,0 +1,42 @@
import { Db } from '../db/Db';
import { GPSPoint } from '../models/GPSPoint';
import { LocationRequest } from 'src/models/LocationRequest';
export class LocationRepository {
private db: Db;
constructor(db: Db) {
this.db = db;
}
public async getHistory(query: LocationRequest): Promise<GPSPoint[]> {
let apiResponse: any[] = [];
try {
apiResponse = await this.db.GpsData().findAll(query.toMap());
} catch (error) {
throw new Error(`Error fetching data: ${error}`);
}
try {
return apiResponse.map((p) => {
const data = p.dataValues;
const mappings: any = this.db.locationTableColumns;
return new GPSPoint({
id: data[mappings.id],
latitude: data[mappings.latitude],
longitude: data[mappings.longitude],
altitude: data[mappings.altitude],
address: data[mappings.address],
locality: data[mappings.locality],
country: data[mappings.country],
postalCode: data[mappings.postal_code],
timestamp: data[mappings.timestamp],
});
});
} catch (error) {
throw new Error(`Error parsing data: ${error}`);
}
}
}

28
tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
"skipLibCheck": true,
"sourceMap": true,
"outDir": "./dist",
"moduleResolution": "node",
"removeComments": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"baseUrl": "."
},
"exclude": ["node_modules"],
"include": ["./src/**/*.ts"]
}

View file

@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPS Tracker</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="/js/main.js"></script>
</body>
</html>