Initial Commit

This commit is contained in:
ExostFlash 2025-08-28 13:57:06 +02:00
commit 7647bcd52a
13 changed files with 3097 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
todos.db
todos.db-shm
todos.db-wal
.env

103
app.js Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

32
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>