diff --git a/server/tp4/controllers/item.js b/server/tp4/controllers/item.js new file mode 100644 index 0000000000000000000000000000000000000000..53fe260ae8a5ee37a3a762a0edfa4de3339d08d7 --- /dev/null +++ b/server/tp4/controllers/item.js @@ -0,0 +1,80 @@ +'use_strict'; + +const db = require('../models'); + +module.exports = { + + get_all: (req, res, next) => { + return db.Item.findAll() + .then((items) => res.json(items)) + .catch((err) => next(err)); + }, + + get_by_id: (req, res, next) => { + return db.Item.findByPk(req.params.item_id) + .then((item) => { + if (!item) { + throw { status: 404, message: 'Requested Item not found' }; + } + return res.json(item); + }) + .catch((err) => next(err)); + }, + + create: (req, res, next) => { + const data = { + text: req.body.text || '...' + }; + return db.Item.create(data) + .then((item) => res.json(item)) + .catch((err) => next(err)); + }, + + update_by_id: (req, res, next) => { + return db.Item.findByPk(req.params.item_id) + .then((item) => { + if (!item) { + throw { status: 404, message: 'Requested Item not found' }; + } + Object.assign(item, req.body); + return item.save(); + }) + .then((item) => res.json(item)) + .catch((err) => next(err)); + }, + + delete_by_id: (req, res, next) => { + return db.Item.findByPk(req.params.item_id) + .then((item) => { + if (!item) { + throw { status: 404, message: 'Requested Item not found' }; + } + return item.destroy(); + }) + .then(() => res.status(200).end()) + .catch((err) => next(err)); + }, + + // USER // + + is_of_user: (req, res, next) => { + return db.Item.findByPk(req.params.item_id) + .then((item) => { + if (!item) { + throw { status: 404, message: 'Requested Item not found' }; + } + return item.getList(); + }) + .then((list) => { + return list.getUser(); + }) + .then((user) => { + if (user.id !== req.user.id) { + throw { status: 401, message: 'This is not your List..' }; + } + return next(); + }) + .catch((err) => next(err)); + } + +}; diff --git a/server/tp4/controllers/list.js b/server/tp4/controllers/list.js new file mode 100644 index 0000000000000000000000000000000000000000..7245c69be80445a0c2bf562218f99b2a54605e72 --- /dev/null +++ b/server/tp4/controllers/list.js @@ -0,0 +1,119 @@ +'use_strict'; + +const db = require('../models'); + +module.exports = { + + get_all: (req, res, next) => { + return db.List.findAll() + .then((lists) => res.json(lists)) + .catch((err) => next(err)); + }, + + get_by_id: (req, res, next) => { + return db.List.findByPk(req.params.list_id) + .then((list) => { + if (!list) { + throw { status: 404, message: 'Requested List not found' }; + } + return res.json(list); + }) + .catch((err) => next(err)); + }, + + create: (req, res, next) => { + const data = { + title: req.body.title || '...' + }; + return db.List.create(data) + .then((list) => res.json(list)) + .catch((err) => next(err)); + }, + + update_by_id: (req, res, next) => { + return db.List.findByPk(req.params.list_id) + .then((list) => { + if (!list) { + throw { status: 404, message: 'Requested List not found' }; + } + Object.assign(list, req.body); + return list.save(); + }) + .then((list) => res.json(list)) + .catch((err) => next(err)); + }, + + delete_by_id: (req, res, next) => { + return db.List.findByPk(req.params.list_id) + .then((list) => { + if (!list) { + throw { status: 404, message: 'Requested List not found' }; + } + return list.destroy(); + }) + .then(() => res.status(200).end()) + .catch((err) => next(err)); + }, + + get_items_of_id: (req, res, next) => { + return db.List.findByPk(req.params.list_id) + .then((list) => { + if (!list) { + throw { status: 404, message: 'Requested List not found' }; + } + return list.getItems(); + }) + .then((items) => res.json(items)) + .catch((err) => next(err)); + }, + + add_item_to_id: (req, res, next) => { + return db.List.findByPk(req.params.list_id) + .then((list) => { + if (!list) { + throw { status: 404, message: 'Requested List not found' }; + } + const data = { + text: req.body.text || '...' + }; + return list.createItem(data); + }) + .then((item) => res.json(item)) + .catch((err) => next(err)); + }, + + // USER // + + is_of_user: (req, res, next) => { + return db.List.findByPk(req.params.list_id) + .then((list) => { + if (!list) { + throw { status: 404, message: 'Requested List not found' }; + } + return list.getUser(); + }) + .then((user) => { + if (user.id !== req.user.id) { + throw { status: 401, message: 'This is not your List..' }; + } + return next(); + }) + .catch((err) => next(err)); + }, + + get_all_of_user: (req, res, next) => { + return req.user.getLists() + .then((lists) => res.json(lists)) + .catch((err) => next(err)); + }, + + create_of_user: (req, res, next) => { + const data = { + title: req.body.title || '...' + }; + return req.user.createList(data) + .then((list) => res.json(list)) + .catch((err) => next(err)); + } + +}; diff --git a/server/tp4/controllers/user.js b/server/tp4/controllers/user.js new file mode 100644 index 0000000000000000000000000000000000000000..24bb62dac2767fbd455604e127dbf1d5f0ec29b5 --- /dev/null +++ b/server/tp4/controllers/user.js @@ -0,0 +1,111 @@ +'use_strict'; + +const + jsonwebtoken = require('jsonwebtoken'), + expressjwt = require('express-jwt'), + db = require('../models'); + +const secret = 'buiVUTY,676:88b&hj%cgF*Chi'; + +module.exports = { + + get_all: (req, res, next) => { + return db.User.findAll({ + order: ['username'] + }) + .then((users) => res.json(users)) + .catch((err) => next(err)); + }, + + get_by_id: (req, res, next) => { + return db.User.findByPk(req.params.user_id) + .then((user) => { + if (!user) { + throw { status: 404, message: 'Requested User not found' }; + } + return res.json(user); + }) + .catch((err) => next(err)); + }, + + signup: (req, res, next) => { + const data = { + username: req.body.username || '', + password: db.User.generate_hash(req.body.password) || '' + }; + return db.User.create(data) + .then((user) => res.json(user)) + .catch((err) => next(err)); + }, + + update_by_id: (req, res, next) => { + return db.User.findByPk(req.params.user_id) + .then((user) => { + if (!user) { + throw { status: 404, message: 'Requested User not found' }; + } + delete req.body.password; + Object.assign(user, req.body); + return user.save(); + }) + .then((user) => res.json(user)) + .catch((err) => next(err)); + }, + + delete_by_id: (req, res, next) => { + return db.User.findByPk(req.params.user_id) + .then((user) => { + if (!user) { + throw { status: 404, message: 'Requested User not found' }; + } + return user.destroy(); + }) + .then(() => res.status(200).end()) + .catch((err) => next(err)); + }, + + signin: (req, res, next) => { + const username = req.body.username || ''; + const password = req.body.password || ''; + + db.User.findOne({ + where: { username } + }) + .then((user) => { + if (!user) { + throw { status: 404, message: 'Requested User not found' }; + } + if (!user.check_password(password)) { + throw { status: 401, message: 'Wrong password' }; + } + + const token = jsonwebtoken.sign( + { id: user.id }, + secret, + { algorithm: 'HS256', expiresIn: 60 * 60 * 12 } + ); + + return res.json({ user, token }); + }) + .catch((err) => next(err)); + }, + + whoami: (req, res, next) => { + return res.json(req.user); + }, + + identify_client: [ + expressjwt({ secret, algorithms: ['HS256'] }), + (req, res, next) => { + db.User.findByPk(req.user.id) + .then((user) => { + if (!user) { + throw { status: 404, message: 'Requested User not found' }; + } + req.user = user; + return next(); + }); + } + ] + +}; diff --git a/server/tp4/models/index.js b/server/tp4/models/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b160847e3944f504548a4c12bafd65c9d7cad11e --- /dev/null +++ b/server/tp4/models/index.js @@ -0,0 +1,32 @@ +'use strict'; + +const + fs = require('fs'), + Sequelize = require('sequelize'); + +// create Sequelize instance +const sequelize = new Sequelize('[DBname]', '[username]', '[password]', { + host: 'localhost', + port: 3306, + dialect: 'mysql', + dialectOptions: { decimalNumbers: true } + // operatorsAliases: false + // logging: false +}); + +const db = {}; + +fs.readdirSync(__dirname) + .filter((filename) => filename !== 'index.js') + .forEach((filename) => { + const model = require('./' + filename)(sequelize, Sequelize.DataTypes); + db[model.name] = model; + }); + +Object.keys(db).forEach((modelName) => { + db[modelName].associate(db); +}); + +sequelize.sync(); + +module.exports = db; diff --git a/server/tp4/models/item.js b/server/tp4/models/item.js new file mode 100644 index 0000000000000000000000000000000000000000..976324c9f2a66c40d7f4eca8ca27e45b93a2a26c --- /dev/null +++ b/server/tp4/models/item.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Item = sequelize.define( + 'Item', + { + text: DataTypes.STRING + } + ); + + Item.associate = (db) => { + Item.belongsTo(db.List, { onDelete: 'cascade' }); + }; + + return Item; +}; diff --git a/server/tp4/models/list.js b/server/tp4/models/list.js new file mode 100644 index 0000000000000000000000000000000000000000..a7ac13f2d0494bed3f9a43ff8a5657fad0a0b192 --- /dev/null +++ b/server/tp4/models/list.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const List = sequelize.define( + 'List', + { + title: DataTypes.STRING + } + ); + + List.associate = (db) => { + List.hasMany(db.Item, { onDelete: 'cascade' }); + List.belongsTo(db.User, { onDelete: 'cascade' }); + }; + + return List; +}; diff --git a/server/tp4/models/user.js b/server/tp4/models/user.js new file mode 100644 index 0000000000000000000000000000000000000000..447c8379b26f9a028c115758de3eeff0bdd61d4d --- /dev/null +++ b/server/tp4/models/user.js @@ -0,0 +1,31 @@ +'use strict'; + +const bcrypt = require('bcrypt'); + +module.exports = (sequelize, DataTypes) => { + const User = sequelize.define( + 'User', + { + username: DataTypes.STRING, + password: DataTypes.STRING + } + ); + + User.associate = (db) => { + User.hasMany(db.List, { onDelete: 'cascade' }); + }; + + User.generate_hash = (password) => bcrypt.hashSync(password, 10); + + User.prototype.toJSON = function () { + const data = Object.assign({}, this.get()); + delete data.password; + return data; + }; + + User.prototype.check_password = function (password) { + return bcrypt.compareSync(password, this.password); + }; + + return User; +}; diff --git a/server/tp4/package.json b/server/tp4/package.json new file mode 100644 index 0000000000000000000000000000000000000000..edf71666ef979b324f1593ad586d5d3e003d20a2 --- /dev/null +++ b/server/tp4/package.json @@ -0,0 +1,22 @@ +{ + "name": "list", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "bcrypt": "^5.0.0", + "body-parser": "^1.19.0", + "cors": "^2.8.5", + "express": "^4.17.1", + "express-jwt": "^6.0.0", + "jsonwebtoken": "^8.5.1", + "mysql2": "^2.2.5", + "sequelize": "^6.3.5" + } +} diff --git a/server/tp4/routes/index.js b/server/tp4/routes/index.js new file mode 100644 index 0000000000000000000000000000000000000000..da9b75668347905acc52dc5ac1598be3a3e9e96e --- /dev/null +++ b/server/tp4/routes/index.js @@ -0,0 +1,16 @@ +'use strict'; + +const + fs = require('fs'); + +module.exports = function (app) { + + fs.readdirSync(__dirname) + .filter((filename) => filename !== 'index.js') + .forEach((filename) => { + require('./' + filename).forEach((r) => { + app[r.method](r.url, r.func); + }); + }); + +}; diff --git a/server/tp4/routes/item.js b/server/tp4/routes/item.js new file mode 100644 index 0000000000000000000000000000000000000000..736f16938f017a61a17b1cca67f9e49625f0bc6a --- /dev/null +++ b/server/tp4/routes/item.js @@ -0,0 +1,34 @@ +'use strict'; + +const + item_ctrl = require('../controllers/item'); + +module.exports = [ + + { + url: '/item', + method: 'get', + func: [item_ctrl.get_all] + }, + { + url: '/item', + method: 'post', + func: [item_ctrl.create] + }, + { + url: '/item/:item_id', + method: 'get', + func: [item_ctrl.get_by_id] + }, + { + url: '/item/:item_id', + method: 'put', + func: [item_ctrl.update_by_id] + }, + { + url: '/item/:item_id', + method: 'delete', + func: [item_ctrl.delete_by_id] + } + +]; diff --git a/server/tp4/routes/list.js b/server/tp4/routes/list.js new file mode 100644 index 0000000000000000000000000000000000000000..2ce1eb7d92a23af6341cbf7f2922f09851cb1e0c --- /dev/null +++ b/server/tp4/routes/list.js @@ -0,0 +1,45 @@ +'use strict'; + +const + list_ctrl = require('../controllers/list'), + user_ctrl = require('../controllers/user'); + +module.exports = [ + + { + url: '/list', + method: 'get', + func: [user_ctrl.identify_client, list_ctrl.get_all_of_user] + }, + { + url: '/list', + method: 'post', + func: [user_ctrl.identify_client, list_ctrl.create_of_user] + }, + { + url: '/list/:list_id', + method: 'get', + func: [user_ctrl.identify_client, list_ctrl.is_of_user, list_ctrl.get_by_id] + }, + { + url: '/list/:list_id', + method: 'put', + func: [user_ctrl.identify_client, list_ctrl.is_of_user, list_ctrl.update_by_id] + }, + { + url: '/list/:list_id', + method: 'delete', + func: [user_ctrl.identify_client, list_ctrl.is_of_user, list_ctrl.delete_by_id] + }, + { + url: '/list/:list_id/items', + method: 'get', + func: [user_ctrl.identify_client, list_ctrl.is_of_user, list_ctrl.get_items_of_id] + }, + { + url: '/list/:list_id/items', + method: 'post', + func: [user_ctrl.identify_client, list_ctrl.is_of_user, list_ctrl.add_item_to_id] + }, + +]; diff --git a/server/tp4/routes/user.js b/server/tp4/routes/user.js new file mode 100644 index 0000000000000000000000000000000000000000..545ec1706bbe0f31818ffa4f71b99937c0596907 --- /dev/null +++ b/server/tp4/routes/user.js @@ -0,0 +1,51 @@ +'use strict'; + +const user_ctrl = require('../controllers/user'); + +function client_is_user_id(req, res, next) { + if (req.user.id == req.params.user_id) { + return next(); + } else { + throw { status: 403, message: 'Action is not authorized' }; + } +} + +module.exports = [ + + { + url: '/user', + method: 'get', + func: user_ctrl.get_all + }, + { + url: '/user/signup', + method: 'post', + func: user_ctrl.signup + }, + { + url: '/user/signin', + method: 'post', + func: user_ctrl.signin + }, + { + url: '/user/whoami', + method: 'get', + func: [user_ctrl.identify_client, user_ctrl.whoami] + }, + { + url: '/user/:user_id', + method: 'get', + func: user_ctrl.get_by_id + }, + { + url: '/user/:user_id', + method: 'put', + func: [user_ctrl.identify_client, client_is_user_id, user_ctrl.update_by_id] + }, + { + url: '/user/:user_id', + method: 'delete', + func: [user_ctrl.identify_client, client_is_user_id, user_ctrl.delete_by_id] + } + +]; diff --git a/server/tp4/server.js b/server/tp4/server.js new file mode 100644 index 0000000000000000000000000000000000000000..b156aa1db4946b90fa99500bc6dbcc1a3841d5b5 --- /dev/null +++ b/server/tp4/server.js @@ -0,0 +1,31 @@ +'use strict'; + +const + express = require('express'), + bodyParser = require('body-parser'), + cors = require('cors'); + +const app = express(); + +app.use(cors()); +// app.use(express.static('../build')); +app.use(bodyParser.json()); + +// register routes +require('./routes')(app); + +// register error handling middleware +app.use(function (err, req, res, next) { + if (err.status === undefined) { + return res.status(500).send(err.message); + } else { + return res.status(err.status).send(err.message); + } +}); + +// launch server +const server = app.listen(4200, function () { + const host = server.address().address; + const port = server.address().port; + console.log('App listening at http://%s:%s', host, port); +}); diff --git a/sujets/sujet4.md b/sujets/sujet4.md new file mode 100644 index 0000000000000000000000000000000000000000..6abb7743f97bbddac433d540c88422ff8e4f5169 --- /dev/null +++ b/sujets/sujet4.md @@ -0,0 +1,47 @@ +Application Listes +=== + +Dans cette application, on va interagir avec un serveur qui dispose du modèle de données suivant : + - _User_ : id (pk), username, password + - _List_ : id (pk), title, UserId (fk) + - _Item_ : id (pk), title, ListId (fk) + +En plus des routes de gestion des User, l'API fournit les routes suivantes : + - `GET /list` : renvoie un tableau d'objets _List_ + - `POST /list` : attend un objet contenant un champ `title`, crée et renvoie une nouvelle _List_ + - `DELETE /list/:id` : supprime la _List_ d'id `id` + - `GET /list/:id/item` : renvoie un tableau d'objets _Item_ associés à la _List_ d'id `id` + - `POST /list/:id/item` : attend un objet contenant un champ `title`, crée et renvoie un nouvel _Item_ associé à _List_ d'id `id` + - `DELETE /item/:id` : supprime l'_Item_ d'id `id` + +Toutes ces routes ne sont accessibles qu'à un utilisateur authentifié et attendent un token dans l'en-tête `Authorization`. + +> ___Indication___ : le code serveur fourni a besoin d'un serveur MySql avec une base existante (les tables sont créées si besoin au lancement). Il vous faut modifier le fichier `list_api/models/index.js` pour y saisir les nom de user, mot de passe et nom de base. + +___ + +Ajouter une route `/lists` dans l'application (ainsi que l'entrée de menu correspondante) dans laquelle on rend un composant `Lists`. + +Au montage du composant `Lists`, l'ensemble des _List_ de l'utilisateur sont récupérées. +Les titres de ces _List_ sont affichés avec pour chacun un bouton permettant d'en demander la suppression et un bouton permettant d'en afficher le détail. +Un composant `AddListForm` permet l'ajout d'une nouvelle _List_. + +Une demande de détail pour la _List_ d'id `id` nous fait changer de route (client) pour aller à la route `/lists/:id`. +C'est dans cette sous-route que l'on rend un composant `ListDetail`. + +> ___Indications___ : on peut déclarer un composant `Route` à n'importe quel niveau de l'application, pas uniquement au top level. Pour déclarer une sous-route en fonction de la route courante (en concaténant la sous-route après la route courante par exemple), on peut récupérer la route courante grâce à la fonction `useRouteMatch` de React Router. On récupère les valeurs des paramètres des routes paramétrées (ici `:id`) grâce à la fonction `useParams`. + +Ce dernier récupère et affiche l'ensemble des _Item_ de la _List_ avec également pour chacun un bouton permettant d'en demander la suppression. +Un composant `AddItemForm` permet l'ajout d'un nouvel _Item_ dans la _List_. + +> ___Contraintes___ : utiliser la bibliothèque `react-query` pour accéder aux données (`useQuery`) ainsi que pour les ajouts et suppressions (`useMutation`). Toutes les fonctions exécutant les requêtes (`fetch`) et ayant donc connaissance de l'API seront déclarées dans un module séparé. + +**Test ultime** + +Le rafraîchissement de l'application à la route `/lists/2` doit restaurer l'interface dans un état où l'utilisateur est considéré authentifié, les titres des `List` de cet utilisateurs sont affichés ainsi que les _Item_ de la _List_ d'id 2. + +Code splitting +--- + +Faire en sorte que le code du composant `Lists` soit chargé dynamiquement uniquement lors du premier accès à cette section de l'application : +https://reactjs.org/docs/code-splitting.html#route-based-code-splitting