parent
533ebe960f
commit
c4e0c67e34
35 changed files with 1329 additions and 111 deletions
.env.example.gitignorepackage-lock.jsonpackage.json
src
22
.env.example
22
.env.example
|
@ -9,7 +9,23 @@ BACKEND_ADDRESS=127.0.0.1
|
||||||
BACKEND_PORT=3000
|
BACKEND_PORT=3000
|
||||||
|
|
||||||
# Application database URL (required)
|
# Application database URL (required)
|
||||||
|
# Postgres:
|
||||||
DB_URL=postgres://user:password@host:port/dbname
|
DB_URL=postgres://user:password@host:port/dbname
|
||||||
|
# SQLite:
|
||||||
|
# DB_URL=sqlite:///path/to/app.db
|
||||||
|
|
||||||
|
# The server key is used to sign JWT tokens (required)
|
||||||
|
# It should be a random string with at least 32 characters.
|
||||||
|
# You can generate a random string with the following command:
|
||||||
|
# openssl rand -base64 32
|
||||||
|
SERVER_KEY=your_server_key
|
||||||
|
|
||||||
|
# Admin password (required)
|
||||||
|
# The admin password is used to authenticate the admin user.
|
||||||
|
ADMIN_PASSWORD=your_admin_password
|
||||||
|
|
||||||
|
# Admin user email (required)
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
|
||||||
# If the location data is stored on another db than the one used by the backend,
|
# If the location data is stored on another db than the one used by the backend,
|
||||||
# you can specify a different database URL here.
|
# you can specify a different database URL here.
|
||||||
|
@ -21,8 +37,12 @@ DB_URL=postgres://user:password@host:port/dbname
|
||||||
# Location database dialect (default: inferred from the URL)
|
# Location database dialect (default: inferred from the URL)
|
||||||
# DB_LOCATION_DIALECT=postgres
|
# DB_LOCATION_DIALECT=postgres
|
||||||
|
|
||||||
|
# Prefix for the application tables (default: empty).
|
||||||
|
# Note that this does not apply to DB_LOCATION_TABLE if it is set.
|
||||||
|
DB_TABLE_PREFIX=
|
||||||
|
|
||||||
# Name of the table that contains the location points (required)
|
# Name of the table that contains the location points (required)
|
||||||
DB_LOCATION_TABLE=gpsdata
|
DB_LOCATION_TABLE=location_history
|
||||||
|
|
||||||
## Database mappings
|
## Database mappings
|
||||||
# The name of the column that contains the primary key of each location point
|
# The name of the column that contains the primary key of each location point
|
||||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -25,3 +25,8 @@ coverage
|
||||||
*.sw?
|
*.sw?
|
||||||
*.vim
|
*.vim
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
188
package-lock.json
generated
188
package-lock.json
generated
|
@ -7,19 +7,23 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"body-parser": "^1.20.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"passport": "^0.7.0",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.13.3",
|
"pg": "^8.13.3",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"nodemon": "^3.1.9",
|
"nodemon": "^3.1.9",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
@ -141,6 +145,17 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
|
@ -370,6 +385,15 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
@ -475,6 +499,12 @@
|
||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
@ -760,6 +790,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
@ -1505,12 +1544,103 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/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==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
@ -2017,32 +2147,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/passport": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"passport-strategy": "1.x.x",
|
|
||||||
"pause": "0.0.1",
|
|
||||||
"utils-merge": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/jaredhanson"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/passport-strategy": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
@ -2059,11 +2163,6 @@
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pause": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
|
||||||
},
|
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.13.3",
|
"version": "8.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz",
|
||||||
|
@ -2567,6 +2666,15 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sequelize/node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "1.16.2",
|
"version": "1.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||||
|
@ -3114,12 +3222,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "8.3.2",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/esm/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/validator": {
|
"node_modules/validator": {
|
||||||
|
|
10
package.json
10
package.json
|
@ -21,20 +21,24 @@
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "GPL-3.0",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"body-parser": "^1.20.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"passport": "^0.7.0",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.13.3",
|
"pg": "^8.13.3",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"nodemon": "^3.1.9",
|
"nodemon": "^3.1.9",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
|
56
src/App.ts
Normal file
56
src/App.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import cors from 'cors';
|
||||||
|
import express from 'express';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
|
||||||
|
import { useGlobals } from './globals';
|
||||||
|
import Routes from './routes';
|
||||||
|
|
||||||
|
class App {
|
||||||
|
private readonly app: express.Express;
|
||||||
|
private readonly address: string;
|
||||||
|
private readonly port: number;
|
||||||
|
|
||||||
|
private constructor({
|
||||||
|
app,
|
||||||
|
address,
|
||||||
|
port,
|
||||||
|
routes,
|
||||||
|
}: any) {
|
||||||
|
useGlobals();
|
||||||
|
$db.sync().then(() => {
|
||||||
|
$repos.userRoles.init();
|
||||||
|
$repos.users.syncAdminUser();
|
||||||
|
})
|
||||||
|
|
||||||
|
this.app = app;
|
||||||
|
this.address = address;
|
||||||
|
this.port = port;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(express.static('frontend/dist'));
|
||||||
|
routes.register(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromEnv(): App {
|
||||||
|
const address = process.env.BACKEND_ADDRESS || '127.0.0.1';
|
||||||
|
const port = new Number(process.env.BACKEND_PORT || 3000).valueOf();
|
||||||
|
const app = express();
|
||||||
|
const routes = new Routes();
|
||||||
|
|
||||||
|
return new App({
|
||||||
|
app,
|
||||||
|
address,
|
||||||
|
port,
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public listen(): void {
|
||||||
|
this.app.listen(this.port, this.address, () => {
|
||||||
|
console.log(`Server is running on port ${this.address}:${this.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
40
src/Secrets.ts
Normal file
40
src/Secrets.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
class Secrets {
|
||||||
|
public readonly serverKey: string;
|
||||||
|
public readonly adminPassword: string;
|
||||||
|
public readonly adminEmail: string;
|
||||||
|
|
||||||
|
private constructor({
|
||||||
|
serverKey,
|
||||||
|
adminPassword,
|
||||||
|
adminEmail,
|
||||||
|
}: any) {
|
||||||
|
this.serverKey = serverKey;
|
||||||
|
this.adminPassword = adminPassword;
|
||||||
|
this.adminEmail = adminEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromEnv(): Secrets {
|
||||||
|
if (!process.env.SERVER_KEY?.length) {
|
||||||
|
throw new Error(
|
||||||
|
'SERVER_KEY not found in environment.\n' +
|
||||||
|
'Generate one with `openssl rand -base64 32` and add it to your environment.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.ADMIN_PASSWORD?.length) {
|
||||||
|
throw new Error('ADMIN_PASSWORD not found in environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.ADMIN_EMAIL?.length) {
|
||||||
|
throw new Error('ADMIN_EMAIL not found in environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Secrets({
|
||||||
|
serverKey: process.env.SERVER_KEY,
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD,
|
||||||
|
adminEmail: process.env.ADMIN_EMAIL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Secrets;
|
74
src/auth.ts
Normal file
74
src/auth.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
import { Forbidden, Unauthorized } from './errors';
|
||||||
|
import { Optional } from '~/types';
|
||||||
|
import { RoleName, User, UserSession } from '~/models';
|
||||||
|
import Route from './routes/Route';
|
||||||
|
|
||||||
|
class AuthInfo {
|
||||||
|
public user: User;
|
||||||
|
public session: Optional<UserSession>;
|
||||||
|
|
||||||
|
constructor(user: User, session: Optional<UserSession> = null) {
|
||||||
|
this.user = user;
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate(roles: RoleName[] = []) {
|
||||||
|
return function (route: any, method: string) {
|
||||||
|
const routeClass = (<typeof Route> route.constructor);
|
||||||
|
routeClass.preRequestHandlers[method] = async (req: Request): Promise<AuthInfo> => {
|
||||||
|
let user: Optional<User>;
|
||||||
|
let session: Optional<UserSession>;
|
||||||
|
|
||||||
|
// Check the `session` cookie or the `Authorization` header for the session token
|
||||||
|
let token = req.cookies?.session;
|
||||||
|
if (!token?.length) {
|
||||||
|
const authHeader = req.headers?.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
token = authHeader.slice(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the token is valid
|
||||||
|
if (token?.length) {
|
||||||
|
session = await $repos.userSessions.byToken(token);
|
||||||
|
if (session) {
|
||||||
|
user = await $repos.users.get(session.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(session && user)) {
|
||||||
|
// Check the `username` and `password` query or body parameters
|
||||||
|
const username = req.body?.username || req.query?.username;
|
||||||
|
const password = req.body?.password || req.query?.password;
|
||||||
|
|
||||||
|
if (username?.length && password?.length) {
|
||||||
|
user = await $repos.users.find(username);
|
||||||
|
if (!(user && user.checkPassword(password))) {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Unauthorized('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.length) {
|
||||||
|
// Check if the user has the required roles
|
||||||
|
const userRoles = new Set((await user.roles()).map((role) => role.name));
|
||||||
|
const missingPermissions = roles.filter((role) => !userRoles.has(role));
|
||||||
|
|
||||||
|
if (missingPermissions.length) {
|
||||||
|
throw new Forbidden('Missing required roles: [' + missingPermissions.join(', ') + ']');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuthInfo(user, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AuthInfo, authenticate };
|
115
src/db/Db.ts
115
src/db/Db.ts
|
@ -1,6 +1,10 @@
|
||||||
import { Sequelize, Dialect } from 'sequelize';
|
import { Sequelize, Dialect } from 'sequelize';
|
||||||
|
|
||||||
import GPSData from './types/GPSData';
|
import GPSData from './types/GPSData';
|
||||||
|
import Role from './types/Role';
|
||||||
|
import User from './types/User';
|
||||||
|
import UserRole from './types/UserRole';
|
||||||
|
import UserSession from './types/UserSession';
|
||||||
|
|
||||||
class Db {
|
class Db {
|
||||||
private readonly url: string;
|
private readonly url: string;
|
||||||
|
@ -9,6 +13,7 @@ class Db {
|
||||||
public readonly locationTableColumns: Record<string, string>;
|
public readonly locationTableColumns: Record<string, string>;
|
||||||
private readonly dialect: Dialect;
|
private readonly dialect: Dialect;
|
||||||
private readonly locationDialect: Dialect;
|
private readonly locationDialect: Dialect;
|
||||||
|
private readonly tablePrefix: string;
|
||||||
private readonly appDb: Sequelize;
|
private readonly appDb: Sequelize;
|
||||||
private readonly locationDb: Sequelize;
|
private readonly locationDb: Sequelize;
|
||||||
|
|
||||||
|
@ -18,18 +23,20 @@ class Db {
|
||||||
opts: {
|
opts: {
|
||||||
url: string,
|
url: string,
|
||||||
locationUrl: string,
|
locationUrl: string,
|
||||||
locationTable: string,
|
locationTable?: string | null,
|
||||||
locationTableColumns: Record<string, string>,
|
locationTableColumns: Record<string, string>,
|
||||||
dialect: Dialect,
|
dialect: Dialect,
|
||||||
locationDialect: Dialect | null,
|
locationDialect: Dialect | null,
|
||||||
|
tablePrefix?: string | null,
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.url = opts.url;
|
this.url = opts.url;
|
||||||
this.locationUrl = opts.locationUrl;
|
this.locationUrl = opts.locationUrl;
|
||||||
this.locationTable = opts.locationTable;
|
|
||||||
this.locationTableColumns = opts.locationTableColumns
|
this.locationTableColumns = opts.locationTableColumns
|
||||||
this.dialect = opts.dialect as Dialect;
|
this.dialect = opts.dialect as Dialect;
|
||||||
this.locationDialect = (opts.locationDialect || this.dialect) as Dialect;
|
this.locationDialect = (opts.locationDialect || this.dialect) as Dialect;
|
||||||
|
this.tablePrefix = opts.tablePrefix || '';
|
||||||
|
this.locationTable = opts.locationTable || this.tableName('location_history');
|
||||||
|
|
||||||
this.appDb = new Sequelize(this.url, {
|
this.appDb = new Sequelize(this.url, {
|
||||||
dialect: this.dialect,
|
dialect: this.dialect,
|
||||||
|
@ -53,6 +60,7 @@ class Db {
|
||||||
opts.locationTable = process.env.DB_LOCATION_TABLE;
|
opts.locationTable = process.env.DB_LOCATION_TABLE;
|
||||||
opts.dialect = process.env.DB_DIALECT || opts.url.split(':')[0];
|
opts.dialect = process.env.DB_DIALECT || opts.url.split(':')[0];
|
||||||
opts.locationDialect = process.env.DB_LOCATION_DIALECT || opts.locationUrl.split(':')[0];
|
opts.locationDialect = process.env.DB_LOCATION_DIALECT || opts.locationUrl.split(':')[0];
|
||||||
|
opts.tablePrefix = process.env.DB_TABLE_PREFIX;
|
||||||
|
|
||||||
if (!opts.url?.length) {
|
if (!opts.url?.length) {
|
||||||
console.error('No DB_URL provided');
|
console.error('No DB_URL provided');
|
||||||
|
@ -81,7 +89,7 @@ class Db {
|
||||||
'country',
|
'country',
|
||||||
'postal_code'
|
'postal_code'
|
||||||
].reduce((acc: any, name: string) => {
|
].reduce((acc: any, name: string) => {
|
||||||
acc[name] = process.env[this.prefixed(name)];
|
acc[name] = process.env[this.prefixedEnv(name)];
|
||||||
if (!acc[name]?.length && requiredColumns[name]) {
|
if (!acc[name]?.length && requiredColumns[name]) {
|
||||||
// Default to the name of the required field
|
// Default to the name of the required field
|
||||||
acc[name] = name;
|
acc[name] = name;
|
||||||
|
@ -93,10 +101,66 @@ class Db {
|
||||||
return new Db(opts);
|
return new Db(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static prefixed(name: string): string {
|
private static prefixedEnv(name: string): string {
|
||||||
return `${Db.envColumnPrefix}${name.toUpperCase()}`;
|
return `${Db.envColumnPrefix}${name.toUpperCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public tableName(table: string): string {
|
||||||
|
return `${this.tablePrefix}${table}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sync() {
|
||||||
|
console.log('Syncing databases');
|
||||||
|
|
||||||
|
const gpsData = this.GPSData();
|
||||||
|
const role = this.Role();
|
||||||
|
const user = this.User();
|
||||||
|
const userRole = this.UserRole();
|
||||||
|
const userSession = this.UserSession();
|
||||||
|
this.initConstraints();
|
||||||
|
|
||||||
|
await gpsData.sync();
|
||||||
|
await role.sync();
|
||||||
|
await user.sync();
|
||||||
|
await userRole.sync();
|
||||||
|
await userSession.sync();
|
||||||
|
|
||||||
|
await this.appDb.sync();
|
||||||
|
console.log('Database sync completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private initConstraints() {
|
||||||
|
this.appDb.models.UserSession.belongsTo(this.appDb.models.User, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
targetKey: 'id',
|
||||||
|
as: 'user',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.appDb.models.User.hasMany(this.appDb.models.UserSession, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
sourceKey: 'id',
|
||||||
|
as: 'sessions'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.appDb.models.User.belongsToMany(this.appDb.models.Role, {
|
||||||
|
through: this.appDb.models.UserRole,
|
||||||
|
foreignKey: 'userId',
|
||||||
|
otherKey: 'roleId',
|
||||||
|
as: 'roles'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.appDb.models.Role.belongsToMany(this.appDb.models.User, {
|
||||||
|
through: this.appDb.models.UserRole,
|
||||||
|
foreignKey: 'roleId',
|
||||||
|
otherKey: 'userId',
|
||||||
|
as: 'users'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.appDb.models.UserSession.sync();
|
||||||
|
this.appDb.sync();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tables
|
* Tables
|
||||||
*/
|
*/
|
||||||
|
@ -104,9 +168,50 @@ class Db {
|
||||||
public GPSData() {
|
public GPSData() {
|
||||||
return this.locationDb.define('GPSData', GPSData(this.locationTableColumns), {
|
return this.locationDb.define('GPSData', GPSData(this.locationTableColumns), {
|
||||||
tableName: this.locationTable,
|
tableName: this.locationTable,
|
||||||
timestamps: false
|
timestamps: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Role() {
|
||||||
|
return this.appDb.define('Role', Role(), {
|
||||||
|
tableName: this.tableName('roles'),
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public User() {
|
||||||
|
return this.appDb.define('User', User(), {
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['username', 'email'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tableName: this.tableName('users'),
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserRole() {
|
||||||
|
return this.appDb.define('UserRole', UserRole(), {
|
||||||
|
tableName: this.tableName('user_roles'),
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserSession() {
|
||||||
|
const ret = this.appDb.define('UserSession', UserSession(), {
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['userId'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tableName: this.tableName('user_sessions'),
|
||||||
|
timestamps: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Db;
|
export default Db;
|
||||||
|
|
24
src/db/types/Role.ts
Normal file
24
src/db/types/Role.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { DataTypes, } from 'sequelize';
|
||||||
|
|
||||||
|
function Role(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false,
|
||||||
|
autoIncrement: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Role;
|
46
src/db/types/User.ts
Normal file
46
src/db/types/User.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { DataTypes, } from 'sequelize';
|
||||||
|
|
||||||
|
function User(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
firstName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default User;
|
32
src/db/types/UserRole.ts
Normal file
32
src/db/types/UserRole.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { DataTypes, } from 'sequelize';
|
||||||
|
|
||||||
|
function UserRole(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
roleId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserRole;
|
34
src/db/types/UserSession.ts
Normal file
34
src/db/types/UserSession.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
function UserSession(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => uuidv4(),
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserSession;
|
20
src/errors.ts
Normal file
20
src/errors.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
abstract class ApplicationError extends Error {
|
||||||
|
constructor(public message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BadRequest extends ApplicationError { }
|
||||||
|
class ValidationError extends BadRequest { }
|
||||||
|
class Unauthorized extends BadRequest { }
|
||||||
|
class Forbidden extends BadRequest { }
|
||||||
|
class NotFound extends BadRequest { }
|
||||||
|
|
||||||
|
export {
|
||||||
|
ApplicationError,
|
||||||
|
BadRequest,
|
||||||
|
Forbidden,
|
||||||
|
NotFound,
|
||||||
|
Unauthorized,
|
||||||
|
ValidationError,
|
||||||
|
};
|
|
@ -1,6 +1,7 @@
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
import { Db } from './db';
|
import { Db } from './db';
|
||||||
|
import Secrets from './Secrets';
|
||||||
import Repositories from './repos';
|
import Repositories from './repos';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
@ -8,9 +9,11 @@ dotenv.config();
|
||||||
declare global {
|
declare global {
|
||||||
var $db: Db;
|
var $db: Db;
|
||||||
var $repos: Repositories;
|
var $repos: Repositories;
|
||||||
|
var $secrets: Secrets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGlobals() {
|
export function useGlobals() {
|
||||||
|
globalThis.$secrets = Secrets.fromEnv();
|
||||||
globalThis.$db = Db.fromEnv();
|
globalThis.$db = Db.fromEnv();
|
||||||
globalThis.$repos = new Repositories(globalThis.$db);
|
globalThis.$repos = new Repositories();
|
||||||
}
|
}
|
||||||
|
|
13
src/helpers/random.ts
Normal file
13
src/helpers/random.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
function randomString(length: number) {
|
||||||
|
let result = '';
|
||||||
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const charactersLength = characters.length;
|
||||||
|
let counter = 0;
|
||||||
|
while (counter < length) {
|
||||||
|
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { randomString };
|
11
src/helpers/security.ts
Normal file
11
src/helpers/security.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
function maskPassword(obj: Record<string, any>): Record<string, any> {
|
||||||
|
if (obj.password) {
|
||||||
|
delete obj.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
maskPassword,
|
||||||
|
};
|
22
src/main.ts
22
src/main.ts
|
@ -1,20 +1,4 @@
|
||||||
import cors from 'cors';
|
import App from './App';
|
||||||
import express from 'express';
|
|
||||||
|
|
||||||
import { useGlobals } from './globals';
|
const app = App.fromEnv();
|
||||||
useGlobals();
|
app.listen();
|
||||||
|
|
||||||
import Routes from './routes';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const address = process.env.BACKEND_ADDRESS || '127.0.0.1';
|
|
||||||
const port = process.env.BACKEND_PORT || 3000;
|
|
||||||
const routes = new Routes();
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.static('frontend/dist'));
|
|
||||||
routes.register(app)
|
|
||||||
|
|
||||||
app.listen(new Number(port).valueOf(), address, () => {
|
|
||||||
console.log(`Server is running on port ${address}:${port}`);
|
|
||||||
});
|
|
||||||
|
|
57
src/models/Role.ts
Normal file
57
src/models/Role.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import { Optional } from '~/types';
|
||||||
|
import RoleName from './RoleName';
|
||||||
|
|
||||||
|
class Role {
|
||||||
|
public id: number;
|
||||||
|
public name: RoleName;
|
||||||
|
public createdAt: Optional<Date>;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
createdAt = null,
|
||||||
|
}: any) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(role: number | string): Promise<Optional<Role>> {
|
||||||
|
const dbRole = await $db.Role().findOne({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: role },
|
||||||
|
{ name: role },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbRole) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Role(dbRole.dataValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(): Promise<void> {
|
||||||
|
const userRole = await $db.Role().findOne({
|
||||||
|
where: {
|
||||||
|
name: this.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRole) {
|
||||||
|
await $db.Role().create({
|
||||||
|
name: this.name,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await userRole.update({
|
||||||
|
name: this.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Role;
|
6
src/models/RoleName.ts
Normal file
6
src/models/RoleName.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
enum RoleName {
|
||||||
|
Admin = 'admin',
|
||||||
|
User = 'user',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleName;
|
113
src/models/User.ts
Normal file
113
src/models/User.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
import { Optional } from '~/types';
|
||||||
|
import Role from './Role';
|
||||||
|
import RoleName from './RoleName';
|
||||||
|
|
||||||
|
class User {
|
||||||
|
public id: number;
|
||||||
|
public username: string;
|
||||||
|
public password: string;
|
||||||
|
public email: string;
|
||||||
|
public firstName: Optional<string>;
|
||||||
|
public lastName: Optional<string>;
|
||||||
|
public createdAt: Optional<Date>;
|
||||||
|
public updatedAt: Optional<Date>;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName = null,
|
||||||
|
lastName = null,
|
||||||
|
createdAt = null,
|
||||||
|
updatedAt = null,
|
||||||
|
}: User) {
|
||||||
|
this.id = id;
|
||||||
|
this.username = username;
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
this.firstName = firstName;
|
||||||
|
this.lastName = lastName;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static hashPassword(password: string): string {
|
||||||
|
return bcrypt.hashSync(password, bcrypt.genSaltSync(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkPassword(password: string): boolean {
|
||||||
|
return bcrypt.compareSync(password, this.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async roles(): Promise<Role[]> {
|
||||||
|
return (
|
||||||
|
await $db.UserRole().findAll({
|
||||||
|
where: {
|
||||||
|
userId: this.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).map((role) => new Role(role.dataValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> {
|
||||||
|
const rolesToFetch = roles.map((role) => {
|
||||||
|
if (role instanceof Role) {
|
||||||
|
return role.id || role.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return role;
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleIds = new Set(
|
||||||
|
(
|
||||||
|
await $db.Role().findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: {
|
||||||
|
id: rolesToFetch,
|
||||||
|
name: rolesToFetch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).map((role) => (role as any).id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const userRoles = await $db.UserRole().findAll({
|
||||||
|
where: {
|
||||||
|
userId: this.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRoleIds = new Set(userRoles.map((role) => (role as any).roleId));
|
||||||
|
const toAdd = [...roleIds].filter((roleId) => !userRoleIds.has(roleId));
|
||||||
|
const toRemove = userRoles.filter((role) => !roleIds.has((role as any).roleId)).map((role) => (role as any).roleId);
|
||||||
|
|
||||||
|
await $db.UserRole().bulkCreate(
|
||||||
|
toAdd.map((roleId) => ({
|
||||||
|
userId: this.id,
|
||||||
|
roleId: roleId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
await $db.UserRole().destroy({
|
||||||
|
where: {
|
||||||
|
userId: this.id,
|
||||||
|
roleId: toRemove,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(): Promise<void> {
|
||||||
|
this.password = User.hashPassword(this.password);
|
||||||
|
await $db.User().update(this, {
|
||||||
|
where: {
|
||||||
|
id: this.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default User;
|
48
src/models/UserSession.ts
Normal file
48
src/models/UserSession.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
import { Optional } from '~/types';
|
||||||
|
|
||||||
|
class UserSession {
|
||||||
|
public id: string;
|
||||||
|
public userId: number;
|
||||||
|
public expiresAt: Optional<Date>;
|
||||||
|
public createdAt: Optional<Date>;
|
||||||
|
public updatedAt: Optional<Date>;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
expiresAt = null,
|
||||||
|
createdAt = null,
|
||||||
|
updatedAt = null,
|
||||||
|
}: any) {
|
||||||
|
this.id = id;
|
||||||
|
this.userId = userId;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
|
||||||
|
['expiresAt', 'createdAt', 'updatedAt'].forEach((key) => {
|
||||||
|
if ((this as any)[key] && !((this as any)[key] instanceof Date)) {
|
||||||
|
(this as any)[key] = new Date((this as any)[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getToken() {
|
||||||
|
return jwt.sign({
|
||||||
|
sessionId: this.id,
|
||||||
|
userId: this.userId,
|
||||||
|
}, $secrets.serverKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
return $db.UserSession().destroy({
|
||||||
|
where: {
|
||||||
|
id: this.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserSession;
|
|
@ -1,5 +1,13 @@
|
||||||
import GPSPoint from "./GPSPoint";
|
import GPSPoint from "./GPSPoint";
|
||||||
|
import Role from "./Role";
|
||||||
|
import RoleName from "./RoleName";
|
||||||
|
import User from "./User";
|
||||||
|
import UserSession from "./UserSession";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
GPSPoint,
|
GPSPoint,
|
||||||
|
Role,
|
||||||
|
RoleName,
|
||||||
|
User,
|
||||||
|
UserSession,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,19 +1,12 @@
|
||||||
import { Db } from '~/db';
|
|
||||||
import { GPSPoint } from '../models';
|
import { GPSPoint } from '../models';
|
||||||
import { LocationRequest } from '../requests';
|
import { LocationRequest } from '../requests';
|
||||||
|
|
||||||
class LocationRepository {
|
class Location {
|
||||||
private db: Db;
|
|
||||||
|
|
||||||
constructor(db: Db) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistory(query: LocationRequest): Promise<GPSPoint[]> {
|
public async getHistory(query: LocationRequest): Promise<GPSPoint[]> {
|
||||||
let apiResponse: any[] = [];
|
let apiResponse: any[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
apiResponse = await this.db.GPSData().findAll(query.toMap(this.db));
|
apiResponse = await $db.GPSData().findAll(query.toMap($db));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Error fetching data: ${error}`);
|
throw new Error(`Error fetching data: ${error}`);
|
||||||
}
|
}
|
||||||
|
@ -21,7 +14,7 @@ class LocationRepository {
|
||||||
try {
|
try {
|
||||||
return apiResponse.map((p) => {
|
return apiResponse.map((p) => {
|
||||||
const data = p.dataValues;
|
const data = p.dataValues;
|
||||||
const mappings: any = this.db.locationTableColumns;
|
const mappings: any = $db.locationTableColumns;
|
||||||
|
|
||||||
return new GPSPoint({
|
return new GPSPoint({
|
||||||
id: data[mappings.id],
|
id: data[mappings.id],
|
||||||
|
@ -41,4 +34,4 @@ class LocationRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LocationRepository;
|
export default Location;
|
13
src/repos/UserRoles.ts
Normal file
13
src/repos/UserRoles.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Role, RoleName } from '../models';
|
||||||
|
|
||||||
|
class UserRoles {
|
||||||
|
private static userRoleNames: string[] = Object.values(RoleName);
|
||||||
|
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
UserRoles.userRoleNames.forEach(async (name) => {
|
||||||
|
new Role({ name }).save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserRoles;
|
58
src/repos/UserSessions.ts
Normal file
58
src/repos/UserSessions.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
import { Optional } from '~/types';
|
||||||
|
import { Unauthorized } from '../errors';
|
||||||
|
import { UserSession } from '../models';
|
||||||
|
|
||||||
|
class UserSessions {
|
||||||
|
public async find(sessionId: string): Promise<Optional<UserSession>> {
|
||||||
|
const dbSession = await $db.UserSession().findByPk(sessionId)
|
||||||
|
if (!dbSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new UserSession(dbSession.dataValues);
|
||||||
|
if (session.expiresAt && session.expiresAt < new Date()) {
|
||||||
|
await session.destroy();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(userId: number, expiresAt: Optional<Date> = null): Promise<UserSession> {
|
||||||
|
const session = await $db.UserSession().create({
|
||||||
|
userId,
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new UserSession(session.dataValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async byToken(token: string): Promise<Optional<UserSession>> {
|
||||||
|
let payload: Record<string, any>
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, $secrets.serverKey) as {
|
||||||
|
sessionId: string, userId: number
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Unauthorized('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.find(payload.sessionId);
|
||||||
|
let expiresAt = session?.expiresAt;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
session.userId !== payload.userId ||
|
||||||
|
(expiresAt && expiresAt < new Date())
|
||||||
|
) {
|
||||||
|
throw new Unauthorized('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserSessions;
|
139
src/repos/Users.ts
Normal file
139
src/repos/Users.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
import { Optional } from '~/types';
|
||||||
|
import { RoleName } from '../models';
|
||||||
|
import { User } from '../models';
|
||||||
|
import { ValidationError } from '../errors';
|
||||||
|
|
||||||
|
class Users {
|
||||||
|
public async syncAdminUser(): Promise<User> {
|
||||||
|
const adminPassword = $secrets.adminPassword;
|
||||||
|
let adminUser = await this.find('admin');
|
||||||
|
const adminEmail = $secrets.adminEmail;
|
||||||
|
|
||||||
|
if (adminUser) {
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (!adminUser.checkPassword(adminPassword)) {
|
||||||
|
adminUser.password = adminPassword;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.email !== adminEmail) {
|
||||||
|
adminUser.email = adminEmail;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return adminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminUser.save();
|
||||||
|
} else {
|
||||||
|
console.log('Creating admin user');
|
||||||
|
adminUser = await this.create({
|
||||||
|
username: 'admin',
|
||||||
|
email: adminEmail,
|
||||||
|
password: adminPassword,
|
||||||
|
firstName: 'Admin',
|
||||||
|
lastName: 'User',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Admin user created');
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminUser.setRoles([RoleName.Admin]);
|
||||||
|
return adminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(userId: number): Promise<Optional<User>> {
|
||||||
|
const dbUser = await $db.User().findByPk(userId);
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new User(dbUser.dataValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(username: string): Promise<Optional<User>> {
|
||||||
|
const dbUser = await $db.User().findOne({
|
||||||
|
where: {
|
||||||
|
[Op.or]: {
|
||||||
|
username,
|
||||||
|
email: username,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new User(dbUser.dataValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(username: string, {
|
||||||
|
password = null,
|
||||||
|
email = null,
|
||||||
|
firstName = null,
|
||||||
|
lastName = null,
|
||||||
|
}: any): Promise<void> {
|
||||||
|
const dbUser = await this.find(username);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
console.log(`User ${username} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password?.length) {
|
||||||
|
console.log(`Updating password for ${username}`);
|
||||||
|
dbUser.password = password;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email?.length) {
|
||||||
|
if (!email.includes('@')) {
|
||||||
|
throw new ValidationError('Invalid email');
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser.email = email;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstName?.length) {
|
||||||
|
dbUser.firstName = firstName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastName?.length) {
|
||||||
|
dbUser.lastName = lastName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await dbUser.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName = null,
|
||||||
|
lastName = null,
|
||||||
|
}: any): Promise<User> {
|
||||||
|
const dbUser = await $db.User().create({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password: User.hashPassword(password),
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new User(dbUser.dataValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Users;
|
|
@ -1,11 +1,19 @@
|
||||||
import { Db } from 'src/db';
|
import Location from './Location';
|
||||||
import LocationRepository from './LocationRepository';
|
import Users from './Users';
|
||||||
|
import UserRoles from './UserRoles';
|
||||||
|
import UserSessions from './UserSessions';
|
||||||
|
|
||||||
class Repositories {
|
class Repositories {
|
||||||
public location: LocationRepository;
|
public location: Location;
|
||||||
|
public users: Users;
|
||||||
|
public userRoles: UserRoles;
|
||||||
|
public userSessions: UserSessions;
|
||||||
|
|
||||||
constructor(db: Db) {
|
constructor() {
|
||||||
this.location = new LocationRepository(db);
|
this.location = new Location();
|
||||||
|
this.users = new Users();
|
||||||
|
this.userRoles = new UserRoles();
|
||||||
|
this.userSessions = new UserSessions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Op } from 'sequelize';
|
||||||
|
|
||||||
import { Optional } from 'src/types';
|
import { Optional } from 'src/types';
|
||||||
import { Db } from 'src/db';
|
import { Db } from 'src/db';
|
||||||
|
import { ValidationError } from '../errors';
|
||||||
|
|
||||||
class LocationRequest {
|
class LocationRequest {
|
||||||
limit: Optional<number> = 250;
|
limit: Optional<number> = 250;
|
||||||
|
@ -34,7 +35,7 @@ class LocationRequest {
|
||||||
if (req[key] != null) {
|
if (req[key] != null) {
|
||||||
const numValue = (this as any)[key] = parseInt(req[key]);
|
const numValue = (this as any)[key] = parseInt(req[key]);
|
||||||
if (isNaN(numValue)) {
|
if (isNaN(numValue)) {
|
||||||
throw new TypeError(`Invalid value for ${key}: ${req[key]}`);
|
throw new ValidationError(`Invalid value for ${key}: ${req[key]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,7 +45,7 @@ class LocationRequest {
|
||||||
const numValue = (this as any)[key] = parseInt(req[key]);
|
const numValue = (this as any)[key] = parseInt(req[key]);
|
||||||
const dateValue = (this as any)[key] = new Date(isNaN(numValue) ? req[key] : numValue);
|
const dateValue = (this as any)[key] = new Date(isNaN(numValue) ? req[key] : numValue);
|
||||||
if (isNaN(dateValue.getTime())) {
|
if (isNaN(dateValue.getTime())) {
|
||||||
throw new TypeError(`Invalid value for ${key}: ${req[key]}`);
|
throw new ValidationError(`Invalid value for ${key}: ${req[key]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,20 @@
|
||||||
import { Express, Request, Response } from 'express';
|
import { Express, Request, Response } from 'express';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BadRequest,
|
||||||
|
Forbidden,
|
||||||
|
NotFound,
|
||||||
|
Unauthorized,
|
||||||
|
} from '../errors';
|
||||||
|
|
||||||
|
import { AuthInfo } from '../auth';
|
||||||
|
import { Optional, RequestHandler } from '../types';
|
||||||
import { logRequest } from '../helpers/logging';
|
import { logRequest } from '../helpers/logging';
|
||||||
|
|
||||||
abstract class Route {
|
abstract class Route {
|
||||||
protected readonly path: string;
|
protected readonly path: string;
|
||||||
|
// Method -> Handler mapping
|
||||||
|
public static preRequestHandlers: Record<string, RequestHandler> = {};
|
||||||
|
|
||||||
constructor(path: string) {
|
constructor(path: string) {
|
||||||
this.path = path;
|
this.path = path;
|
||||||
|
@ -13,52 +24,99 @@ abstract class Route {
|
||||||
res.status(405).send('Method Not Allowed');
|
res.status(405).send('Method Not Allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
protected static ServerError(req: Request, res: Response, error: Error) {
|
||||||
logRequest(req);
|
console.error(`Unhandled error in ${req.method} ${req.path}: ${error}`);
|
||||||
return await this.get(req, res);
|
res.status(500).send('Internal Server Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
private postRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
protected static handleError(req: Request, res: Response, error: Error) {
|
||||||
logRequest(req);
|
if (error instanceof Unauthorized) {
|
||||||
return await this.post(req, res);
|
const redirect = req.query?.redirect || '/';
|
||||||
|
res.redirect(`/login?redirect=${redirect}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Forbidden) {
|
||||||
|
res.status(403).send(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NotFound) {
|
||||||
|
res.status(404).send(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof BadRequest) {
|
||||||
|
res.status(400).send(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route.ServerError(req, res, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
private putRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
private handleRequest = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
handlerName: string,
|
||||||
|
) => {
|
||||||
logRequest(req);
|
logRequest(req);
|
||||||
return await this.put(req, res);
|
// @ts-expect-error
|
||||||
|
const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>);
|
||||||
|
const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let authInfo: Optional<AuthInfo>
|
||||||
|
if (preRequestHandler) {
|
||||||
|
authInfo = await preRequestHandler(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(req, res, authInfo!);
|
||||||
|
} catch (error) {
|
||||||
|
(<typeof Route> this.constructor).handleError(req, res, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
private getRoute = async (req: Request, res: Response) => {
|
||||||
logRequest(req);
|
await this.handleRequest(req, res, 'get');
|
||||||
return await this.delete(req, res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private patchRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
private postRoute = async (req: Request, res: Response) => {
|
||||||
logRequest(req);
|
await this.handleRequest(req, res, 'post');
|
||||||
return await this.patch(req, res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get: (req: Request, res: Response) => Promise<void> = async (_, res) => {
|
private putRoute = async (req: Request, res: Response) => {
|
||||||
|
await this.handleRequest(req, res, 'put');
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteRoute = async (req: Request, res: Response) => {
|
||||||
|
await this.handleRequest(req, res, 'delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchRoute = async (req: Request, res: Response) => {
|
||||||
|
await this.handleRequest(req, res, 'patch');
|
||||||
|
}
|
||||||
|
|
||||||
|
public get = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
|
||||||
Route.NotAllowed(res);
|
Route.NotAllowed(res);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public post: (req: Request, res: Response) => Promise<void> = async (_, res) => {
|
public post = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
|
||||||
Route.NotAllowed(res);
|
Route.NotAllowed(res);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public put: (req: Request, res: Response) => Promise<void> = async (_, res) => {
|
public put = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
|
||||||
Route.NotAllowed(res);
|
Route.NotAllowed(res);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public delete: (req: Request, res: Response) => Promise<void> = async (_, res) => {
|
public delete = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
|
||||||
Route.NotAllowed(res);
|
Route.NotAllowed(res);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public patch: (req: Request, res: Response) => Promise<void> = async (_, res) => {
|
public patch = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => {
|
||||||
Route.NotAllowed(res);
|
Route.NotAllowed(res);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
import Route from '../Route';
|
import Route from '../Route';
|
||||||
|
import { Unauthorized, } from '../../errors';
|
||||||
|
|
||||||
abstract class ApiRoute extends Route {
|
abstract class ApiRoute extends Route {
|
||||||
protected version: string;
|
protected version: string;
|
||||||
|
@ -7,6 +10,16 @@ abstract class ApiRoute extends Route {
|
||||||
super(ApiRoute.toApiPath(path, version));
|
super(ApiRoute.toApiPath(path, version));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static handleError(req: Request, res: Response, error: Error) {
|
||||||
|
// Handle API unauthorized errors with a 401+JSON response instead of a redirect
|
||||||
|
if (error instanceof Unauthorized) {
|
||||||
|
res.status(401).send(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.handleError(req, res, error);
|
||||||
|
}
|
||||||
|
|
||||||
protected static toApiPath(path: string, version: string): string {
|
protected static toApiPath(path: string, version: string): string {
|
||||||
if (!path.startsWith('/')) {
|
if (!path.startsWith('/')) {
|
||||||
path = `/${path}`;
|
path = `/${path}`;
|
||||||
|
|
77
src/routes/api/v1/Auth.ts
Normal file
77
src/routes/api/v1/Auth.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { Optional } from '../../../types';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import ApiV1Route from './Route';
|
||||||
|
import { ValidationError } from '../../../errors';
|
||||||
|
import { AuthInfo, authenticate } from '../../../auth';
|
||||||
|
|
||||||
|
class Auth extends ApiV1Route {
|
||||||
|
constructor() {
|
||||||
|
super('/auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session for the user.
|
||||||
|
*
|
||||||
|
* If the user is already authenticated (either through a cookie or a token),
|
||||||
|
* the existing session will be returned. Otherwise, a new session will be created.
|
||||||
|
*
|
||||||
|
* @param req: Request - The request object.
|
||||||
|
* @param res: Response - The response object.
|
||||||
|
* @param auth: Optional<AuthInfo> - The authentication information.
|
||||||
|
*/
|
||||||
|
@authenticate()
|
||||||
|
post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||||
|
const user = auth!.user;
|
||||||
|
let session = auth!.session;
|
||||||
|
const expiresAt = req.body?.expiresAt;
|
||||||
|
let expiresAtDate: Optional<Date> = null;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
console.debug('The user already has an active session or token');
|
||||||
|
res.json({
|
||||||
|
session: {
|
||||||
|
token: session.getToken(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt) {
|
||||||
|
try {
|
||||||
|
expiresAtDate = new Date(expiresAt);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ValidationError(`Invalid expiresAt: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session = await $repos.userSessions.create(user.id, expiresAtDate);
|
||||||
|
res.setHeader('Set-Cookie', `session=${session.getToken()}; Path=/; HttpOnly; SameSite=Strict`);
|
||||||
|
res.json({
|
||||||
|
session: {
|
||||||
|
token: session.getToken(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the current session.
|
||||||
|
*
|
||||||
|
* It requires the user to be authenticated (either through a cookie or a token).
|
||||||
|
* If the user is authenticated, the session will be destroyed and the cookie will be cleared.
|
||||||
|
*/
|
||||||
|
@authenticate()
|
||||||
|
delete = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||||
|
const session = auth!.session;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
await session.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Set-Cookie', 'session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0');
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Auth;
|
|
@ -1,16 +1,15 @@
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { authenticate } from '../../../auth';
|
||||||
import { LocationRequest } from '../../../requests';
|
import { LocationRequest } from '../../../requests';
|
||||||
import LocationRepository from '~/repos/LocationRepository';
|
|
||||||
import ApiV1Route from './Route';
|
import ApiV1Route from './Route';
|
||||||
|
|
||||||
const $location: LocationRepository = globalThis.$repos.location;
|
|
||||||
|
|
||||||
class GPSData extends ApiV1Route {
|
class GPSData extends ApiV1Route {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('/gpsdata');
|
super('/gpsdata');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@authenticate()
|
||||||
get = async (req: Request, res: Response) => {
|
get = async (req: Request, res: Response) => {
|
||||||
let query: LocationRequest
|
let query: LocationRequest
|
||||||
|
|
||||||
|
@ -24,7 +23,7 @@ class GPSData extends ApiV1Route {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gpsData = await $location.getHistory(query);
|
const gpsData = await $repos.location.getHistory(query);
|
||||||
res.json(gpsData);
|
res.json(gpsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const e = `Error fetching data: ${error}`;
|
const e = `Error fetching data: ${error}`;
|
||||||
|
|
30
src/routes/api/v1/UserSelf.ts
Normal file
30
src/routes/api/v1/UserSelf.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Optional } from '../../../types';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import ApiV1Route from './Route';
|
||||||
|
import { AuthInfo, authenticate } from '../../../auth';
|
||||||
|
import { maskPassword } from '../../../helpers/security';
|
||||||
|
|
||||||
|
class UserSelf extends ApiV1Route {
|
||||||
|
constructor() {
|
||||||
|
super('/users/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/me
|
||||||
|
*
|
||||||
|
* It returns a JSON object with the user and session information.
|
||||||
|
*/
|
||||||
|
@authenticate()
|
||||||
|
get = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||||
|
const user = auth!.user;
|
||||||
|
const session = auth!.session;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: maskPassword(user),
|
||||||
|
...(session ? { session } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserSelf;
|
|
@ -1,8 +1,14 @@
|
||||||
|
import Auth from "./Auth";
|
||||||
import GPSData from "./GPSData";
|
import GPSData from "./GPSData";
|
||||||
import Routes from "../../Routes";
|
import Routes from "../../Routes";
|
||||||
|
import UserSelf from "./UserSelf";
|
||||||
|
|
||||||
class ApiV1Routes extends Routes {
|
class ApiV1Routes extends Routes {
|
||||||
public routes = [new GPSData()];
|
public routes = [
|
||||||
|
new Auth(),
|
||||||
|
new GPSData(),
|
||||||
|
new UserSelf(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ApiV1Routes;
|
export default ApiV1Routes;
|
||||||
|
|
12
src/types.ts
12
src/types.ts
|
@ -1,3 +1,11 @@
|
||||||
type Optional<T> = T | null | undefined;
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
export { Optional };
|
import { AuthInfo } from './auth';
|
||||||
|
|
||||||
|
type Optional<T> = T | null | undefined;
|
||||||
|
type RequestHandler = (req: Request, res: Response) => Promise<AuthInfo>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Optional,
|
||||||
|
RequestHandler,
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue