Support for users and authentication [backend].

This commit is contained in:
Fabio Manganiello 2025-03-04 21:29:05 +01:00
parent 533ebe960f
commit c4e0c67e34
Signed by: blacklight
GPG key ID: D90FBA7F76362774
35 changed files with 1329 additions and 111 deletions

View file

@ -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
View file

@ -25,3 +25,8 @@ coverage
*.sw?
*.vim
*.tsbuildinfo
# Database files
*.db
*.sqlite
*.sqlite3

188
package-lock.json generated
View file

@ -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": {

View file

@ -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
View 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
View 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
View 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 };

View file

@ -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
View 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
View 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
View 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;

View 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
View 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,
};

View file

@ -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
View 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
View file

@ -0,0 +1,11 @@
function maskPassword(obj: Record<string, any>): Record<string, any> {
if (obj.password) {
delete obj.password;
}
return obj;
}
export {
maskPassword,
};

View file

@ -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
View 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
View file

@ -0,0 +1,6 @@
enum RoleName {
Admin = 'admin',
User = 'user',
}
export default RoleName;

113
src/models/User.ts Normal file
View 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
View 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;

View file

@ -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,
};

View file

@ -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
View 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
View 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
View 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;

View file

@ -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();
}
}

View file

@ -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]}`);
}
}
}

View file

@ -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();
}

View file

@ -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
View 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;

View file

@ -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}`;

View 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;

View file

@ -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;

View file

@ -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,
};