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
|
||||
|
||||
# Application database URL (required)
|
||||
# Postgres:
|
||||
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,
|
||||
# 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)
|
||||
# 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)
|
||||
DB_LOCATION_TABLE=gpsdata
|
||||
DB_LOCATION_TABLE=location_history
|
||||
|
||||
## Database mappings
|
||||
# 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?
|
||||
*.vim
|
||||
*.tsbuildinfo
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
|
188
package-lock.json
generated
188
package-lock.json
generated
|
@ -7,19 +7,23 @@
|
|||
"": {
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"body-parser": "^1.20.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"passport": "^0.7.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.13.3",
|
||||
"sequelize": "^6.37.5",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22.13.4",
|
||||
"nodemon": "^3.1.9",
|
||||
"typescript": "^5.7.3"
|
||||
|
@ -141,6 +145,17 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
|
@ -370,6 +385,15 @@
|
|||
],
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
@ -475,6 +499,12 @@
|
|||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
|
@ -760,6 +790,15 @@
|
|||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
|
@ -1505,12 +1544,103 @@
|
|||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
|
@ -2017,32 +2147,6 @@
|
|||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
|
@ -2059,11 +2163,6 @@
|
|||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"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": {
|
||||
"version": "8.13.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz",
|
||||
|
@ -2567,6 +2666,15 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
|
@ -3114,12 +3222,16 @@
|
|||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
|
|
10
package.json
10
package.json
|
@ -21,20 +21,24 @@
|
|||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"license": "GPL-3.0",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"body-parser": "^1.20.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"passport": "^0.7.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.13.3",
|
||||
"sequelize": "^6.37.5",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22.13.4",
|
||||
"nodemon": "^3.1.9",
|
||||
"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 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 {
|
||||
private readonly url: string;
|
||||
|
@ -9,6 +13,7 @@ class Db {
|
|||
public readonly locationTableColumns: Record<string, string>;
|
||||
private readonly dialect: Dialect;
|
||||
private readonly locationDialect: Dialect;
|
||||
private readonly tablePrefix: string;
|
||||
private readonly appDb: Sequelize;
|
||||
private readonly locationDb: Sequelize;
|
||||
|
||||
|
@ -18,18 +23,20 @@ class Db {
|
|||
opts: {
|
||||
url: string,
|
||||
locationUrl: string,
|
||||
locationTable: string,
|
||||
locationTable?: string | null,
|
||||
locationTableColumns: Record<string, string>,
|
||||
dialect: Dialect,
|
||||
locationDialect: Dialect | null,
|
||||
tablePrefix?: string | null,
|
||||
}
|
||||
) {
|
||||
this.url = opts.url;
|
||||
this.locationUrl = opts.locationUrl;
|
||||
this.locationTable = opts.locationTable;
|
||||
this.locationTableColumns = opts.locationTableColumns
|
||||
this.dialect = opts.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, {
|
||||
dialect: this.dialect,
|
||||
|
@ -53,6 +60,7 @@ class Db {
|
|||
opts.locationTable = process.env.DB_LOCATION_TABLE;
|
||||
opts.dialect = process.env.DB_DIALECT || opts.url.split(':')[0];
|
||||
opts.locationDialect = process.env.DB_LOCATION_DIALECT || opts.locationUrl.split(':')[0];
|
||||
opts.tablePrefix = process.env.DB_TABLE_PREFIX;
|
||||
|
||||
if (!opts.url?.length) {
|
||||
console.error('No DB_URL provided');
|
||||
|
@ -81,7 +89,7 @@ class Db {
|
|||
'country',
|
||||
'postal_code'
|
||||
].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]) {
|
||||
// Default to the name of the required field
|
||||
acc[name] = name;
|
||||
|
@ -93,10 +101,66 @@ class Db {
|
|||
return new Db(opts);
|
||||
}
|
||||
|
||||
private static prefixed(name: string): string {
|
||||
private static prefixedEnv(name: string): string {
|
||||
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
|
||||
*/
|
||||
|
@ -104,9 +168,50 @@ class Db {
|
|||
public GPSData() {
|
||||
return this.locationDb.define('GPSData', GPSData(this.locationTableColumns), {
|
||||
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;
|
||||
|
|
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 { Db } from './db';
|
||||
import Secrets from './Secrets';
|
||||
import Repositories from './repos';
|
||||
|
||||
dotenv.config();
|
||||
|
@ -8,9 +9,11 @@ dotenv.config();
|
|||
declare global {
|
||||
var $db: Db;
|
||||
var $repos: Repositories;
|
||||
var $secrets: Secrets;
|
||||
}
|
||||
|
||||
export function useGlobals() {
|
||||
globalThis.$secrets = Secrets.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 express from 'express';
|
||||
import App from './App';
|
||||
|
||||
import { useGlobals } from './globals';
|
||||
useGlobals();
|
||||
|
||||
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}`);
|
||||
});
|
||||
const app = App.fromEnv();
|
||||
app.listen();
|
||||
|
|
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 Role from "./Role";
|
||||
import RoleName from "./RoleName";
|
||||
import User from "./User";
|
||||
import UserSession from "./UserSession";
|
||||
|
||||
export {
|
||||
GPSPoint,
|
||||
Role,
|
||||
RoleName,
|
||||
User,
|
||||
UserSession,
|
||||
};
|
||||
|
|
|
@ -1,19 +1,12 @@
|
|||
import { Db } from '~/db';
|
||||
import { GPSPoint } from '../models';
|
||||
import { LocationRequest } from '../requests';
|
||||
|
||||
class LocationRepository {
|
||||
private db: Db;
|
||||
|
||||
constructor(db: Db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
class Location {
|
||||
public async getHistory(query: LocationRequest): Promise<GPSPoint[]> {
|
||||
let apiResponse: any[] = [];
|
||||
|
||||
try {
|
||||
apiResponse = await this.db.GPSData().findAll(query.toMap(this.db));
|
||||
apiResponse = await $db.GPSData().findAll(query.toMap($db));
|
||||
} catch (error) {
|
||||
throw new Error(`Error fetching data: ${error}`);
|
||||
}
|
||||
|
@ -21,7 +14,7 @@ class LocationRepository {
|
|||
try {
|
||||
return apiResponse.map((p) => {
|
||||
const data = p.dataValues;
|
||||
const mappings: any = this.db.locationTableColumns;
|
||||
const mappings: any = $db.locationTableColumns;
|
||||
|
||||
return new GPSPoint({
|
||||
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 LocationRepository from './LocationRepository';
|
||||
import Location from './Location';
|
||||
import Users from './Users';
|
||||
import UserRoles from './UserRoles';
|
||||
import UserSessions from './UserSessions';
|
||||
|
||||
class Repositories {
|
||||
public location: LocationRepository;
|
||||
public location: Location;
|
||||
public users: Users;
|
||||
public userRoles: UserRoles;
|
||||
public userSessions: UserSessions;
|
||||
|
||||
constructor(db: Db) {
|
||||
this.location = new LocationRepository(db);
|
||||
constructor() {
|
||||
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 { Db } from 'src/db';
|
||||
import { ValidationError } from '../errors';
|
||||
|
||||
class LocationRequest {
|
||||
limit: Optional<number> = 250;
|
||||
|
@ -34,7 +35,7 @@ class LocationRequest {
|
|||
if (req[key] != null) {
|
||||
const numValue = (this as any)[key] = parseInt(req[key]);
|
||||
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 dateValue = (this as any)[key] = new Date(isNaN(numValue) ? req[key] : numValue);
|
||||
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 {
|
||||
BadRequest,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
Unauthorized,
|
||||
} from '../errors';
|
||||
|
||||
import { AuthInfo } from '../auth';
|
||||
import { Optional, RequestHandler } from '../types';
|
||||
import { logRequest } from '../helpers/logging';
|
||||
|
||||
abstract class Route {
|
||||
protected readonly path: string;
|
||||
// Method -> Handler mapping
|
||||
public static preRequestHandlers: Record<string, RequestHandler> = {};
|
||||
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
|
@ -13,52 +24,99 @@ abstract class Route {
|
|||
res.status(405).send('Method Not Allowed');
|
||||
}
|
||||
|
||||
private getRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
||||
logRequest(req);
|
||||
return await this.get(req, res);
|
||||
protected static ServerError(req: Request, res: Response, error: Error) {
|
||||
console.error(`Unhandled error in ${req.method} ${req.path}: ${error}`);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
|
||||
private postRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
||||
logRequest(req);
|
||||
return await this.post(req, res);
|
||||
protected static handleError(req: Request, res: Response, error: Error) {
|
||||
if (error instanceof Unauthorized) {
|
||||
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);
|
||||
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) => {
|
||||
logRequest(req);
|
||||
return await this.delete(req, res);
|
||||
private getRoute = async (req: Request, res: Response) => {
|
||||
await this.handleRequest(req, res, 'get');
|
||||
}
|
||||
|
||||
private patchRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
|
||||
logRequest(req);
|
||||
return await this.patch(req, res);
|
||||
private postRoute = async (req: Request, res: Response) => {
|
||||
await this.handleRequest(req, res, 'post');
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { Request, Response } from 'express';
|
||||
|
||||
import Route from '../Route';
|
||||
import { Unauthorized, } from '../../errors';
|
||||
|
||||
abstract class ApiRoute extends Route {
|
||||
protected version: string;
|
||||
|
@ -7,6 +10,16 @@ abstract class ApiRoute extends Route {
|
|||
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 {
|
||||
if (!path.startsWith('/')) {
|
||||
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 { authenticate } from '../../../auth';
|
||||
import { LocationRequest } from '../../../requests';
|
||||
import LocationRepository from '~/repos/LocationRepository';
|
||||
import ApiV1Route from './Route';
|
||||
|
||||
const $location: LocationRepository = globalThis.$repos.location;
|
||||
|
||||
class GPSData extends ApiV1Route {
|
||||
constructor() {
|
||||
super('/gpsdata');
|
||||
}
|
||||
|
||||
@authenticate()
|
||||
get = async (req: Request, res: Response) => {
|
||||
let query: LocationRequest
|
||||
|
||||
|
@ -24,7 +23,7 @@ class GPSData extends ApiV1Route {
|
|||
}
|
||||
|
||||
try {
|
||||
const gpsData = await $location.getHistory(query);
|
||||
const gpsData = await $repos.location.getHistory(query);
|
||||
res.json(gpsData);
|
||||
} catch (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 Routes from "../../Routes";
|
||||
import UserSelf from "./UserSelf";
|
||||
|
||||
class ApiV1Routes extends Routes {
|
||||
public routes = [new GPSData()];
|
||||
public routes = [
|
||||
new Auth(),
|
||||
new GPSData(),
|
||||
new UserSelf(),
|
||||
];
|
||||
}
|
||||
|
||||
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