mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: octane calendar link that each user can add
This commit is contained in:
parent
fd81b74594
commit
5759a1f248
8 changed files with 163 additions and 2 deletions
|
|
@ -5,4 +5,4 @@ RUN npm install --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "src/server.js"]
|
CMD ["sh", "-c", "npm run migrate && node src/server.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) {
|
async function onSaveDisplayName(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
|
|
@ -158,6 +179,20 @@ async function onResetDisplayName() {
|
||||||
<p class="note"><a href="/calendar-ics-help.html">Comment trouver mon lien ICS ?</a></p>
|
<p class="note"><a href="/calendar-ics-help.html">Comment trouver mon lien ICS ?</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>Répétitions dans mon calendrier</h2>
|
||||||
|
<p class="note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="panel">
|
||||||
|
<form class="inline-form" onsubmit="return false">
|
||||||
|
<input id="ics-feed-url" type="text" readonly>
|
||||||
|
<button type="button" class="secondary icon-btn" id="copy-ics-feed-btn">Copier</button>
|
||||||
|
<a id="ics-feed-open-link" class="pill-link">S'abonner</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p class="empty">
|
<p class="empty">
|
||||||
Identité gérée par Authentik — pour changer votre email ou mot de passe,
|
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');
|
const resetBtn = document.getElementById('reset-display-name-btn');
|
||||||
if (resetBtn) resetBtn.addEventListener('click', onResetDisplayName);
|
if (resetBtn) resetBtn.addEventListener('click', onResetDisplayName);
|
||||||
document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed);
|
document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed);
|
||||||
|
document.getElementById('copy-ics-feed-btn').addEventListener('click', onCopyIcsFeedUrl);
|
||||||
await loadMyFeeds();
|
await loadMyFeeds();
|
||||||
|
await loadIcsFeedUrl();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err.message);
|
showError(err.message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const sessionMiddleware = require('./auth/session');
|
||||||
const authRoutes = require('./auth/routes');
|
const authRoutes = require('./auth/routes');
|
||||||
const { attachUser, requireAuth } = require('./auth/middleware');
|
const { attachUser, requireAuth } = require('./auth/middleware');
|
||||||
const apiRouter = require('./routes');
|
const apiRouter = require('./routes');
|
||||||
|
const calendarFeedRoutes = require('./routes/calendarFeed');
|
||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -19,6 +20,10 @@ function createApp() {
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use(attachUser);
|
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('/api', requireAuth, apiRouter);
|
||||||
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
|
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
|
|
|
||||||
1
src/db/migrations/018_rehearsal_ics_token.sql
Normal file
1
src/db/migrations/018_rehearsal_ics_token.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users ADD COLUMN ics_token TEXT UNIQUE;
|
||||||
56
src/lib/icsFeed.js
Normal file
56
src/lib/icsFeed.js
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const crypto = require('crypto');
|
||||||
const pool = require('../db/pool');
|
const pool = require('../db/pool');
|
||||||
|
|
||||||
const PROFILE_FIELDS =
|
const PROFILE_FIELDS =
|
||||||
|
|
@ -72,4 +73,31 @@ async function findAllWithActivity() {
|
||||||
return rows;
|
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,
|
||||||
|
};
|
||||||
|
|
|
||||||
26
src/routes/calendarFeed.js
Normal file
26
src/routes/calendarFeed.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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(
|
router.patch(
|
||||||
'/me/display-name',
|
'/me/display-name',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue