Added authGuard function and its implementations

Also refactored small code mistakes.
This commit is contained in:
Alexis
2021-01-19 18:52:52 +01:00
parent 410a58fc09
commit e8ca2416b7
31 changed files with 4062 additions and 3947 deletions

View File

@@ -163,19 +163,24 @@ INSERT INTO `permission` (slug) VALUES
("MODIFY_SPELLS"), ("MODIFY_SPELLS"),
("DELETE_SPELLS"), ("DELETE_SPELLS"),
("WARN_USERS"), ("WARN_USERS"),
("BAN_USERS"); ("BAN_USERS"),
("SECRET_SPELLS"),
("SECRET_USERS");
INSERT INTO `role_permission` (role_id, permission_id) VALUES INSERT INTO `role_permission` (role_id, permission_id) VALUES
(2, 1), (2, 1),
(3, 1), (3, 1),
(3, 2), (3, 2),
(3, 3), (3, 3),
(3, 7),
(4, 1), (4, 1),
(4, 2), (4, 2),
(4, 3), (4, 3),
(4, 4), (4, 4),
(4, 5), (4, 5),
(4, 6); (4, 6),
(4, 7),
(4, 8);
-- USERS -- USERS
INSERT INTO `user` (uuid, name, mail, avatar, gender, register_date, password, role_id, verified, banned) VALUES INSERT INTO `user` (uuid, name, mail, avatar, gender, register_date, password, role_id, verified, banned) VALUES

View File

@@ -7,7 +7,7 @@ let APIToken = bookshelf.Model.extend({
tableName: 'api_token', tableName: 'api_token',
hidden: ['id'], hidden: ['id'],
user() { user() {
return this.hasOne( 'User', 'user_uuid' ); return this.belongsTo('User', 'user_uuid', 'uuid');
} }
}) })

View File

@@ -5,9 +5,9 @@ const model = require('../models/ingredient-model')
// Model validation // Model validation
const Validator = require('jsonschema').Validator const Validator = require('jsonschema').Validator
const v = new Validator() const validator = new Validator()
const IngredientValidation = require("../validations/IngredientValidation") const IngredientValidation = require("../validations/IngredientValidation")
v.addSchema(IngredientValidation, "/IngredientValidation") validator.addSchema(IngredientValidation, "/IngredientValidation")
// Validations // Validations
const isXSSAttempt = require('../functions').isXSSAttempt const isXSSAttempt = require('../functions').isXSSAttempt
@@ -80,9 +80,9 @@ class IngredientRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(igr, IngredientValidation).valid) { } else if (!validator.validate(igr, IngredientValidation).valid) {
reject({ reject({
"message": `Le modèle d'ingrédient n'est pas respecté : ${v.validate(s, IngredientValidation).errors}`, "message": `Le modèle d'ingrédient n'est pas respecté : ${validator.validate(s, IngredientValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(igr.description)) { } else if (isXSSAttempt(igr.description)) {
@@ -127,9 +127,9 @@ class IngredientRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(igr, IngredientValidation).valid) { } else if (!validator.validate(igr, IngredientValidation).valid) {
reject({ reject({
"message": `Le modèle d'ingrédient n'est pas respecté : ${v.validate(s, IngredientValidation).errors}`, "message": `Le modèle d'ingrédient n'est pas respecté : ${validator.validate(s, IngredientValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(igr.description)) { } else if (isXSSAttempt(igr.description)) {

View File

@@ -5,9 +5,9 @@ const model = require('../models/meta-school-model')
// Model validation // Model validation
const Validator = require('jsonschema').Validator const Validator = require('jsonschema').Validator
const v = new Validator() const validator = new Validator()
const MetaSchoolValidation = require("../validations/MetaSchoolValidation") const MetaSchoolValidation = require("../validations/MetaSchoolValidation")
v.addSchema(MetaSchoolValidation, "/MetaSchoolValidation") validator.addSchema(MetaSchoolValidation, "/MetaSchoolValidation")
// Validations // Validations
const regexXSS = RegExp(/<[^>]*script/) const regexXSS = RegExp(/<[^>]*script/)

View File

@@ -5,9 +5,9 @@ const model = require('../models/school-model')
// Model validation // Model validation
const Validator = require('jsonschema').Validator const Validator = require('jsonschema').Validator
const v = new Validator() const validator = new Validator()
const SchoolValidation = require("../validations/SchoolValidation") const SchoolValidation = require("../validations/SchoolValidation")
v.addSchema(SchoolValidation, "/SchoolValidation") validator.addSchema(SchoolValidation, "/SchoolValidation")
// Validations // Validations
const isXSSAttempt = require('../functions').isXSSAttempt const isXSSAttempt = require('../functions').isXSSAttempt
@@ -79,9 +79,9 @@ class SchoolRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(s, SchoolValidation).valid) { } else if (!validator.validate(s, SchoolValidation).valid) {
reject({ reject({
"message": `Le modèle d'école n'est pas respecté : ${v.validate(s, SchoolValidation).errors}`, "message": `Le modèle d'école n'est pas respecté : ${validator.validate(s, SchoolValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(s.name) || isXSSAttempt(s.description)) { } else if (isXSSAttempt(s.name) || isXSSAttempt(s.description)) {
@@ -127,9 +127,9 @@ class SchoolRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(s, SchoolValidation).valid) { } else if (!validator.validate(s, SchoolValidation).valid) {
reject({ reject({
"message": `Le modèle d'école n'est pas respecté : ${v.validate(s, SchoolValidation).errors}`, "message": `Le modèle d'école n'est pas respecté : ${validator.validate(s, SchoolValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(s.name) || isXSSAttempt(s.description)) { } else if (isXSSAttempt(s.name) || isXSSAttempt(s.description)) {

View File

@@ -5,9 +5,9 @@ const model = require('../models/spell-model')
// Model validation // Model validation
const Validator = require('jsonschema').Validator const Validator = require('jsonschema').Validator
const v = new Validator() const validator = new Validator()
const SpellValidation = require("../validations/SpellValidation") const SpellValidation = require("../validations/SpellValidation")
v.addSchema(SpellValidation, "/SpellValidation") validator.addSchema(SpellValidation, "/SpellValidation")
// Validations // Validations
const isXSSAttempt = require('../functions').isXSSAttempt const isXSSAttempt = require('../functions').isXSSAttempt
@@ -118,9 +118,9 @@ class SpellRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(s, SpellValidation).valid) { } else if (!validator.validate(s, SpellValidation).valid) {
reject({ reject({
"message": `Le modèle de sortilège n'est pas respecté : ${v.validate(s, SpellValidation).errors}`, "message": `Le modèle de sortilège n'est pas respecté : ${validator.validate(s, SpellValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(s.name) || isXSSAttempt(s.description) || isXSSAttempt(s.cost)) { } else if (isXSSAttempt(s.name) || isXSSAttempt(s.description) || isXSSAttempt(s.cost)) {
@@ -194,9 +194,9 @@ class SpellRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(s, SpellValidation).valid) { } else if (!validator.validate(s, SpellValidation).valid) {
reject({ reject({
"message": `Le modèle de sortilège n'est pas respecté : ${v.validate(s, SpellValidation).errors}`, "message": `Le modèle de sortilège n'est pas respecté : ${validator.validate(s, SpellValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(s.name) || isXSSAttempt(s.description) || isXSSAttempt(s.cost)) { } else if (isXSSAttempt(s.name) || isXSSAttempt(s.description) || isXSSAttempt(s.cost)) {

View File

@@ -13,9 +13,9 @@ const mails = require('../smtp/mails');
// Model validation // Model validation
const Validator = require('jsonschema').Validator; const Validator = require('jsonschema').Validator;
const v = new Validator(); const validator = new Validator();
const UserValidation = require("../validations/UserValidation"); const UserValidation = require("../validations/UserValidation");
v.addSchema(UserValidation, "/UserValidation"); validator.addSchema(UserValidation, "/UserValidation");
// Validations // Validations
const isXSSAttempt = require('../functions').isXSSAttempt; const isXSSAttempt = require('../functions').isXSSAttempt;
@@ -168,9 +168,9 @@ class UserRepository {
"message": "Le corps de requête ne peut être vide.", "message": "Le corps de requête ne peut être vide.",
"code": 403, "code": 403,
}) })
} else if (!v.validate(u, UserValidation).valid) { } else if (!validator.validate(u, UserValidation).valid) {
reject({ reject({
"message": "Structure de la requête invalide - " + v.validate(u, UserValidation).errors, "message": "Structure de la requête invalide - " + validator.validate(u, UserValidation).errors,
"code": 403, "code": 403,
}) })
} else if (isXSSAttempt(u.name) || isXSSAttempt(u.password) || isXSSAttempt(u.mail)) { } else if (isXSSAttempt(u.name) || isXSSAttempt(u.password) || isXSSAttempt(u.mail)) {
@@ -397,6 +397,91 @@ class UserRepository {
}) })
} }
checkAPITokenPerms(token, permissions) {
return new Promise(async (resolve, reject) => {
// If the request doesn't have a token, reject it.
if (!token) {
reject({
"message": "Vous devez utiliser un jeton d'API dans l'en-tête de votre requête.",
"code": 401,
})
}
// Fetches user from token
new token_model()
.where({ 'value': token })
.fetch({ withRelated: 'user.role.permissions' })
// Catches not found errors
.catch(err => {
if (err instanceof token_model.NotFoundError) {
reject({
"message": "Ce jeton n'est affilié à aucun compte.",
"code": 404,
});
} else {
reject({
"message": "Le jeton ou l'utilisateur n'a pas pu être récupéré.",
"code": 500,
});
}
})
.then(fullToken => {
token = fullToken.toJSON({ omitPivot: true });
// If the token is associated with a banned user...
if (token.user.banned) {
reject({
"message": "Le jeton est lié à un utilisateur banni, il n'est donc plus utilisable.",
"code": 401,
});
}
// If the token is associated with a non-verified user...
if (!token.user.verified) {
reject({
"message": "Le jeton est lié à un utilisateur non-vérifié, il n'est donc pas utilisable.",
"code": 401,
});
}
let user_permissions = token.user.role.permissions;
// Convert user_perm to array
user_permissions.forEach((user_perm, i) => {
user_permissions[i] = user_perm.slug;
});
// Loops to check if the person has all the permissions required...
permissions.forEach(perm => {
// If not, reject...
if (!user_permissions.includes(perm)) {
reject({
"message": "Permissions insuffisantes.",
"code": 401
})
}
});
// If everything went well, resolve the promise.
resolve({
"message": "Credentials accepted.",
"code": 200
});
})
// Unhandled errors
.catch(err => {
reject({
"message": "Une erreur inconnue est survenue.",
"code": 500,
});
});
})
}
/** /**
* Check if the email that was input is available for account creation. * Check if the email that was input is available for account creation.
* *

View File

@@ -5,9 +5,9 @@ const model = require('../models/variable-model')
// Model validation // Model validation
const Validator = require('jsonschema').Validator const Validator = require('jsonschema').Validator
const v = new Validator() const validator = new Validator()
const VariableValidation = require("../validations/VariableValidation") const VariableValidation = require("../validations/VariableValidation")
v.addSchema(VariableValidation, "/VariableValidation") validator.addSchema(VariableValidation, "/VariableValidation")
// Validations // Validations
const isXSSAttempt = require('../functions').isXSSAttempt const isXSSAttempt = require('../functions').isXSSAttempt
@@ -80,9 +80,9 @@ class VariableRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(vr, VariableValidation).valid) { } else if (!validator.validate(vr, VariableValidation).valid) {
reject({ reject({
"message": `Le modèle de variable n'est pas respecté : ${v.validate(s, VariableValidation).errors}`, "message": `Le modèle de variable n'est pas respecté : ${validator.validate(s, VariableValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(vr.description)) { } else if (isXSSAttempt(vr.description)) {
@@ -126,9 +126,9 @@ class VariableRepository {
"message": "Le corps de la requête ne peut pas être vide.", "message": "Le corps de la requête ne peut pas être vide.",
"code": 403, "code": 403,
}); });
} else if (!v.validate(vr, VariableValidation).valid) { } else if (!validator.validate(vr, VariableValidation).valid) {
reject({ reject({
"message": `Le modèle de variable n'est pas respecté : ${v.validate(s, VariableValidation).errors}`, "message": `Le modèle de variable n'est pas respecté : ${validator.validate(s, VariableValidation).errors}`,
"code": 403, "code": 403,
}); });
} else if (isXSSAttempt(vr.description)) { } else if (isXSSAttempt(vr.description)) {

View File

@@ -1,5 +1,4 @@
'use strict' 'use strict'
// Router // Router
const express = require('express'); const express = require('express');
let router = express.Router(); let router = express.Router();
@@ -9,29 +8,6 @@ const UserRepository = require('../repositories/user-repository');
const Users = new UserRepository(); const Users = new UserRepository();
// ROUTES // ROUTES
// ENTRY POINT
const authUser = (mail, password) => {
return Users.logUserToAPI(mail, password)
.catch(err => {
throw err
})
}
router.get('/login', async (req, res) => {
authUser(req.body.mail, req.body.password)
.then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8')
res.end(JSON.stringify(v))
})
.catch(err => {
res.status(err.code).send(JSON.stringify(
{
"error": err.message,
"code": err.code
})
)
})
})
// GEN API TOKEN // GEN API TOKEN
const generateAPIToken = (mail, password) => { const generateAPIToken = (mail, password) => {
return Users.genAPIToken(mail, password) return Users.genAPIToken(mail, password)
@@ -50,4 +26,4 @@ router.get('/genToken', async (req, res) => {
}) })
}) })
module.exports = router module.exports = router;

View File

@@ -1,12 +1,11 @@
'use strict' 'use strict'
// Router // Router
const express = require('express') const express = require('express');
let router = express.Router() let router = express.Router();
// Connection // Connection
const connection = require('../database/bookshelf') const functions = require('../functions');
const functions = require('../functions')
// Repository // Repository
const IngredientRepository = require('../repositories/ingredient-repository'); const IngredientRepository = require('../repositories/ingredient-repository');

View File

@@ -1,12 +1,11 @@
'use strict' 'use strict'
// Router // Router
const express = require('express') const express = require('express');
let router = express.Router() let router = express.Router();
// Connection // Connection
const connection = require('../database/bookshelf') const functions = require('../functions');
const functions = require('../functions')
// Repository // Repository
const MetaSchoolRepository = require('../repositories/meta-school-repository'); const MetaSchoolRepository = require('../repositories/meta-school-repository');

View File

@@ -0,0 +1,23 @@
// Repository
const UserRepository = require('../../repositories/user-repository');
const Users = new UserRepository();
// AUTHGUARD
const authGuard = (permissions) => {
return async (req, res, next) => {
// Get token from headers
let api_token = req.headers['auracle_key'];
// Uses repo to validate the associated perms with the token
Users.checkAPITokenPerms(api_token, permissions)
.then(v => {
next();
})
.catch(err => {
res.status(err.code).send(JSON.stringify(err))
});
}
}
module.exports = authGuard;

View File

@@ -1,12 +1,11 @@
'use strict' 'use strict'
// Router // Router
const express = require('express') const express = require('express');
let router = express.Router() let router = express.Router();
// Connection // Connection
const connection = require('../database/bookshelf') const functions = require('../functions');
const functions = require('../functions')
// Repository // Repository
const SchoolRepository = require('../repositories/school-repository'); const SchoolRepository = require('../repositories/school-repository');

View File

@@ -1,12 +1,14 @@
'use strict' 'use strict'
// Router // Router
const express = require('express') const express = require('express');
let router = express.Router() let router = express.Router();
// Connection // Connection
const connection = require('../database/bookshelf') const functions = require('../functions');
const functions = require('../functions')
// AuthGuard
const authGuard = require('./middleware/authGuard');
// Repository // Repository
const SpellReposity = require('../repositories/spell-repository'); const SpellReposity = require('../repositories/spell-repository');
@@ -21,7 +23,9 @@ const getPublicSpells = (name, description, level, charge, cost, ritual) => {
throw err throw err
}) })
} }
router.get('//:name?/:description?/:level?/:charge?/:cost?/:ritual?/', async (req, res) => { router.get(
'//:name?/:description?/:level?/:charge?/:cost?/:ritual?/',
async (req, res) => {
getPublicSpells(req.query.name, req.query.description, req.query.level, req.query.charge, req.query.cost, req.query.ritual) getPublicSpells(req.query.name, req.query.description, req.query.level, req.query.charge, req.query.cost, req.query.ritual)
.then(v => { .then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8') res.setHeader('Content-Type', 'application/json;charset=utf-8')
@@ -45,7 +49,10 @@ const getSpells = (name, description, level, charge, cost, ritual) => {
throw err throw err
}) })
} }
router.get('/private/:name?/:description?/:level?/:charge?/:cost?/:ritual?/', async (req, res) => { router.get(
'/private/:name?/:description?/:level?/:charge?/:cost?/:ritual?/',
authGuard(['SECRET_SPELLS']),
async (req, res) => {
getSpells(req.query.name, req.query.description, req.query.level, req.query.charge, req.query.cost, req.query.ritual) getSpells(req.query.name, req.query.description, req.query.level, req.query.charge, req.query.cost, req.query.ritual)
.then(v => { .then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8') res.setHeader('Content-Type', 'application/json;charset=utf-8')
@@ -69,7 +76,9 @@ const getSomeSpells = (page) => {
throw err throw err
}) })
} }
router.get('/page/:page', async (req, res) => { router.get(
'/page/:page',
async (req, res) => {
getSomeSpells(req.params.page) getSomeSpells(req.params.page)
.then(v => { .then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8') res.setHeader('Content-Type', 'application/json;charset=utf-8')
@@ -93,7 +102,9 @@ const getSpell = (id) => {
throw err throw err
}) })
} }
router.get('/:id/', async (req, res) => { router.get(
'/:id/',
async (req, res) => {
getSpell(req.params.id) getSpell(req.params.id)
.then(v => { .then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8') res.setHeader('Content-Type', 'application/json;charset=utf-8')
@@ -118,7 +129,10 @@ const addSpell = (s) => {
throw err throw err
}) })
} }
router.post('/', async (req, res) => { router.post(
'/',
authGuard(['SUBMIT_SPELLS']),
async (req, res) => {
addSpell(req.body) addSpell(req.body)
.then(v => { .then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8') res.setHeader('Content-Type', 'application/json;charset=utf-8')
@@ -143,7 +157,10 @@ const updateSpell = (id, s) => {
throw err throw err
}) })
} }
router.put('/:id/', async (req, res) => { router.put(
'/:id/',
authGuard(['SUBMIT_SPELLS', 'MODIFY_SPELLS']),
async (req, res) => {
updateSpell(req.params.id, req.body) updateSpell(req.params.id, req.body)
.then(v => { .then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8') res.setHeader('Content-Type', 'application/json;charset=utf-8')
@@ -168,7 +185,10 @@ const deleteSpell = (id) => {
throw err throw err
}) })
} }
router.delete('/:id/', async (req, res) => { router.delete(
'/:id/',
authGuard(['SUBMIT_SPELLS', 'MODIFY_SPELLS', 'DELETE_SPELLS']),
async (req, res) => {
deleteSpell(req.params.id) deleteSpell(req.params.id)
.then(v => { .then(v => {
res.setHeader('Content-Type', 'application/json;charset=utf-8') res.setHeader('Content-Type', 'application/json;charset=utf-8')

View File

@@ -1,12 +1,8 @@
'use strict' 'use strict'
// Router // Router
const express = require('express') const express = require('express');
let router = express.Router() let router = express.Router();
// Connection
const connection = require('../database/bookshelf')
const db = connection.db
// Repository // Repository
const UserRepository = require('../repositories/user-repository'); const UserRepository = require('../repositories/user-repository');

View File

@@ -1,12 +1,11 @@
'use strict' 'use strict'
// Router // Router
const express = require('express') const express = require('express');
let router = express.Router() let router = express.Router();
// Connection // Connection
const connection = require('../database/bookshelf') const functions = require('../functions');
const functions = require('../functions')
// Repository // Repository
const VariableRepository = require('../repositories/variable-repository'); const VariableRepository = require('../repositories/variable-repository');

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Template Email Auracle Inscription</title> <title>Template Email Auracle Inscription</title>
<style> <style>
@@ -44,6 +45,7 @@
padding: 10vh 5vw; padding: 10vh 5vw;
background: url('https://i.imgur.com/IxEKKEY.png') center center fixed repeat; background: url('https://i.imgur.com/IxEKKEY.png') center center fixed repeat;
} }
.wrapper:before { .wrapper:before {
display: block; display: block;
content: ''; content: '';
@@ -62,7 +64,8 @@
z-index: 5; z-index: 5;
} }
header, footer { header,
footer {
text-align: center; text-align: center;
color: var(--white); color: var(--white);
background-color: rgb(var(--black)); background-color: rgb(var(--black));
@@ -77,6 +80,7 @@
} }
</style> </style>
</head> </head>
<body> <body>
<div class="wrapper"> <div class="wrapper">
<div class="container"> <div class="container">
@@ -85,8 +89,17 @@
</header> </header>
<main> <main>
<p>Votre compte Auracle a bien été enregistré et est rattaché à cette adresse mail.</p> <p>Votre compte Auracle a bien été enregistré et est rattaché à cette adresse mail.</p>
<p>Cependant, afin de garantir la sécurité de vos données, votre compte doit être vérifié avant votre première connexion. Vous pouvez <a href="http://localhost:2814/api/users/verification/{{ user.token }}">suivre ce lien afin de confirmer votre inscription.</a></p> <p>Cependant, afin de garantir la sécurité de vos données, votre compte doit être vérifié avant votre
<p>Si vous rencontrez des problèmes, n'hésitez pas à envoyer un mail à <a href="mailto:support@auracle.io">support@auracle.io</a></p> première connexion. Vous pouvez
<a href="http: //localhost:2814/api/v1/users/verification/{{ user.token }}">
suivre ce lien afin de confirmer votre inscription.
</a>
</p>
<p>Si vous rencontrez des problèmes, n'hésitez pas à envoyer un mail à
<a href="mailto:support@auracle.io">
support@auracle.io
</a>
</p>
<p><strong>Merci de votre soutien !</strong></p> <p><strong>Merci de votre soutien !</strong></p>
<p class="italics">Bien sincèrement, Izàc Tymos.</p> <p class="italics">Bien sincèrement, Izàc Tymos.</p>
</main> </main>
@@ -97,4 +110,5 @@
</div> </div>
</div> </div>
</body> </body>
</html> </html>