From 5759a1f248cdb26d1115551ef49eb7fe0017bfa2 Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Fri, 31 Jul 2026 11:30:00 +0200 Subject: [PATCH] update: octane calendar link that each user can add --- Dockerfile | 2 +- public/js/profile.js | 37 ++++++++++++ src/app.js | 5 ++ src/db/migrations/018_rehearsal_ics_token.sql | 1 + src/lib/icsFeed.js | 56 +++++++++++++++++++ src/repositories/usersRepo.js | 30 +++++++++- src/routes/calendarFeed.js | 26 +++++++++ src/routes/users.js | 8 +++ 8 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/db/migrations/018_rehearsal_ics_token.sql create mode 100644 src/lib/icsFeed.js create mode 100644 src/routes/calendarFeed.js diff --git a/Dockerfile b/Dockerfile index e711b8d..cbc5331 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,4 +5,4 @@ RUN npm install --omit=dev COPY . . ENV NODE_ENV=production EXPOSE 3000 -CMD ["node", "src/server.js"] +CMD ["sh", "-c", "npm run migrate && node src/server.js"] diff --git a/public/js/profile.js b/public/js/profile.js index 9e8d9d4..8b0f6b3 100644 --- a/public/js/profile.js +++ b/public/js/profile.js @@ -78,6 +78,27 @@ async function onRemoveMyFeed(feedId) { } } +async function loadIcsFeedUrl() { + const { path } = await api.get('/api/users/me/ics-feed-url'); + const httpsUrl = `${window.location.origin}${path}`; + const webcalUrl = `webcal://${window.location.host}${path}`; + document.getElementById('ics-feed-url').value = webcalUrl; + document.getElementById('ics-feed-open-link').href = webcalUrl; + document.getElementById('copy-ics-feed-btn').dataset.url = httpsUrl; +} + +async function onCopyIcsFeedUrl(e) { + const btn = e.target; + try { + await navigator.clipboard.writeText(btn.dataset.url); + const original = btn.textContent; + btn.textContent = 'Copié !'; + setTimeout(() => { btn.textContent = original; }, 1500); + } catch (err) { + showError("Impossible de copier le lien : " + err.message); + } +} + async function onSaveDisplayName(e) { e.preventDefault(); const form = e.target; @@ -158,6 +179,20 @@ async function onResetDisplayName() {

Comment trouver mon lien ICS ?

+

Répétitions dans mon calendrier

+

+ Abonnez-vous à ce lien depuis Google Calendar, Apple Calendar ou Outlook pour voir les répétitions + (proposées et confirmées) directement dans votre calendrier personnel, mises à jour automatiquement. + Ce lien est personnel, ne le partagez pas. +

+
+
+ + + S'abonner +
+
+

Identité gérée par Authentik — pour changer votre email ou mot de passe, @@ -172,7 +207,9 @@ async function onResetDisplayName() { const resetBtn = document.getElementById('reset-display-name-btn'); if (resetBtn) resetBtn.addEventListener('click', onResetDisplayName); document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed); + document.getElementById('copy-ics-feed-btn').addEventListener('click', onCopyIcsFeedUrl); await loadMyFeeds(); + await loadIcsFeedUrl(); } catch (err) { showError(err.message); } diff --git a/src/app.js b/src/app.js index ef4a3dd..c4c1208 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,7 @@ const sessionMiddleware = require('./auth/session'); const authRoutes = require('./auth/routes'); const { attachUser, requireAuth } = require('./auth/middleware'); const apiRouter = require('./routes'); +const calendarFeedRoutes = require('./routes/calendarFeed'); function createApp() { const app = express(); @@ -19,6 +20,10 @@ function createApp() { app.use('/auth', authRoutes); app.use(attachUser); + // Unauthenticated: subscribed to directly by calendar apps via a secret + // per-user token, not the browser session (see routes/calendarFeed.js). + app.use('/calendar/feed', calendarFeedRoutes); + app.use('/api', requireAuth, apiRouter); app.use(requireAuth, express.static(path.join(__dirname, '../public'))); diff --git a/src/db/migrations/018_rehearsal_ics_token.sql b/src/db/migrations/018_rehearsal_ics_token.sql new file mode 100644 index 0000000..5d04b86 --- /dev/null +++ b/src/db/migrations/018_rehearsal_ics_token.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN ics_token TEXT UNIQUE; diff --git a/src/lib/icsFeed.js b/src/lib/icsFeed.js new file mode 100644 index 0000000..45467c4 --- /dev/null +++ b/src/lib/icsFeed.js @@ -0,0 +1,56 @@ +// Builds a text/calendar feed of rehearsals for subscription (webcal://) in +// external calendar apps. Timestamps are emitted in UTC (`...Z`) so no +// VTIMEZONE block is needed — every client renders them in its own zone. +function formatDateUTC(date) { + return new Date(date).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; +} + +// Per RFC 5545 §3.3.11: backslash-escape commas, semicolons and backslashes, +// and turn newlines into the literal `\n` escape sequence. +function escapeText(text) { + return String(text) + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); +} + +function rehearsalStatus(rehearsal) { + const hasReject = rehearsal.votes.some((v) => v.vote === 'reject'); + const hasAccept = rehearsal.votes.some((v) => v.vote === 'accept'); + if (hasAccept && !hasReject) return 'CONFIRMED'; + return 'TENTATIVE'; +} + +function rehearsalToEvent(rehearsal) { + const status = rehearsalStatus(rehearsal); + const summary = status === 'CONFIRMED' ? 'Répétition' : 'Répétition (proposition)'; + const lines = [ + 'BEGIN:VEVENT', + `UID:rehearsal-${rehearsal.id}@octane`, + `DTSTAMP:${formatDateUTC(rehearsal.created_at || new Date())}`, + `DTSTART:${formatDateUTC(rehearsal.starts_at)}`, + `DTEND:${formatDateUTC(rehearsal.ends_at)}`, + `SUMMARY:${escapeText(summary)}`, + `STATUS:${status}`, + ]; + if (rehearsal.location) lines.push(`LOCATION:${escapeText(rehearsal.location)}`); + lines.push('END:VEVENT'); + return lines; +} + +function buildRehearsalsFeed(rehearsals) { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Octane//Rehearsals//FR', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:Répétitions Octane', + ...rehearsals.flatMap(rehearsalToEvent), + 'END:VCALENDAR', + ]; + return lines.join('\r\n'); +} + +module.exports = { buildRehearsalsFeed }; diff --git a/src/repositories/usersRepo.js b/src/repositories/usersRepo.js index 97f59a3..5bc9fa8 100644 --- a/src/repositories/usersRepo.js +++ b/src/repositories/usersRepo.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const pool = require('../db/pool'); const PROFILE_FIELDS = @@ -72,4 +73,31 @@ async function findAllWithActivity() { return rows; } -module.exports = { findById, upsertFromClaims, updateDisplayName, getActivityStats, findAllWithActivity }; +// Lazily creates the user's rehearsal-feed secret on first request, rather +// than at signup — most users will never subscribe to the ICS feed. +async function ensureIcsToken(userId) { + const { rows } = await pool.query('SELECT ics_token FROM users WHERE id = $1', [userId]); + if (rows[0] && rows[0].ics_token) return rows[0].ics_token; + + const token = crypto.randomBytes(24).toString('hex'); + const { rows: updated } = await pool.query( + 'UPDATE users SET ics_token = $2 WHERE id = $1 RETURNING ics_token', + [userId, token] + ); + return updated[0].ics_token; +} + +async function findByIcsToken(token) { + const { rows } = await pool.query(`SELECT ${PROFILE_FIELDS} FROM users WHERE ics_token = $1`, [token]); + return rows[0] || null; +} + +module.exports = { + findById, + upsertFromClaims, + updateDisplayName, + getActivityStats, + findAllWithActivity, + ensureIcsToken, + findByIcsToken, +}; diff --git a/src/routes/calendarFeed.js b/src/routes/calendarFeed.js new file mode 100644 index 0000000..d0fd290 --- /dev/null +++ b/src/routes/calendarFeed.js @@ -0,0 +1,26 @@ +// Public (unauthenticated) route: calendar apps subscribing to a webcal:// +// link don't send our session cookie, so this can't sit behind requireAuth. +// The per-user token in the URL is the only access control — see +// usersRepo.ensureIcsToken / findByIcsToken. +const express = require('express'); +const usersRepo = require('../repositories/usersRepo'); +const rehearsalsRepo = require('../repositories/rehearsalsRepo'); +const { buildRehearsalsFeed } = require('../lib/icsFeed'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/rehearsals/:token.ics', + asyncHandler(async (req, res) => { + const user = await usersRepo.findByIcsToken(req.params.token); + if (!user) return res.status(404).send('Not found'); + + const rehearsals = await rehearsalsRepo.findUpcoming(); + res + .type('text/calendar; charset=utf-8') + .send(buildRehearsalsFeed(rehearsals)); + }) +); + +module.exports = router; diff --git a/src/routes/users.js b/src/routes/users.js index a5bf971..920dca0 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -48,6 +48,14 @@ router.get( }) ); +router.get( + '/me/ics-feed-url', + asyncHandler(async (req, res) => { + const token = await usersRepo.ensureIcsToken(req.user.id); + res.json({ path: `/calendar/feed/rehearsals/${token}.ics` }); + }) +); + router.patch( '/me/display-name', asyncHandler(async (req, res) => {