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 }) {
await query.context.createTable($db.tableName('user_devices'), {
id: {
type: DataTypes.INTEGER,
type: DataTypes.UUID,
primaryKey: true,
autoIncrement: true
defaultValue: () => uuidv4(),
},
userId: {
type: DataTypes.INTEGER,

View file

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

View file

@ -4,6 +4,7 @@ import { Op } from 'sequelize';
import { Optional } from '~/types';
import Role from './Role';
import RoleName from './RoleName';
import UserDevice from './UserDevice';
class User {
public id: number;
@ -50,6 +51,16 @@ class User {
).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> {
const inputRoleIds = new Set(
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 {
public id: string;
public userId: number;
public name?: Optional<string>;
public expiresAt: Optional<Date>;
public createdAt: Optional<Date>;
constructor({
id,
userId,
name,
expiresAt = null,
createdAt = null,
}: any) {
this.id = id;
this.userId = userId;
this.name = name;
this.expiresAt = expiresAt;
this.createdAt = createdAt;

View file

@ -2,6 +2,7 @@ import GPSPoint from "./GPSPoint";
import Role from "./Role";
import RoleName from "./RoleName";
import User from "./User";
import UserDevice from "./UserDevice";
import UserSession from "./UserSession";
export {
@ -9,5 +10,6 @@ export {
Role,
RoleName,
User,
UserDevice,
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 Users from './Users';
import UserDevices from './UserDevices';
import UserRoles from './UserRoles';
import UserSessions from './UserSessions';
class Repositories {
public location: Location;
public users: Users;
public userDevices: UserDevices;
public userRoles: UserRoles;
public userSessions: UserSessions;
constructor() {
this.location = new Location();
this.users = new Users();
this.userDevices = new UserDevices();
this.userRoles = new UserRoles();
this.userSessions = new UserSessions();
}

View file

@ -1,4 +1,5 @@
import { Express, Request, Response } from 'express';
import { UniqueConstraintError, ValidationError } from 'sequelize';
import {
BadRequest,
@ -50,7 +51,14 @@ abstract class Route {
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({
error: error.message,
});
@ -66,17 +74,18 @@ abstract class Route {
handlerName: string,
) => {
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 {
// @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>
if (preRequestHandler) {
authInfo = await preRequestHandler(req, res);
}
handler(req, res, authInfo!);
await handler(req, res, authInfo!);
} catch (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 Devices from "./Devices";
import DevicesById from "./DevicesById";
import GPSData from "./GPSData";
import Routes from "../../Routes";
import UserSelf from "./UserSelf";
@ -6,6 +8,8 @@ import UserSelf from "./UserSelf";
class ApiV1Routes extends Routes {
public routes = [
new Auth(),
new Devices(),
new DevicesById(),
new GPSData(),
new UserSelf(),
];