Initial Commit
This commit is contained in:
commit
7647bcd52a
13 changed files with 3097 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
todos.db
|
||||||
|
todos.db-shm
|
||||||
|
todos.db-wal
|
||||||
|
.env
|
||||||
103
app.js
Normal file
103
app.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// server.js
|
||||||
|
const path = require('path');
|
||||||
|
const Fastify = require('fastify');
|
||||||
|
const formBody = require('@fastify/formbody');
|
||||||
|
const fastifyStatic = require('@fastify/static');
|
||||||
|
const fastifyView = require('@fastify/view');
|
||||||
|
const ejs = require('ejs');
|
||||||
|
const promClient = require('prom-client');
|
||||||
|
const fastifyCookie = require('@fastify/cookie');
|
||||||
|
const fastifySession = require('@fastify/session');
|
||||||
|
|
||||||
|
// --- App Instance ---
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// --- Plugins ---
|
||||||
|
app.register(formBody);
|
||||||
|
app.register(fastifyCookie);
|
||||||
|
app.register(fastifySession, {
|
||||||
|
secret: 'this_very_long_random_secret_32_chars_minimum!',
|
||||||
|
cookie: { secure: false },
|
||||||
|
saveUninitialized: false,
|
||||||
|
});
|
||||||
|
app.register(fastifyStatic, {
|
||||||
|
root: path.join(__dirname, 'public'),
|
||||||
|
prefix: '/static/',
|
||||||
|
});
|
||||||
|
app.register(fastifyView, {
|
||||||
|
engine: { ejs },
|
||||||
|
root: path.join(__dirname, 'views'),
|
||||||
|
layout: 'layout.ejs',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Prometheus Metrics ---
|
||||||
|
const register = new promClient.Registry();
|
||||||
|
promClient.collectDefaultMetrics({ register });
|
||||||
|
|
||||||
|
const counterCreated = new promClient.Counter({
|
||||||
|
name: 'todo_created_total',
|
||||||
|
help: 'Nombre total de todos créés',
|
||||||
|
});
|
||||||
|
const counterDeleted = new promClient.Counter({
|
||||||
|
name: 'todo_deleted_total',
|
||||||
|
help: 'Nombre total de todos supprimés',
|
||||||
|
});
|
||||||
|
const gaugeCount = new promClient.Gauge({
|
||||||
|
name: 'todo_count',
|
||||||
|
help: 'Nombre courant de todos',
|
||||||
|
});
|
||||||
|
register.registerMetric(counterCreated);
|
||||||
|
register.registerMetric(counterDeleted);
|
||||||
|
register.registerMetric(gaugeCount);
|
||||||
|
|
||||||
|
const fastifySwagger = require('@fastify/swagger');
|
||||||
|
const fastifySwaggerUI = require('@fastify/swagger-ui');
|
||||||
|
|
||||||
|
// --- Swagger ---
|
||||||
|
app.register(fastifySwagger, {
|
||||||
|
openapi: {
|
||||||
|
info: {
|
||||||
|
title: 'Todo API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'CRUD de Todo en Fastify (Node.js) + SQLite',
|
||||||
|
},
|
||||||
|
servers: [{ url: 'http://localhost:3000' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.register(fastifySwaggerUI, {
|
||||||
|
routePrefix: '/api/docs',
|
||||||
|
uiConfig: {
|
||||||
|
docExpansion: 'full',
|
||||||
|
deepLinking: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Routes ---
|
||||||
|
app.register(require('./routes/api'), {
|
||||||
|
promRegister: register,
|
||||||
|
counterCreated,
|
||||||
|
counterDeleted,
|
||||||
|
gaugeCount,
|
||||||
|
});
|
||||||
|
app.register(require('./routes/ui'), {
|
||||||
|
counterCreated,
|
||||||
|
counterDeleted,
|
||||||
|
gaugeCount,
|
||||||
|
});
|
||||||
|
app.register(require('./routes/other'), {
|
||||||
|
promRegister: register,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Start Server ---
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await app.ready();
|
||||||
|
app.swagger();
|
||||||
|
await app.listen({ port: 3000, host: 'localhost' });
|
||||||
|
app.log.info('Server listening on http://localhost:3000');
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start();
|
||||||
50
db.js
Normal file
50
db.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// db.js
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const db = new Database('todos.db');
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
// Création table si absente
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pseudo TEXT NOT NULL,
|
||||||
|
discord_id TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS todos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
users_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (users_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Ajout de l'utilisateur test si absent
|
||||||
|
const userTest = db.prepare('SELECT * FROM users WHERE id = 1').get();
|
||||||
|
if (!userTest) {
|
||||||
|
db.prepare('INSERT INTO users (id, pseudo, discord_id) VALUES (?, ?, ?)').run(1, 'test', 'null id');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// USERS
|
||||||
|
allUsers: () => db.prepare('SELECT * FROM users ORDER BY id DESC').all(),
|
||||||
|
getUserById: (id) => db.prepare('SELECT * FROM users WHERE id = ?').get(id),
|
||||||
|
getUserByDiscordId: (discord_id) => db.prepare('SELECT * FROM users WHERE discord_id = ?').get(discord_id),
|
||||||
|
createUser: (pseudo, discord_id) =>
|
||||||
|
db.prepare('INSERT INTO users (pseudo, discord_id) VALUES (?, ?)').run(pseudo, discord_id),
|
||||||
|
|
||||||
|
// TODOS
|
||||||
|
allTodos: () => db.prepare('SELECT * FROM todos ORDER BY id DESC').all(),
|
||||||
|
getTodo: (id) => db.prepare('SELECT * FROM todos WHERE id = ?').get(id),
|
||||||
|
getTodosByUser: (users_id) => db.prepare('SELECT * FROM todos WHERE users_id = ? ORDER BY id DESC').all(users_id),
|
||||||
|
createTodo: (users_id, title) =>
|
||||||
|
db.prepare('INSERT INTO todos (users_id, title) VALUES (?, ?)').run(users_id, title),
|
||||||
|
updateTodo: (id, { title, completed }) =>
|
||||||
|
db.prepare('UPDATE todos SET title = COALESCE(?, title), completed = COALESCE(?, completed) WHERE id = ?')
|
||||||
|
.run(title ?? null, typeof completed === 'number' ? completed : null, id),
|
||||||
|
removeTodo: (id) => db.prepare('DELETE FROM todos WHERE id = ?').run(id),
|
||||||
|
};
|
||||||
50
discord.js
Normal file
50
discord.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
const axios = require("axios");
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
const BOT_ID = "1410578978712060024";
|
||||||
|
const BOT_SECRET = "vhk6jp_jYjvShOqpI8MJ2Efjjm_9Cmyi";
|
||||||
|
const REDIRECT_URI = "http://localhost:3000/auth/discord/callback";
|
||||||
|
|
||||||
|
exports.handleDiscordAuth = async (request, reply) => {
|
||||||
|
const code = request.query.code;
|
||||||
|
if (!code) return reply.code(400).send({ error: "Code de validation manquant" });
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("client_id", BOT_ID);
|
||||||
|
params.append("client_secret", BOT_SECRET);
|
||||||
|
params.append("grant_type", "authorization_code");
|
||||||
|
params.append("code", code);
|
||||||
|
params.append("redirect_uri", REDIRECT_URI);
|
||||||
|
params.append("scope", "identify email");
|
||||||
|
const tokenData = await axios.post(
|
||||||
|
"https://discord.com/api/oauth2/token",
|
||||||
|
params,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const accessToken = tokenData.data.access_token;
|
||||||
|
const userResponse = await axios.get("https://discord.com/api/users/@me", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const userData = userResponse.data;
|
||||||
|
if (!userData || !userData.id) {
|
||||||
|
console.error('Réponse Discord inattendue:', userResponse.data);
|
||||||
|
return reply.code(500).send({ error: "Impossible de récupérer les infos utilisateur depuis Discord", details: userResponse.data });
|
||||||
|
}
|
||||||
|
let savedUser = db.getUserByDiscordId(userData.id);
|
||||||
|
if (!savedUser) {
|
||||||
|
db.createUser(userData.username, userData.id);
|
||||||
|
savedUser = db.getUserByDiscordId(userData.id);
|
||||||
|
}
|
||||||
|
request.session.user = savedUser;
|
||||||
|
return reply.redirect('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.response?.data || err.message);
|
||||||
|
return reply.code(500).send({ error: "Erreur lors de la connexion à Discord" });
|
||||||
|
}
|
||||||
|
};
|
||||||
2297
package-lock.json
generated
Normal file
2297
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "totolist",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@git.lehub.tf:2222/SchoolTask/totolist.git"
|
||||||
|
},
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/formbody": "^8.0.2",
|
||||||
|
"@fastify/session": "^11.1.0",
|
||||||
|
"@fastify/static": "^8.2.0",
|
||||||
|
"@fastify/swagger": "^9.5.1",
|
||||||
|
"@fastify/swagger-ui": "^5.2.3",
|
||||||
|
"@fastify/view": "^11.1.1",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"fastify": "^5.5.0",
|
||||||
|
"nunjucks": "^3.2.4",
|
||||||
|
"prom-client": "^15.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
242
public/style.css
Normal file
242
public/style.css
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
--primary-bg: #0b0c10;
|
||||||
|
--header-bg: #111217;
|
||||||
|
--border: #1e1f27;
|
||||||
|
--input-bg: #14151c;
|
||||||
|
--input-border: #262837;
|
||||||
|
--button-bg: #1a1c27;
|
||||||
|
--button-border: #2a2d3e;
|
||||||
|
--danger-bg: #2a0f15;
|
||||||
|
--danger-border: #3c1e26;
|
||||||
|
--accent: #9ecbff;
|
||||||
|
--text: #eaeaea;
|
||||||
|
--muted: #8892a6;
|
||||||
|
--done: #8aa;
|
||||||
|
--discord: #5865F2;
|
||||||
|
--discord-hover: #4752C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--header-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a:hover {
|
||||||
|
background: #1a2333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, .discordAuth {
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--button-border);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: filter 0.2s, background 0.2s, border 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover, .discordAuth:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
border-color: var(--danger-border);
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
li > div {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.done {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--done);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discord Auth Button */
|
||||||
|
.discordAuth {
|
||||||
|
background: var(--discord);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: 0 2px 8px 0 #23272a22;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discordAuth::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: url('https://cdn.jsdelivr.net/gh/edent/SuperTinyIcons/images/svg/discord.svg') no-repeat center/contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discordAuth:hover {
|
||||||
|
background: var(--discord-hover);
|
||||||
|
box-shadow: 0 4px 16px 0 #23272a33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: #e74c3c;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: 0 2px 8px 0 #e74c3c22;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/box-arrow-left.svg') no-repeat center/contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
box-shadow: 0 4px 16px 0 #e74c3c33;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background: var(--header-bg);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 16px 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn, .delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background: #26323833;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #e74c3c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
144
routes/api.js
Normal file
144
routes/api.js
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
const fastifySwagger = require('@fastify/swagger');
|
||||||
|
const fastifySwaggerUI = require('@fastify/swagger-ui');
|
||||||
|
|
||||||
|
module.exports = async function (app, opts) {
|
||||||
|
const db = require('../db');
|
||||||
|
const promClient = require('prom-client');
|
||||||
|
|
||||||
|
// --- Schémas pour Swagger ---
|
||||||
|
const Todo = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
completed: { type: 'integer', enum: [0, 1] },
|
||||||
|
created_at: { type: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const TodoCreate = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['title'],
|
||||||
|
properties: { title: { type: 'string' } },
|
||||||
|
};
|
||||||
|
const TodoUpdate = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string' },
|
||||||
|
completed: { type: 'integer', enum: [0, 1] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Prometheus ---
|
||||||
|
const register = opts.promRegister;
|
||||||
|
const counterCreated = opts.counterCreated;
|
||||||
|
const counterDeleted = opts.counterDeleted;
|
||||||
|
const gaugeCount = opts.gaugeCount;
|
||||||
|
|
||||||
|
function refreshGauge() {
|
||||||
|
try {
|
||||||
|
const count = db.all().length;
|
||||||
|
gaugeCount.set(count);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/todos', {
|
||||||
|
schema: {
|
||||||
|
response: { 200: { type: 'array', items: Todo } },
|
||||||
|
tags: ['todos'],
|
||||||
|
},
|
||||||
|
}, async () => db.allTodos());
|
||||||
|
|
||||||
|
app.get('/api/todos/:id', {
|
||||||
|
schema: {
|
||||||
|
params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
|
||||||
|
response: { 200: Todo, 404: { type: 'object', properties: { message: { type: 'string' } } } },
|
||||||
|
tags: ['todos'],
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const todo = db.getTodo(req.params.id);
|
||||||
|
if (!todo) return reply.code(404).send({ message: 'Not found' });
|
||||||
|
return todo;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/todos', {
|
||||||
|
schema: {
|
||||||
|
body: TodoCreate,
|
||||||
|
response: { 201: Todo },
|
||||||
|
tags: ['todos'],
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { title } = req.body;
|
||||||
|
const users_id = req.session?.user?.id || 1;
|
||||||
|
const result = db.createTodo(users_id, title);
|
||||||
|
counterCreated.inc();
|
||||||
|
refreshGauge();
|
||||||
|
const created = db.getTodo(result.lastInsertRowid);
|
||||||
|
reply.code(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/todos/:id', {
|
||||||
|
schema: {
|
||||||
|
params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
|
||||||
|
body: TodoUpdate,
|
||||||
|
response: { 200: Todo, 404: { type: 'object', properties: { message: { type: 'string' } } } },
|
||||||
|
tags: ['todos'],
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
if (!db.getTodo(id)) return reply.code(404).send({ message: 'Not found' });
|
||||||
|
db.updateTodo(id, req.body || {});
|
||||||
|
const updated = db.getTodo(id);
|
||||||
|
refreshGauge();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/todos/:id', {
|
||||||
|
schema: {
|
||||||
|
params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
|
||||||
|
response: { 204: { type: 'null' } },
|
||||||
|
tags: ['todos'],
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const existing = db.getTodo(id);
|
||||||
|
if (existing) {
|
||||||
|
db.removeTodo(id);
|
||||||
|
counterDeleted.inc();
|
||||||
|
refreshGauge();
|
||||||
|
}
|
||||||
|
reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour voir tous les users
|
||||||
|
app.get('/api/users', {
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
pseudo: { type: 'string' },
|
||||||
|
discord_id: { type: 'string' },
|
||||||
|
created_at: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['users'],
|
||||||
|
}
|
||||||
|
}, async () => db.allUsers());
|
||||||
|
|
||||||
|
app.register(fastifySwagger, {
|
||||||
|
openapi: {
|
||||||
|
info: {
|
||||||
|
title: 'Todo API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'CRUD de Todo en Fastify (Node.js) + SQLite',
|
||||||
|
},
|
||||||
|
servers: [{ url: 'http://localhost:3000' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
26
routes/other.js
Normal file
26
routes/other.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
module.exports = async function (app, opts) {
|
||||||
|
const register = opts.promRegister;
|
||||||
|
|
||||||
|
app.get('/', async (req, reply) => reply.redirect('/ui'));
|
||||||
|
|
||||||
|
app.get('/metrics', async (req, reply) => {
|
||||||
|
reply.header('Content-Type', register.contentType);
|
||||||
|
return register.metrics();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Auth Discord ---
|
||||||
|
app.get('/auth/discord', async (req, reply) => {
|
||||||
|
const clientId = '1410578978712060024';
|
||||||
|
const redirectUri = 'http://localhost:3000/auth/discord/callback';
|
||||||
|
const scope = 'identify email';
|
||||||
|
const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}`;
|
||||||
|
reply.redirect(discordAuthUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/discord/callback', require('./../discord').handleDiscordAuth);
|
||||||
|
|
||||||
|
app.get('/logout', async (req, reply) => {
|
||||||
|
req.session.destroy();
|
||||||
|
reply.redirect('/');
|
||||||
|
});
|
||||||
|
};
|
||||||
51
routes/ui.js
Normal file
51
routes/ui.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
module.exports = async function (app, opts) {
|
||||||
|
const db = require('../db');
|
||||||
|
const counterCreated = opts.counterCreated;
|
||||||
|
const counterDeleted = opts.counterDeleted;
|
||||||
|
const gaugeCount = opts.gaugeCount;
|
||||||
|
|
||||||
|
function refreshGauge() {
|
||||||
|
try {
|
||||||
|
const count = db.all().length;
|
||||||
|
gaugeCount.set(count);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/ui', async (req, reply) => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
return reply.view('login_required.ejs', { user: req.session.user });
|
||||||
|
}
|
||||||
|
const todos = db.getTodosByUser(req.session.user.id);
|
||||||
|
return reply.view('index.ejs', { todos, user: req.session.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/ui/todos', async (req, reply) => {
|
||||||
|
const title = (req.body?.title || '').trim();
|
||||||
|
if (title) {
|
||||||
|
db.createTodo(req.session.user.id, title);
|
||||||
|
counterCreated.inc();
|
||||||
|
refreshGauge();
|
||||||
|
}
|
||||||
|
reply.redirect('/ui');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/ui/todos/:id/toggle', async (req, reply) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const todo = db.getTodo(id);
|
||||||
|
if (todo) {
|
||||||
|
db.updateTodo(id, { completed: todo.completed ? 0 : 1 });
|
||||||
|
refreshGauge();
|
||||||
|
}
|
||||||
|
reply.redirect('/ui');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/ui/todos/:id/delete', async (req, reply) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (db.getTodo(id)) {
|
||||||
|
db.removeTodo(id);
|
||||||
|
counterDeleted.inc();
|
||||||
|
refreshGauge();
|
||||||
|
}
|
||||||
|
reply.redirect('/ui');
|
||||||
|
});
|
||||||
|
};
|
||||||
52
views/index.ejs
Normal file
52
views/index.ejs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<section class="new">
|
||||||
|
<form method="post" action="/ui/todos">
|
||||||
|
<input type="text" name="title" placeholder="Nouvelle tâche..." required />
|
||||||
|
<button type="submit">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="list">
|
||||||
|
<% if (todos.length === 0) { %>
|
||||||
|
<p>Aucune tâche pour le moment.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul>
|
||||||
|
<% todos.forEach(function(t) { %>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/ui/todos/<%= t.id %>/toggle" style="display:inline;">
|
||||||
|
<button title="Basculer" class="toggle-btn">
|
||||||
|
<% if (t.completed) { %>
|
||||||
|
<!-- Icône coche remplie -->
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="#4CAF50" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="10" fill="#4CAF50"/>
|
||||||
|
<polyline points="6,11 9,14 14,7" fill="none" stroke="#fff" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<% } else { %>
|
||||||
|
<!-- Icône cercle vide -->
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="#bbb" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="9"/>
|
||||||
|
</svg>
|
||||||
|
<% } %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="<%= t.completed ? 'done' : '' %>">
|
||||||
|
<span><%= t.title %></span>
|
||||||
|
<div class="todo-meta">
|
||||||
|
<small>#<%= t.id %> — <%= t.created_at %></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/ui/todos/<%= t.id %>/delete" style="display:inline;">
|
||||||
|
<button class="delete-btn" title="Supprimer">
|
||||||
|
<!-- Icône poubelle moderne -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
41
views/layout.ejs
Normal file
41
views/layout.ejs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title><%= typeof title !== 'undefined' ? title : "Todo App" %></title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Todo App</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/ui">UI</a>
|
||||||
|
<a href="/api/docs">Swagger</a>
|
||||||
|
<a href="/metrics">Metrics</a>
|
||||||
|
<% if (!user) { %>
|
||||||
|
<a class="discordAuth" href="/auth/discord">Discord Connect</a>
|
||||||
|
<% } else { %>
|
||||||
|
<a class="logout" href="/logout">Déconnexion</a>
|
||||||
|
<% } %>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<%- body %>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<div class="footer-content">
|
||||||
|
<span>
|
||||||
|
© <a href="https://mecdu.dev" target="_blank" rel="noopener" class="footer-link">mecdu.dev</a>
|
||||||
|
</span>
|
||||||
|
<% if (user && user.pseudo) { %>
|
||||||
|
<span style="margin-left:16px; color:var(--muted);">
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
<span style="margin-left:16px; color:var(--accent);">
|
||||||
|
Connecté en tant que <strong><%= user.pseudo %></strong>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
views/login_required.ejs
Normal file
4
views/login_required.ejs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div style="text-align:center; margin-top: 60px;">
|
||||||
|
<h2>Il faut que tu te connectes sur Discord, petit fou !</h2>
|
||||||
|
<a class="discordAuth" href="/auth/discord">Connexion Discord</a>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Reference in a new issue