Backend support for user devices.

This commit is contained in:
Fabio Manganiello 2025-03-09 10:58:40 +01:00
parent 4d7f60236f
commit 3d01aed1c5
Signed by: blacklight
GPG key ID: D90FBA7F76362774
12 changed files with 248 additions and 9 deletions

View file

@ -207,9 +207,9 @@ async function createUserSessionsTable(query: { context: any }) {
async function createUserDevicesTable(query: { context: any }) { async function createUserDevicesTable(query: { context: any }) {
await query.context.createTable($db.tableName('user_devices'), { await query.context.createTable($db.tableName('user_devices'), {
id: { id: {
type: DataTypes.INTEGER, type: DataTypes.UUID,
primaryKey: true, primaryKey: true,
autoIncrement: true defaultValue: () => uuidv4(),
}, },
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,

View file

@ -1,11 +1,12 @@
import { DataTypes, } from 'sequelize'; import { DataTypes, } from 'sequelize';
import { v4 as uuidv4 } from 'uuid';
function UserDevice(): Record<string, any> { function UserDevice(): Record<string, any> {
return { return {
id: { id: {
type: DataTypes.INTEGER, type: DataTypes.UUID,
primaryKey: true, primaryKey: true,
autoIncrement: true defaultValue: () => uuidv4(),
}, },
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,

View file

@ -4,6 +4,7 @@ import { Op } from 'sequelize';
import { Optional } from '~/types'; import { Optional } from '~/types';
import Role from './Role'; import Role from './Role';
import RoleName from './RoleName'; import RoleName from './RoleName';
import UserDevice from './UserDevice';
class User { class User {
public id: number; public id: number;
@ -50,6 +51,16 @@ class User {
).map((role) => new Role(role.dataValues)); ).map((role) => new Role(role.dataValues));
} }
public async devices(): Promise<UserDevice[]> {
return (
await $db.UserDevice().findAll({
where: {
userId: this.id,
},
})
).map((device) => new UserDevice(device.dataValues));
}
public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> { public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> {
const inputRoleIds = new Set( const inputRoleIds = new Set(
roles.filter((role) => { roles.filter((role) => {

48
src/models/UserDevice.ts Normal file
View file

@ -0,0 +1,48 @@
import { Optional } from '~/types';
class UserDevice {
public id: string;
public userId: number;
public name: string;
public createdAt: Optional<Date>;
constructor({
id,
userId,
name,
createdAt = null,
}: {
id: string;
userId: number;
name: string;
createdAt?: Optional<Date>;
}) {
this.id = id;
this.name = name;
this.userId = userId;
this.createdAt = createdAt ? new Date(createdAt) : null;
}
public async destroy() {
await $db.UserDevice().destroy({
where: {
id: this.id,
},
});
}
public async save() {
await $db.UserDevice().update(
{
name: this.name,
},
{
where: {
id: this.id,
},
}
);
}
}
export default UserDevice;

View file

@ -5,17 +5,20 @@ import { Optional } from '~/types';
class UserSession { class UserSession {
public id: string; public id: string;
public userId: number; public userId: number;
public name?: Optional<string>;
public expiresAt: Optional<Date>; public expiresAt: Optional<Date>;
public createdAt: Optional<Date>; public createdAt: Optional<Date>;
constructor({ constructor({
id, id,
userId, userId,
name,
expiresAt = null, expiresAt = null,
createdAt = null, createdAt = null,
}: any) { }: any) {
this.id = id; this.id = id;
this.userId = userId; this.userId = userId;
this.name = name;
this.expiresAt = expiresAt; this.expiresAt = expiresAt;
this.createdAt = createdAt; this.createdAt = createdAt;

View file

@ -2,6 +2,7 @@ import GPSPoint from "./GPSPoint";
import Role from "./Role"; import Role from "./Role";
import RoleName from "./RoleName"; import RoleName from "./RoleName";
import User from "./User"; import User from "./User";
import UserDevice from "./UserDevice";
import UserSession from "./UserSession"; import UserSession from "./UserSession";
export { export {
@ -9,5 +10,6 @@ export {
Role, Role,
RoleName, RoleName,
User, User,
UserDevice,
UserSession, UserSession,
}; };

32
src/repos/UserDevices.ts Normal file
View file

@ -0,0 +1,32 @@
import { Optional } from '~/types';
import { UserDevice } from '../models';
class UserDevices {
public async get(deviceId: string): Promise<Optional<UserDevice>> {
let dbDevice = await $db.UserDevice().findByPk(deviceId)
if (!dbDevice) {
dbDevice = await $db.UserDevice().findOne({
where: { name: deviceId }
});
if (!dbDevice) {
return null;
}
}
return new UserDevice(dbDevice.dataValues);
}
public async create(name: string, args: {
userId: number,
}): Promise<UserDevice> {
const device = await $db.UserDevice().create({
userId: args.userId,
name: name,
});
return new UserDevice(device.dataValues);
}
}
export default UserDevices;

View file

@ -1,17 +1,20 @@
import Location from './Location'; import Location from './Location';
import Users from './Users'; import Users from './Users';
import UserDevices from './UserDevices';
import UserRoles from './UserRoles'; import UserRoles from './UserRoles';
import UserSessions from './UserSessions'; import UserSessions from './UserSessions';
class Repositories { class Repositories {
public location: Location; public location: Location;
public users: Users; public users: Users;
public userDevices: UserDevices;
public userRoles: UserRoles; public userRoles: UserRoles;
public userSessions: UserSessions; public userSessions: UserSessions;
constructor() { constructor() {
this.location = new Location(); this.location = new Location();
this.users = new Users(); this.users = new Users();
this.userDevices = new UserDevices();
this.userRoles = new UserRoles(); this.userRoles = new UserRoles();
this.userSessions = new UserSessions(); this.userSessions = new UserSessions();
} }

View file

@ -1,4 +1,5 @@
import { Express, Request, Response } from 'express'; import { Express, Request, Response } from 'express';
import { UniqueConstraintError, ValidationError } from 'sequelize';
import { import {
BadRequest, BadRequest,
@ -50,7 +51,14 @@ abstract class Route {
return; return;
} }
if (error instanceof BadRequest) { if (error instanceof UniqueConstraintError) {
res.status(400).json({
error: `A record with the same [${Object.values(error.fields).join(', ')}] already exists`,
});
return;
}
if (error instanceof BadRequest || error instanceof ValidationError) {
res.status(400).json({ res.status(400).json({
error: error.message, error: error.message,
}); });
@ -66,17 +74,18 @@ abstract class Route {
handlerName: string, handlerName: string,
) => { ) => {
logRequest(req); logRequest(req);
// @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 { try {
// @ts-expect-error
const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>);
const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName];
let authInfo: Optional<AuthInfo> let authInfo: Optional<AuthInfo>
if (preRequestHandler) { if (preRequestHandler) {
authInfo = await preRequestHandler(req, res); authInfo = await preRequestHandler(req, res);
} }
handler(req, res, authInfo!); await handler(req, res, authInfo!);
} catch (error) { } catch (error) {
(<typeof Route> this.constructor).handleError(req, res, error); (<typeof Route> this.constructor).handleError(req, res, error);
} }

View file

@ -0,0 +1,47 @@
import { Optional } from '../../../types';
import { Request, Response } from 'express';
import ApiV1Route from './Route';
import { AuthInfo, authenticate } from '../../../auth';
import { BadRequest } from '../../../errors';
class Devices extends ApiV1Route {
constructor() {
super('/devices');
}
/**
* GET /devices
*
* It returns a JSON object of devices associated with the authenticated user,
* in the format `{ devices: UserDevice[] }`.
*/
@authenticate()
get = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
res.json({
devices: (await auth!.user.devices()),
});
}
/**
* POST /devices
*
* It creates a new device associated with the authenticated user.
* It expects a JSON object with the following properties:
* - `name` (string): The name of the device.
* It must be unique for the user.
*/
@authenticate()
post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
const { name } = req.body;
if (!name) {
throw new BadRequest('Missing name');
}
const device = await $repos.userDevices.create(name, { userId: auth!.user.id });
res.json(device);
}
}
export default Devices;

View file

@ -0,0 +1,79 @@
import { Optional } from '../../../types';
import { Request, Response } from 'express';
import ApiV1Route from './Route';
import { AuthInfo, authenticate } from '../../../auth';
import { Forbidden, Unauthorized } from '../../../errors';
import { RoleName, UserDevice } from '../../../models';
class DevicesById extends ApiV1Route {
constructor() {
super('/devices/:deviceId');
}
private async authenticatedFetch(auth: AuthInfo, deviceId: string): Promise<UserDevice> {
const device = await $repos.userDevices.get(deviceId);
if (!device) {
// Throw a 403 instead of a 404 to avoid leaking information
throw new Forbidden('You do not have access to this device');
}
if (device.userId !== auth!.user.id) {
try {
authenticate([RoleName.Admin]);
} catch (e) {
throw new Unauthorized('You do not have access to this device');
}
}
return device;
}
/**
* GET /devices/:deviceId
*
* It returns a JSON object with the requested device.
* Note that the device must be associated with the authenticated user,
* unless the user is an admin.
*/
@authenticate()
get = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
res.json(await this.authenticatedFetch(auth!, req.params.deviceId));
}
/**
* PATCH /devices/:deviceId
*
* It updates the requested device.
* Note that the device must be associated with the authenticated user,
* unless the user is an admin.
*/
@authenticate()
patch = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
const device = await this.authenticatedFetch(auth!, req.params.deviceId);
const { name } = req.body;
if (name) {
device.name = name;
}
await device.save();
res.json(device);
}
/**
* DELETE /devices/:deviceId
*
* It deletes the requested device.
* Note that the device must be associated with the authenticated user,
* unless the user is an admin.
*/
@authenticate()
delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
const device = await this.authenticatedFetch(auth!, req.params.deviceId);
await device.destroy();
res.status(204).send();
}
}
export default DevicesById;

View file

@ -1,4 +1,6 @@
import Auth from "./Auth"; import Auth from "./Auth";
import Devices from "./Devices";
import DevicesById from "./DevicesById";
import GPSData from "./GPSData"; import GPSData from "./GPSData";
import Routes from "../../Routes"; import Routes from "../../Routes";
import UserSelf from "./UserSelf"; import UserSelf from "./UserSelf";
@ -6,6 +8,8 @@ import UserSelf from "./UserSelf";
class ApiV1Routes extends Routes { class ApiV1Routes extends Routes {
public routes = [ public routes = [
new Auth(), new Auth(),
new Devices(),
new DevicesById(),
new GPSData(), new GPSData(),
new UserSelf(), new UserSelf(),
]; ];