Compare commits

...

4 Commits

21 changed files with 181 additions and 76 deletions

3
.gitignore vendored
View File

@ -66,3 +66,6 @@ typings/
data/* data/*
!/data/.gitkeep !/data/.gitkeep
push.sh
.rsyncignore

22
app.js
View File

@ -2,11 +2,14 @@ require('dotenv').config();
const createError = require('http-errors'); const createError = require('http-errors');
const express = require('express'); const express = require('express');
const session = require('express-session'); const session = require('express-session');
let { redisClient } = require('./redis');
let RedisStore = require('connect-redis')(session);
const csrf = require('csurf'); const csrf = require('csurf');
const path = require('path'); const path = require('path');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const logger = require('morgan'); const logger = require('morgan');
const i18n = require('i18n-2'); const i18n = require('i18n-2');
const debugLocale = require('debug')('soundbirder:locale');
const indexRouter = require('./routes/index'); const indexRouter = require('./routes/index');
const apiRouter = require('./routes/api'); const apiRouter = require('./routes/api');
@ -34,6 +37,7 @@ const sess = {
if (app.get('env') === 'production') { if (app.get('env') === 'production') {
app.set('trust proxy', 1); // trust first proxy app.set('trust proxy', 1); // trust first proxy
sess.cookie.secure = true; // serve secure cookies sess.cookie.secure = true; // serve secure cookies
sess.store = new RedisStore({ client: redisClient });
} }
app.use(session(sess)); app.use(session(sess));
@ -41,7 +45,8 @@ app.use(session(sess));
i18n.expressBind(app, { i18n.expressBind(app, {
locales: ['en', 'es', 'fr', 'de'], locales: ['en', 'es', 'fr', 'de'],
defaultLocale: 'en', defaultLocale: 'en',
cookieName: 'locale' cookieName: 'locale',
extension: '.json'
}); });
app.use(function (req, res, next) { app.use(function (req, res, next) {
@ -52,11 +57,11 @@ app.use(function (req, res, next) {
if (rxLocale.test(req.url)) { if (rxLocale.test(req.url)) {
const arr = rxLocale.exec(req.url); const arr = rxLocale.exec(req.url);
const locale = arr[1]; const locale = arr[1];
debugLocale("Setting locale from url prefix", locale);
req.i18n.setLocale(locale); req.i18n.setLocale(locale);
debugLocale("Locale set to", req.i18n.locale);
} }
if (req.cookies.locale === undefined) { res.cookie('locale', req.i18n.locale, { maxAge: 900000, sameSite: true });
res.cookie('locale', req.i18n.locale, { maxAge: 90000 });
}
// add extra logic // add extra logic
next(); next();
}); });
@ -65,15 +70,6 @@ app.use('/dist/leaflet', express.static('node_modules/leaflet/dist'));
app.use('/dist/feather', express.static('node_modules/feather-icons/dist')); app.use('/dist/feather', express.static('node_modules/feather-icons/dist'));
app.use('/dist/axios', express.static('node_modules/axios/dist')); app.use('/dist/axios', express.static('node_modules/axios/dist'));
app.use(function (req, res, next) {
res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
);
next();
});
app.use('/api/0', apiRouter); app.use('/api/0', apiRouter);
const csrfProtection = csrf({ cookie: true }); const csrfProtection = csrf({ cookie: true });

View File

@ -6,17 +6,22 @@ const quizzController = require('./quizz');
const QUIZZ_SIZE = process.env.QUIZZ_SIZE ? process.env.QUIZZ_SIZE : 5; const QUIZZ_SIZE = process.env.QUIZZ_SIZE ? process.env.QUIZZ_SIZE : 5;
function check(req, res) { function getHome(req, res) {
res.render('api', {
title: "SoundBirder api",
version: 0
});
} }
function quizz(req, res) { function quizz(req, res) {
debug('Generating quizz'); debug('Generating quizz');
const { lat, lng } = req.body; const { lat, lng } = req.query;
debug(`Coordinates: ${lat}, ${lng}`);
const locale = req.i18n.locale; const locale = req.i18n.locale;
debugLocale("Locale:", locale); debugLocale("Locale:", locale);
quizzController.generateQuizz({ lat, lng }, locale, QUIZZ_SIZE) quizzController.generateQuizz({ lat, lng }, locale, QUIZZ_SIZE)
.then(({ species, correct, audio }) => { .then(({ species, answer, audio }) => {
req.session.correct = correct; req.session.answer = answer;
res.json({ species, audio }); res.json({ species, audio });
debug("Quizz sent"); debug("Quizz sent");
}) })
@ -27,12 +32,37 @@ function quizz(req, res) {
}); });
} }
function check(req, res) {
function getHome(req, res) { let answer, correct;
res.render('api', { try {
title: "SoundBirder api", answer = req.query.species;
version: 0 correct = req.session.answer;
}); } catch (error) {
console.error(error);
}
let result = {};
try {
if (correct === undefined) {
console.error("No answer found in session");
} else if (answer === correct.speciesCode) {
debug("Correct answer");
result = {
correct: true,
message: req.i18n.__('Correct!'),
answer: correct
};
} else {
debug("Wrong answer");
result = {
correct: false,
message: req.i18n.__('Wrong!'),
answer: correct
};
}
} catch (error) {
console.error(error);
}
res.json(result);
} }
const game = { const game = {

View File

@ -1,16 +1,5 @@
require('dotenv').config(); const { redisClient } = require('../redis');
const debug = require('debug')('soundbirder:cache'); const debug = require('debug')('soundbirder:cache');
const redis = require('redis');
const host = process.env.REDIS_HOST ? process.env.REDIS_HOST : 'localhost';
const port = process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379;
const url = `redis://${host}:${port}`;
const redisClient = redis.createClient({
url
});
(async () => {
redisClient.connect();
})();0
function cacheResponse(request, response) { function cacheResponse(request, response) {
debug("Caching response", request); debug("Caching response", request);
@ -18,9 +7,9 @@ function cacheResponse(request, response) {
} }
async function getCached(request) { async function getCached(request) {
debug("Getting cached response", request);
const cached = await redisClient.get(request); const cached = await redisClient.get(request);
if (cached) { if (cached) {
debug("Got cached response", request);
return JSON.parse(cached); return JSON.parse(cached);
} }
return null; return null;

View File

@ -16,10 +16,17 @@ async function generateQuizz(coordinates, locale, size) {
debugResponses('Localized species selection:', speciesSelectionLocalized); debugResponses('Localized species selection:', speciesSelectionLocalized);
quizz.species = speciesSelectionLocalized; quizz.species = speciesSelectionLocalized;
debug("Got species selection", quizz.species); debug("Got species selection", quizz.species);
const answer = await choice(speciesSelectionLocalized); let answer;
debug("Got answer", answer); do {
quizz.correct = answer.speciesCode; answer = choice(speciesSelectionLocalized);
quizz.answer = answer;
quizz.audio = await getAudio(answer.sciName); quizz.audio = await getAudio(answer.sciName);
if (quizz.audio === undefined) {
debug("No audio found for species", answer.sciName);
debug("Trying again...");
}
} while (quizz.audio === undefined);
debug("Got answer", answer);
debug("Got audio", quizz.audio); debug("Got audio", quizz.audio);
} catch (error) { } catch (error) {
debug("Error raised while generating quizz"); debug("Error raised while generating quizz");

View File

@ -1,4 +1,4 @@
version: '3.9' version: '3'
services: services:
express: express:
@ -10,8 +10,10 @@ services:
- NODE_ENV=production - NODE_ENV=production
- REDIS_HOST=${REDIS_HOST:-soundbirder_redis} - REDIS_HOST=${REDIS_HOST:-soundbirder_redis}
- REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PORT=${REDIS_PORT:-6379}
- DEBUG=${DEBUG:-""}
ports: ports:
- "${EXPRESS_PORT:-3000}:3000" - "${EXPRESS_PORT:-3000}:3000"
restart: unless-stopped
networks: networks:
- soundbirder_network - soundbirder_network
depends_on: depends_on:

View File

@ -2,12 +2,12 @@ FROM node:16.17.0
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package*.json . COPY package*.json ./
RUN npm install RUN npm install
RUN npm ci --only=production RUN npm ci --only=production
COPY . . COPY . ./
EXPOSE 3000 EXPOSE 3000
CMD [ "./bin/www" ] CMD [ "./bin/www" ]

View File

@ -1,4 +1,4 @@
{ {
"Game": "Game", "Game": "Spiel",
"About": "About" "About": "About"
} }

View File

@ -8,5 +8,8 @@
"The project is made with ♥ by Samuel ORTION": "The project is made with ♥ by Samuel ORTION", "The project is made with ♥ by Samuel ORTION": "The project is made with ♥ by Samuel ORTION",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.": "SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.", "SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.": "SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird.",
"The project is made with ♥ by Samuel ORTION.": "The project is made with ♥ by Samuel ORTION.", "The project is made with ♥ by Samuel ORTION.": "The project is made with ♥ by Samuel ORTION.",
"Welcome to SoundBirder' API": "Welcome to SoundBirder' API" "Welcome to SoundBirder' API": "Welcome to SoundBirder' API",
"Wrong!": "Wrong!",
"Correct!": "Correct!",
"It was a": "It was a"
} }

View File

@ -1,4 +0,0 @@
{
"Home": "Inicio",
"About": "Acerca de",
}

4
locales/es.json Normal file
View File

@ -0,0 +1,4 @@
{
"Home": "Inicio",
"About": "Acerca de"
}

View File

@ -4,5 +4,8 @@
"Contact": "Contact", "Contact": "Contact",
"SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird": "SoundBirder est une application web open-source qui permet d'apprendre à reconnaitre les chants d'oiseaux. Elle se base sur des enregistrements audios de Xeno-Canto et sur les données de répartitions d'eBird", "SoundBirder is an open-source web application to learn bird song identification. It is based on bird records from Xeno-Canto and data from eBird": "SoundBirder est une application web open-source qui permet d'apprendre à reconnaitre les chants d'oiseaux. Elle se base sur des enregistrements audios de Xeno-Canto et sur les données de répartitions d'eBird",
"Author": "Auteur", "Author": "Auteur",
"The project is made with ♥ by Samuel ORTION": "Ce projet est fait avec ♥ par Samuel ORTION" "The project is made with ♥ by Samuel ORTION": "Ce projet est fait avec ♥ par Samuel ORTION",
"Correct!": "Correct!",
"Wrong!": "Incorrect!",
"It was a": "C'était un"
} }

14
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@unclesamulus/ebird-api": "^0.0.0", "@unclesamulus/ebird-api": "^0.0.0",
"@unclesamulus/xeno-canto-api": "^0.0.0", "@unclesamulus/xeno-canto-api": "^0.0.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"connect-redis": "^6.1.3",
"cookie-parser": "~1.4.4", "cookie-parser": "~1.4.4",
"csurf": "^1.11.0", "csurf": "^1.11.0",
"debug": "~2.6.9", "debug": "~2.6.9",
@ -282,6 +283,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/connect-redis": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz",
"integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==",
"engines": {
"node": ">=12"
}
},
"node_modules/constantinople": { "node_modules/constantinople": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz",
@ -1545,6 +1554,11 @@
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
} }
}, },
"connect-redis": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz",
"integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw=="
},
"constantinople": { "constantinople": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz",

View File

@ -9,6 +9,7 @@
"@unclesamulus/ebird-api": "^0.0.0", "@unclesamulus/ebird-api": "^0.0.0",
"@unclesamulus/xeno-canto-api": "^0.0.0", "@unclesamulus/xeno-canto-api": "^0.0.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"connect-redis": "^6.1.3",
"cookie-parser": "~1.4.4", "cookie-parser": "~1.4.4",
"csurf": "^1.11.0", "csurf": "^1.11.0",
"debug": "~2.6.9", "debug": "~2.6.9",

View File

@ -1,4 +1,3 @@
const API_VERSION = "0"; const API_VERSION = "0";
const API_URL = `/api/${API_VERSION}`; const API_URL = `/api/${API_VERSION}`;
@ -30,11 +29,11 @@ function getQuizz(lat, lng) {
}); });
} }
function checkResponse(speciesId) { function checkAnswer(speciesId) {
return query('game/check', { return query(`game/check`, {
species: speciesId species: speciesId
}).then(response => { }).then(data => {
return response.data.correct return data;
}).catch(error => { }).catch(error => {
throw error; throw error;
}); });
@ -42,7 +41,7 @@ function checkResponse(speciesId) {
const client = { const client = {
getQuizz, getQuizz,
checkResponse checkAnswer
} }
export default client; export default client;

View File

@ -19,7 +19,9 @@ function quizzStep() {
// Retrieve coordinates from former done geolocation (TODO: fix the need of cookie) // Retrieve coordinates from former done geolocation (TODO: fix the need of cookie)
const coordinates = getCoordinates(); const coordinates = getCoordinates();
client.getQuizz(coordinates) const [lat, lng] = coordinates;
console.log("Coordinates: " + `${lat}, ${lng}`);
client.getQuizz(lat, lng)
.then(quizz => { .then(quizz => {
// Display the quizz // Display the quizz
displayQuizz(quizz); displayQuizz(quizz);
@ -38,12 +40,43 @@ function displayQuizz(quizz) {
proposal.classList.add('proposal'); proposal.classList.add('proposal');
let button = document.createElement('button'); let button = document.createElement('button');
button.classList.add('proposal-button'); button.classList.add('proposal-button');
button.value = sp.code; button.value = sp.speciesCode;
button.innerText = sp.comName; button.innerText = sp.comName;
proposal.appendChild(button); proposal.appendChild(button);
proposals.appendChild(proposal); proposals.appendChild(proposal);
button.addEventListener('click', verifyAnswer);
}); });
} }
function verifyAnswer(event) {
let answer = event.target.value;
client.checkAnswer(answer)
.then(data => {
let message_class;
if (data.correct)
message_class = 'success';
else
message_class = 'error';
displayResult(message_class, data.message, data.answer);
}).catch(error => {
console.log(error);
});
}
function displayResult(message_class, message, species) {
let result = document.querySelector('.game-results-step .message');
document.querySelector('.game-quizz-step').classList.toggle('none');
result.classList.remove('success', 'error');
result.classList.add(message_class);
result.innerText = message;
document.querySelector('.game-results-step').classList.remove('none');
document.querySelector('.game-results-step .restart-button').addEventListener('click', restart);
document.querySelector('.game-results-step .species .com').innerText = species.comName;
document.querySelector('.game-results-step .species .sci').innerText = species.sciName;
}
function restart() {
window.location.reload();
}
function game() { function game() {
geolocationStep(); geolocationStep();

View File

@ -7,7 +7,7 @@ body {
margin: 0; margin: 0;
} }
body > * { body>* {
padding: 1em; padding: 1em;
} }
@ -54,12 +54,12 @@ main {
min-height: calc(100vh - 15em); min-height: calc(100vh - 15em);
} }
#map { height: 50vh; } #map {
height: 50vh;
nav {
} }
nav {}
nav li { nav li {
list-style: none; list-style: none;
} }
@ -73,3 +73,7 @@ nav ul {
.none { .none {
display: none; display: none;
} }
.species span {
margin-left: 1em;
}

17
redis.js Normal file
View File

@ -0,0 +1,17 @@
const redis = require('redis');
const redisHost = process.env.REDIS_HOST ? process.env.REDIS_HOST : 'localhost';
const redisPort = process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379;
const url = `redis://${redisHost}:${redisPort}`;
const redisClient = redis.createClient({
url,
legacyMode: true
});
(async () => {
redisClient.connect()
})();
module.exports = {
redisClient
}

View File

@ -9,6 +9,6 @@ router.route('/game/quizz')
.get(apiController.game.quizz); .get(apiController.game.quizz);
router.route('/game/check') router.route('/game/check')
.post(apiController.game.check); .get(apiController.game.check);
module.exports = router; module.exports = router;

View File

@ -12,7 +12,7 @@ function choices(array, number) {
if (result.includes(c)) { if (result.includes(c)) {
i--; i--;
} else { } else {
result.push(choice(array)); result.push(c);
} }
} }
return result; return result;

View File

@ -8,9 +8,13 @@
.game-quizz-step.none .game-quizz-step.none
ul.proposals ul.proposals
audio audio
.game-result-step.none .game-results-step.none
p.result p.message
button.button.restart-button.disabled .species.answer
p #{ __('It was a') }
span.com
span.sci
button.button.restart-button
i(data-feather="repeat") i(data-feather="repeat")
link(rel="stylesheet" href="/dist/leaflet/leaflet.css") link(rel="stylesheet" href="/dist/leaflet/leaflet.css")
script(src="/dist/leaflet/leaflet.js") script(src="/dist/leaflet/leaflet.js")