feat: Manage app version from admin panel
Removes GitHub dependency for app version display. Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
co-authored by
mathieumoran1
parent
ad211b11bb
commit
f5bd8709a0
@@ -15,7 +15,7 @@ Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep
|
|||||||
- Resume playback: per-user playback progress saving (time/duration)
|
- Resume playback: per-user playback progress saving (time/duration)
|
||||||
- Global settings: active Oscar year, “completion” modal content for checklist
|
- Global settings: active Oscar year, “completion” modal content for checklist
|
||||||
- Password flows: admin temp password reset + forced change on next login
|
- Password flows: admin temp password reset + forced change on next login
|
||||||
- Version endpoint backed by latest GitHub commit (cached)
|
- App version display (footer) managed from Admin panel (no GitHub dependency)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
Create a `.env` file at the project root (same folder as `package.json`).
|
Create a `.env` file at the project root (same folder as `package.json`).
|
||||||
@@ -37,8 +37,6 @@ JWT_SECRET=replace-with-a-random-string # JWT signing secret (auth)
|
|||||||
# optional
|
# optional
|
||||||
DOG_NAMES=woof,WOOF,Woof # simple registration "verification"
|
DOG_NAMES=woof,WOOF,Woof # simple registration "verification"
|
||||||
OMDB_API=your_omdb_key # OMDb API key (movie info)
|
OMDB_API=your_omdb_key # OMDb API key (movie info)
|
||||||
GITHUB_OWNER=quoije # version history
|
|
||||||
GITHUB_REPO=oscars-pool # version history
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate a good `JWT_SECRET`:
|
Generate a good `JWT_SECRET`:
|
||||||
|
|||||||
@@ -65,6 +65,11 @@
|
|||||||
Réglages
|
Réglages
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="admin-version-tab" data-bs-toggle="tab" data-bs-target="#admin-version" type="button" role="tab" aria-controls="admin-version" aria-selected="false">
|
||||||
|
Version
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="admin-movies-tab" data-bs-toggle="tab" data-bs-target="#admin-movies" type="button" role="tab" aria-controls="admin-movies" aria-selected="false">
|
<button class="nav-link" id="admin-movies-tab" data-bs-toggle="tab" data-bs-target="#admin-movies" type="button" role="tab" aria-controls="admin-movies" aria-selected="false">
|
||||||
Films
|
Films
|
||||||
@@ -146,6 +151,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="admin-version" role="tabpanel" aria-labelledby="admin-version-tab">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-xxl-6">
|
||||||
|
<div class="card p-3">
|
||||||
|
<h4 class="mb-1">Version active (footer)</h4>
|
||||||
|
<div class="text-muted mb-3">
|
||||||
|
Remplace complètement le checker GitHub. La version affichée dans le footer vient de la DB et peut être changée ici.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="app_version_alert" class="alert d-none" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="app_version_active_select" class="form-label">Choisir la version active</label>
|
||||||
|
<select id="app_version_active_select" class="form-select"></select>
|
||||||
|
<div class="form-text">Cette sélection impacte ce que les utilisateurs voient dans le footer.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||||
|
<button type="button" id="app_version_reload" class="btn btn-outline-primary">Recharger</button>
|
||||||
|
<button type="button" id="app_version_set_active" class="btn btn-warning">Définir active</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="text-muted small mb-1">Aperçu (format footer)</div>
|
||||||
|
<div id="app_version_preview" class="border rounded p-2 bg-light">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xxl-6">
|
||||||
|
<div class="card p-3">
|
||||||
|
<h4 class="mb-1">Créer une nouvelle version</h4>
|
||||||
|
<div class="text-muted mb-3">
|
||||||
|
Ajoute une entrée dans l’historique, puis tu peux la rendre active.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="app_version_new_version" class="form-label">Version</label>
|
||||||
|
<input id="app_version_new_version" type="text" class="form-control" placeholder="ex: 1.0.1" maxlength="80">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label for="app_version_new_message" class="form-label">Message (optionnel)</label>
|
||||||
|
<input id="app_version_new_message" type="text" class="form-control" placeholder="ex: Hotfix login" maxlength="500">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex flex-wrap justify-content-end gap-2">
|
||||||
|
<button type="button" id="app_version_create" class="btn btn-primary">Créer</button>
|
||||||
|
<button type="button" id="app_version_create_and_activate" class="btn btn-warning">Créer & activer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="text-muted small mb-2">Historique</div>
|
||||||
|
<div id="app_version_list" class="border rounded p-2 bg-light" style="white-space: pre-wrap;">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade" id="admin-modal-100" role="tabpanel" aria-labelledby="admin-modal-100-tab">
|
<div class="tab-pane fade" id="admin-modal-100" role="tabpanel" aria-labelledby="admin-modal-100-tab">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-xxl-6">
|
<div class="col-12 col-xxl-6">
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ window.onload = async function () {
|
|||||||
const activeYearInput = document.getElementById('active_oscar_year');
|
const activeYearInput = document.getElementById('active_oscar_year');
|
||||||
const saveActiveYearBtn = document.getElementById('save-active-year');
|
const saveActiveYearBtn = document.getElementById('save-active-year');
|
||||||
|
|
||||||
|
// Version control tab elements
|
||||||
|
const adminVersionTabBtn = document.getElementById('admin-version-tab');
|
||||||
|
const appVersionAlertEl = document.getElementById('app_version_alert');
|
||||||
|
const appVersionActiveSelectEl = document.getElementById('app_version_active_select');
|
||||||
|
const appVersionReloadBtn = document.getElementById('app_version_reload');
|
||||||
|
const appVersionSetActiveBtn = document.getElementById('app_version_set_active');
|
||||||
|
const appVersionPreviewEl = document.getElementById('app_version_preview');
|
||||||
|
const appVersionNewVersionEl = document.getElementById('app_version_new_version');
|
||||||
|
const appVersionNewMessageEl = document.getElementById('app_version_new_message');
|
||||||
|
const appVersionCreateBtn = document.getElementById('app_version_create');
|
||||||
|
const appVersionCreateAndActivateBtn = document.getElementById('app_version_create_and_activate');
|
||||||
|
const appVersionListEl = document.getElementById('app_version_list');
|
||||||
|
|
||||||
// Oscar date per year (settings tab)
|
// Oscar date per year (settings tab)
|
||||||
const oscarDateYearSelect = document.getElementById('oscar_date_year');
|
const oscarDateYearSelect = document.getElementById('oscar_date_year');
|
||||||
const oscarDateValueInput = document.getElementById('oscar_date_value');
|
const oscarDateValueInput = document.getElementById('oscar_date_value');
|
||||||
@@ -106,6 +119,8 @@ window.onload = async function () {
|
|||||||
let winnerUsers = []; // admin list: {id, name, email, ...}
|
let winnerUsers = []; // admin list: {id, name, email, ...}
|
||||||
let oscarDatesLoadedOnce = false;
|
let oscarDatesLoadedOnce = false;
|
||||||
let oscarDatesByYear = {}; // { "2026": "2026-03-15" }
|
let oscarDatesByYear = {}; // { "2026": "2026-03-15" }
|
||||||
|
let appVersionLoadedOnce = false;
|
||||||
|
let appVersionState = { active: null, versions: [] };
|
||||||
|
|
||||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||||
title: 'Félicitations! very nice 🎉🎉🎉',
|
title: 'Félicitations! very nice 🎉🎉🎉',
|
||||||
@@ -120,6 +135,15 @@ window.onload = async function () {
|
|||||||
responseEl.textContent = message;
|
responseEl.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(raw) {
|
||||||
|
return String(raw || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeHtml(raw) {
|
function sanitizeHtml(raw) {
|
||||||
const html = String(raw || '');
|
const html = String(raw || '');
|
||||||
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
|
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
|
||||||
@@ -144,6 +168,206 @@ window.onload = async function () {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAppVersionState() {
|
||||||
|
const res = await fetch('/api/settings/app-version', { method: 'GET' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
active: data?.active && typeof data.active === 'object' ? data.active : null,
|
||||||
|
versions: Array.isArray(data?.versions) ? data.versions : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAppVersionEntry({ version, message, activate }) {
|
||||||
|
const res = await fetch(`/api/settings/app-version?activate=${activate ? 'true' : 'false'}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ version, message }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
active: data?.active && typeof data.active === 'object' ? data.active : null,
|
||||||
|
versions: Array.isArray(data?.versions) ? data.versions : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActiveAppVersion(id) {
|
||||||
|
const res = await fetch('/api/settings/app-version/active', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
active: data?.active && typeof data.active === 'object' ? data.active : null,
|
||||||
|
versions: Array.isArray(data?.versions) ? data.versions : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAppVersionEntry(id) {
|
||||||
|
const res = await fetch(`/api/settings/app-version/${encodeURIComponent(String(id))}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
active: data?.active && typeof data.active === 'object' ? data.active : null,
|
||||||
|
versions: Array.isArray(data?.versions) ? data.versions : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAppVersionAlert(kind, message) {
|
||||||
|
if (!appVersionAlertEl) return;
|
||||||
|
appVersionAlertEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
|
||||||
|
appVersionAlertEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
|
||||||
|
appVersionAlertEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAppVersionAlert() {
|
||||||
|
if (!appVersionAlertEl) return;
|
||||||
|
appVersionAlertEl.classList.add('d-none');
|
||||||
|
appVersionAlertEl.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAppVersionPreview() {
|
||||||
|
if (!appVersionPreviewEl) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/version', { method: 'GET' });
|
||||||
|
if (!res.ok) throw new Error(`Erreur (${res.status})`);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const line = `${data?.date || ''} - ${data?.version || ''} - ${data?.message || ''}`.trim();
|
||||||
|
appVersionPreviewEl.textContent = line || '—';
|
||||||
|
appVersionPreviewEl.style.whiteSpace = 'pre-wrap';
|
||||||
|
} catch (_) {
|
||||||
|
appVersionPreviewEl.textContent = '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAppVersionUI() {
|
||||||
|
if (!appVersionActiveSelectEl || !appVersionListEl) return;
|
||||||
|
const versions = Array.isArray(appVersionState.versions) ? appVersionState.versions : [];
|
||||||
|
const activeId = appVersionState?.active?.id ? String(appVersionState.active.id) : '';
|
||||||
|
|
||||||
|
const previous = String(appVersionActiveSelectEl.value || '');
|
||||||
|
appVersionActiveSelectEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (!versions.length) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = 'Aucune version (crée-en une)';
|
||||||
|
opt.disabled = true;
|
||||||
|
opt.selected = true;
|
||||||
|
appVersionActiveSelectEl.appendChild(opt);
|
||||||
|
appVersionActiveSelectEl.disabled = true;
|
||||||
|
if (appVersionSetActiveBtn) appVersionSetActiveBtn.disabled = true;
|
||||||
|
} else {
|
||||||
|
versions.forEach((v) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(v?.id || '');
|
||||||
|
const dateLabel = v?.dateISO ? formatDateTime(v.dateISO) : '—';
|
||||||
|
const msg = String(v?.message || '').trim();
|
||||||
|
const shortMsg = msg.length > 80 ? `${msg.slice(0, 77)}...` : msg;
|
||||||
|
opt.textContent = `${v?.version || '—'} — ${dateLabel}${shortMsg ? ` — ${shortMsg}` : ''}`;
|
||||||
|
appVersionActiveSelectEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stillExists = versions.map((v) => String(v?.id || '')).includes(previous);
|
||||||
|
const desired = activeId || (stillExists ? previous : String(versions[0]?.id || ''));
|
||||||
|
appVersionActiveSelectEl.value = desired;
|
||||||
|
appVersionActiveSelectEl.disabled = false;
|
||||||
|
if (appVersionSetActiveBtn) appVersionSetActiveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!versions.length) {
|
||||||
|
appVersionListEl.textContent = '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appVersionListEl.innerHTML = versions.map((v) => {
|
||||||
|
const id = String(v?.id || '');
|
||||||
|
const isActive = activeId && id === activeId;
|
||||||
|
const dateLabel = v?.dateISO ? formatDateTime(v.dateISO) : '—';
|
||||||
|
const msg = escapeHtml(String(v?.message || '').trim());
|
||||||
|
return `
|
||||||
|
<div class="d-flex justify-content-between align-items-start border rounded px-2 py-2 mb-2 bg-white">
|
||||||
|
<div class="me-2">
|
||||||
|
<div class="fw-semibold">
|
||||||
|
${escapeHtml(String(v?.version || '—'))}
|
||||||
|
${isActive ? '<span class="badge bg-warning text-dark ms-1">Active</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">${escapeHtml(dateLabel)}${msg ? ` — ${msg}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" data-app-version-delete="${escapeHtml(id)}">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
appVersionListEl.querySelectorAll('button[data-app-version-delete]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-app-version-delete');
|
||||||
|
if (!id) return;
|
||||||
|
const ok = window.confirm('Supprimer cette version ?');
|
||||||
|
if (!ok) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
const old = btn.textContent;
|
||||||
|
btn.textContent = '...';
|
||||||
|
try {
|
||||||
|
hideAppVersionAlert();
|
||||||
|
appVersionState = await deleteAppVersionEntry(id);
|
||||||
|
appVersionLoadedOnce = true;
|
||||||
|
renderAppVersionUI();
|
||||||
|
await refreshAppVersionPreview();
|
||||||
|
setAppVersionAlert('success', 'Version supprimée.');
|
||||||
|
} catch (err) {
|
||||||
|
setAppVersionAlert('danger', err.message || 'Erreur réseau');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = old;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAppVersionTab(options = {}) {
|
||||||
|
const force = !!options.force;
|
||||||
|
if (!appVersionActiveSelectEl || !appVersionListEl) return;
|
||||||
|
if (appVersionLoadedOnce && !force) return;
|
||||||
|
try {
|
||||||
|
hideAppVersionAlert();
|
||||||
|
appVersionState = await fetchAppVersionState();
|
||||||
|
appVersionLoadedOnce = true;
|
||||||
|
renderAppVersionUI();
|
||||||
|
await refreshAppVersionPreview();
|
||||||
|
} catch (err) {
|
||||||
|
appVersionLoadedOnce = true;
|
||||||
|
appVersionState = { active: null, versions: [] };
|
||||||
|
renderAppVersionUI();
|
||||||
|
await refreshAppVersionPreview();
|
||||||
|
setAppVersionAlert('danger', err.message || 'Erreur réseau');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCompletionModal() {
|
async function fetchCompletionModal() {
|
||||||
const res = await fetch('/api/settings/completion-modal', { method: 'GET' });
|
const res = await fetch('/api/settings/completion-modal', { method: 'GET' });
|
||||||
if (!res.ok) throw new Error(`Erreur (${res.status})`);
|
if (!res.ok) throw new Error(`Erreur (${res.status})`);
|
||||||
@@ -1389,6 +1613,84 @@ window.onload = async function () {
|
|||||||
if (adminBackupTabBtn) {
|
if (adminBackupTabBtn) {
|
||||||
adminBackupTabBtn.addEventListener('shown.bs.tab', () => loadBackups({ force: false }));
|
adminBackupTabBtn.addEventListener('shown.bs.tab', () => loadBackups({ force: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version tab wiring
|
||||||
|
if (adminVersionTabBtn) {
|
||||||
|
adminVersionTabBtn.addEventListener('shown.bs.tab', () => loadAppVersionTab({ force: false }));
|
||||||
|
}
|
||||||
|
if (appVersionReloadBtn) {
|
||||||
|
appVersionReloadBtn.addEventListener('click', async () => {
|
||||||
|
appVersionReloadBtn.disabled = true;
|
||||||
|
const old = appVersionReloadBtn.textContent;
|
||||||
|
appVersionReloadBtn.textContent = 'Chargement...';
|
||||||
|
try {
|
||||||
|
await loadAppVersionTab({ force: true });
|
||||||
|
setAppVersionAlert('success', 'Rechargé.');
|
||||||
|
} finally {
|
||||||
|
appVersionReloadBtn.disabled = false;
|
||||||
|
appVersionReloadBtn.textContent = old;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (appVersionSetActiveBtn && appVersionActiveSelectEl) {
|
||||||
|
appVersionSetActiveBtn.addEventListener('click', async () => {
|
||||||
|
const id = String(appVersionActiveSelectEl.value || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
appVersionSetActiveBtn.disabled = true;
|
||||||
|
const old = appVersionSetActiveBtn.textContent;
|
||||||
|
appVersionSetActiveBtn.textContent = '...';
|
||||||
|
try {
|
||||||
|
hideAppVersionAlert();
|
||||||
|
appVersionState = await setActiveAppVersion(id);
|
||||||
|
appVersionLoadedOnce = true;
|
||||||
|
renderAppVersionUI();
|
||||||
|
await refreshAppVersionPreview();
|
||||||
|
setAppVersionAlert('success', 'Version active mise à jour.');
|
||||||
|
} catch (err) {
|
||||||
|
setAppVersionAlert('danger', err.message || 'Erreur réseau');
|
||||||
|
} finally {
|
||||||
|
appVersionSetActiveBtn.disabled = false;
|
||||||
|
appVersionSetActiveBtn.textContent = old;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateAppVersion(activate) {
|
||||||
|
const version = String(appVersionNewVersionEl?.value || '').trim();
|
||||||
|
const message = String(appVersionNewMessageEl?.value || '').trim();
|
||||||
|
if (!version) {
|
||||||
|
setAppVersionAlert('warning', 'Version requise (ex: 1.0.0).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = activate ? appVersionCreateAndActivateBtn : appVersionCreateBtn;
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
const old = btn.textContent;
|
||||||
|
btn.textContent = '...';
|
||||||
|
try {
|
||||||
|
hideAppVersionAlert();
|
||||||
|
appVersionState = await createAppVersionEntry({ version, message, activate });
|
||||||
|
appVersionLoadedOnce = true;
|
||||||
|
if (appVersionNewVersionEl) appVersionNewVersionEl.value = '';
|
||||||
|
if (appVersionNewMessageEl) appVersionNewMessageEl.value = '';
|
||||||
|
renderAppVersionUI();
|
||||||
|
await refreshAppVersionPreview();
|
||||||
|
setAppVersionAlert('success', activate ? 'Version créée et activée.' : 'Version créée.');
|
||||||
|
} catch (err) {
|
||||||
|
setAppVersionAlert('danger', err.message || 'Erreur réseau');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appVersionCreateBtn) {
|
||||||
|
appVersionCreateBtn.addEventListener('click', () => handleCreateAppVersion(false));
|
||||||
|
}
|
||||||
|
if (appVersionCreateAndActivateBtn) {
|
||||||
|
appVersionCreateAndActivateBtn.addEventListener('click', () => handleCreateAppVersion(true));
|
||||||
|
}
|
||||||
|
|
||||||
if (dbBackupRefreshBtn) {
|
if (dbBackupRefreshBtn) {
|
||||||
dbBackupRefreshBtn.addEventListener('click', async () => {
|
dbBackupRefreshBtn.addEventListener('click', async () => {
|
||||||
dbBackupRefreshBtn.disabled = true;
|
dbBackupRefreshBtn.disabled = true;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const ACTIVE_YEAR_KEY = "active_oscar_year";
|
|||||||
const COMPLETION_MODAL_KEY = "completion_modal_content";
|
const COMPLETION_MODAL_KEY = "completion_modal_content";
|
||||||
const WINNERS_BY_YEAR_KEY = "winners_by_year";
|
const WINNERS_BY_YEAR_KEY = "winners_by_year";
|
||||||
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
|
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
|
||||||
|
const APP_VERSION_KEY = "app_version_control";
|
||||||
|
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||||
title: "Félicitations! very nice 🎉🎉🎉",
|
title: "Félicitations! very nice 🎉🎉🎉",
|
||||||
@@ -143,6 +146,51 @@ function normalizeCompletionModal(rawValue) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createId() {
|
||||||
|
try {
|
||||||
|
// Node 16+ / 18+: randomUUID
|
||||||
|
if (crypto && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
||||||
|
} catch (_) {}
|
||||||
|
// Fallback: time-based
|
||||||
|
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppVersionControl(rawValue) {
|
||||||
|
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||||
|
const versions = Array.isArray(v.versions) ? v.versions : [];
|
||||||
|
|
||||||
|
const cleaned = versions
|
||||||
|
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
|
||||||
|
.map((e) => {
|
||||||
|
const id = typeof e.id === "string" ? e.id.trim() : "";
|
||||||
|
const version = typeof e.version === "string" ? e.version.trim() : "";
|
||||||
|
const message = typeof e.message === "string" ? e.message : "";
|
||||||
|
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
|
||||||
|
const d = new Date(dateISO);
|
||||||
|
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
return {
|
||||||
|
id: id || null,
|
||||||
|
version: version ? version.slice(0, 80) : null,
|
||||||
|
message: String(message || "").slice(0, 500),
|
||||||
|
dateISO: safeDateISO,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((e) => e.id && e.version)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
|
||||||
|
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
|
||||||
|
return bd - ad;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
|
||||||
|
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
|
||||||
|
versions: cleaned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getOrInitActiveYear() {
|
async function getOrInitActiveYear() {
|
||||||
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
|
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
|
||||||
if (existing && typeof existing.value === "number") return existing.value;
|
if (existing && typeof existing.value === "number") return existing.value;
|
||||||
@@ -369,6 +417,157 @@ router.get("/winners", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public: get app version control (active + list)
|
||||||
|
router.get("/app-version", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||||
|
const control = normalizeAppVersionControl(existing?.value);
|
||||||
|
const active = control.activeId
|
||||||
|
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
|
||||||
|
: null;
|
||||||
|
return res.status(200).json({ active, versions: control.versions });
|
||||||
|
} catch (_) {
|
||||||
|
return res.status(200).json({ active: null, versions: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only: create a new version entry (optionally activates if none or if activate=true)
|
||||||
|
// Body: { version: "1.2.3", message?: "..." }
|
||||||
|
router.post("/app-version", async (req, res) => {
|
||||||
|
const token = req.headers.authorization?.split(" ")[1];
|
||||||
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
if (!decoded.admin) {
|
||||||
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = typeof req.body?.version === "string" ? req.body.version.trim() : "";
|
||||||
|
const message = typeof req.body?.message === "string" ? req.body.message : "";
|
||||||
|
if (!version) return res.status(400).json({ message: "Version requise (ex: 1.0.0)" });
|
||||||
|
|
||||||
|
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||||
|
const control = normalizeAppVersionControl(existing?.value);
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: createId(),
|
||||||
|
version: version.slice(0, 80),
|
||||||
|
message: String(message || "").slice(0, 500),
|
||||||
|
dateISO: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
activeId: control.activeId,
|
||||||
|
versions: [entry, ...(Array.isArray(control.versions) ? control.versions : [])],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wantsActivate = String(req.query?.activate || "").toLowerCase() === "true";
|
||||||
|
if (!next.activeId || wantsActivate) {
|
||||||
|
next.activeId = entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await Setting.findOneAndUpdate(
|
||||||
|
{ key: APP_VERSION_KEY },
|
||||||
|
{ $set: { value: next } },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
).select("value");
|
||||||
|
|
||||||
|
const normalized = normalizeAppVersionControl(updated?.value);
|
||||||
|
const active = normalized.activeId
|
||||||
|
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
|
||||||
|
: null;
|
||||||
|
return res.status(200).json({ active, versions: normalized.versions });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof jwt.JsonWebTokenError) {
|
||||||
|
return res.status(401).json({ message: "Invalid or expired token" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only: set active version by id
|
||||||
|
// Body: { id: "<versionId>" }
|
||||||
|
router.put("/app-version/active", async (req, res) => {
|
||||||
|
const token = req.headers.authorization?.split(" ")[1];
|
||||||
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
if (!decoded.admin) {
|
||||||
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = typeof req.body?.id === "string" ? req.body.id.trim() : "";
|
||||||
|
if (!id) return res.status(400).json({ message: "id requis" });
|
||||||
|
|
||||||
|
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||||
|
const control = normalizeAppVersionControl(existing?.value);
|
||||||
|
const exists = control.versions.find((v) => String(v.id) === String(id));
|
||||||
|
if (!exists) return res.status(404).json({ message: "Version introuvable" });
|
||||||
|
|
||||||
|
const next = { activeId: String(id), versions: control.versions };
|
||||||
|
const updated = await Setting.findOneAndUpdate(
|
||||||
|
{ key: APP_VERSION_KEY },
|
||||||
|
{ $set: { value: next } },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
).select("value");
|
||||||
|
|
||||||
|
const normalized = normalizeAppVersionControl(updated?.value);
|
||||||
|
const active = normalized.activeId
|
||||||
|
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
|
||||||
|
: null;
|
||||||
|
return res.status(200).json({ active, versions: normalized.versions });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof jwt.JsonWebTokenError) {
|
||||||
|
return res.status(401).json({ message: "Invalid or expired token" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only: delete a version entry by id (re-picks active if needed)
|
||||||
|
router.delete("/app-version/:id", async (req, res) => {
|
||||||
|
const token = req.headers.authorization?.split(" ")[1];
|
||||||
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
if (!decoded.admin) {
|
||||||
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = typeof req.params?.id === "string" ? req.params.id.trim() : "";
|
||||||
|
if (!id) return res.status(400).json({ message: "id requis" });
|
||||||
|
|
||||||
|
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||||
|
const control = normalizeAppVersionControl(existing?.value);
|
||||||
|
const nextVersions = control.versions.filter((v) => String(v.id) !== String(id));
|
||||||
|
const next = {
|
||||||
|
activeId: control.activeId && String(control.activeId) === String(id) ? null : control.activeId,
|
||||||
|
versions: nextVersions,
|
||||||
|
};
|
||||||
|
if (!next.activeId && nextVersions.length) next.activeId = String(nextVersions[0].id);
|
||||||
|
|
||||||
|
const updated = await Setting.findOneAndUpdate(
|
||||||
|
{ key: APP_VERSION_KEY },
|
||||||
|
{ $set: { value: next } },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
).select("value");
|
||||||
|
|
||||||
|
const normalized = normalizeAppVersionControl(updated?.value);
|
||||||
|
const active = normalized.activeId
|
||||||
|
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
|
||||||
|
: null;
|
||||||
|
return res.status(200).json({ active, versions: normalized.versions });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof jwt.JsonWebTokenError) {
|
||||||
|
return res.status(401).json({ message: "Invalid or expired token" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Admin-only: add/upsert/clear winners for a given year. Points optional.
|
// Admin-only: add/upsert/clear winners for a given year. Points optional.
|
||||||
// - Clear all for year: { userId: "" } (legacy behavior)
|
// - Clear all for year: { userId: "" } (legacy behavior)
|
||||||
// - Add/upsert winner for year: { userId: "<mongoId>", points?: number|null }
|
// - Add/upsert winner for year: { userId: "<mongoId>", points?: number|null }
|
||||||
|
|||||||
+81
-81
@@ -1,29 +1,23 @@
|
|||||||
const axios = require("axios");
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const moment = require("moment-timezone");
|
const moment = require("moment-timezone");
|
||||||
|
const Setting = require("../models/Setting");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
||||||
const TIMEZONE = "America/New_York";
|
const TIMEZONE = "America/New_York";
|
||||||
|
|
||||||
// Cache is stored in the exact response shape expected by the client.
|
// Stored in Mongo via Setting model (admin-controlled).
|
||||||
let latestCommitCache = null;
|
// Shape:
|
||||||
|
// {
|
||||||
// Avoid noisy logs when GitHub isn't configured or is temporarily unavailable.
|
// activeId: string|null,
|
||||||
let lastLogTime = 0;
|
// versions: [{ id, version, message, dateISO }]
|
||||||
const LOG_THROTTLE_MS = 60 * 60 * 1000; // 1h
|
// }
|
||||||
|
const APP_VERSION_KEY = "app_version_control";
|
||||||
|
|
||||||
function nowFormatted() {
|
function nowFormatted() {
|
||||||
return moment(Date.now()).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss");
|
return moment(Date.now()).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGitHubConfig() {
|
|
||||||
const owner = typeof process.env.GITHUB_OWNER === "string" ? process.env.GITHUB_OWNER.trim() : "";
|
|
||||||
const repo = typeof process.env.GITHUB_REPO === "string" ? process.env.GITHUB_REPO.trim() : "";
|
|
||||||
return { owner, repo, configured: !!owner && !!repo };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPackageVersion() {
|
function getPackageVersion() {
|
||||||
try {
|
try {
|
||||||
// routes/version.js -> ../package.json
|
// routes/version.js -> ../package.json
|
||||||
@@ -34,82 +28,88 @@ function getPackageVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFallbackCache(message, { configured } = { configured: false }) {
|
function normalizeAppVersionControl(rawValue) {
|
||||||
latestCommitCache = {
|
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||||
version: getPackageVersion(),
|
const versions = Array.isArray(v.versions) ? v.versions : [];
|
||||||
message: message || "Version info not available.",
|
|
||||||
author: "",
|
|
||||||
date: nowFormatted(),
|
|
||||||
configured: !!configured,
|
|
||||||
source: "fallback",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function logThrottled(...args) {
|
const cleaned = versions
|
||||||
const t = Date.now();
|
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
|
||||||
if (t - lastLogTime < LOG_THROTTLE_MS) return;
|
.map((e) => {
|
||||||
lastLogTime = t;
|
const id = typeof e.id === "string" ? e.id.trim() : "";
|
||||||
// eslint-disable-next-line no-console
|
const version = typeof e.version === "string" ? e.version.trim() : "";
|
||||||
console.error(...args);
|
const message = typeof e.message === "string" ? e.message.slice(0, 500) : "";
|
||||||
}
|
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
|
||||||
|
const d = new Date(dateISO);
|
||||||
async function fetchLatestCommitFromGitHub() {
|
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
const { owner, repo, configured } = getGitHubConfig();
|
return {
|
||||||
if (!configured) {
|
id: id || null,
|
||||||
// Never attempt GitHub calls if not configured.
|
version: version || null,
|
||||||
setFallbackCache("Version endpoint is not configured.", { configured: false });
|
message: String(message || ""),
|
||||||
return;
|
dateISO: safeDateISO,
|
||||||
}
|
};
|
||||||
|
})
|
||||||
try {
|
.filter((e) => e.id && e.version)
|
||||||
const url = `https://api.github.com/repos/${owner}/${repo}/commits`;
|
.sort((a, b) => {
|
||||||
const response = await axios.get(url, {
|
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
|
||||||
headers: {
|
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
|
||||||
"Content-Type": "application/json",
|
return bd - ad;
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && response.data.length > 0) {
|
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
|
||||||
const latestCommit = response.data[0];
|
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
|
||||||
latestCommitCache = {
|
|
||||||
version: String(latestCommit.sha || "").substring(0, 5),
|
|
||||||
message: latestCommit?.commit?.message || "",
|
|
||||||
author: latestCommit?.commit?.author?.name || "",
|
|
||||||
date: moment(latestCommit?.commit?.author?.date || Date.now())
|
|
||||||
.tz(TIMEZONE)
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss"),
|
|
||||||
configured: true,
|
|
||||||
source: "github",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error("No commits found.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Keep serving a non-500 response, and don't spam logs.
|
|
||||||
logThrottled("Error fetching commit:", error?.message || error);
|
|
||||||
setFallbackCache("GitHub commit data is not available.", { configured: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize cache (without crashing / spamming when not configured).
|
// If activeId is invalid, fall back to newest entry.
|
||||||
const initialCfg = getGitHubConfig();
|
return {
|
||||||
if (!initialCfg.configured) {
|
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
|
||||||
setFallbackCache("Version endpoint is not configured.", { configured: false });
|
versions: cleaned,
|
||||||
} else {
|
};
|
||||||
fetchLatestCommitFromGitHub();
|
|
||||||
const timer = setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION);
|
|
||||||
// Don't keep the process alive just for this polling.
|
|
||||||
if (typeof timer.unref === "function") timer.unref();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to get the latest commit (cached)
|
// Route to get the latest commit (cached)
|
||||||
router.get("/", async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
// Always return something (client clears footer on non-2xx).
|
// Always return 200 and never rely on GitHub.
|
||||||
if (!latestCommitCache) {
|
try {
|
||||||
const cfg = getGitHubConfig();
|
const setting = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||||
setFallbackCache("Version info not available.", { configured: cfg.configured });
|
const control = normalizeAppVersionControl(setting?.value);
|
||||||
|
|
||||||
|
const active = control.activeId
|
||||||
|
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return res.status(200).json({
|
||||||
|
version: getPackageVersion(),
|
||||||
|
message: "Version non configurée (définis-la dans Admin → Version).",
|
||||||
|
author: "",
|
||||||
|
date: nowFormatted(),
|
||||||
|
configured: false,
|
||||||
|
source: "fallback",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = active.dateISO
|
||||||
|
? moment(active.dateISO).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss")
|
||||||
|
: nowFormatted();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
version: active.version,
|
||||||
|
message: active.message || "",
|
||||||
|
author: "",
|
||||||
|
date,
|
||||||
|
configured: true,
|
||||||
|
source: "admin",
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// If DB is down, still return something useful.
|
||||||
|
return res.status(200).json({
|
||||||
|
version: getPackageVersion(),
|
||||||
|
message: "Version indisponible (DB).",
|
||||||
|
author: "",
|
||||||
|
date: nowFormatted(),
|
||||||
|
configured: false,
|
||||||
|
source: "fallback",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.status(200).json(latestCommitCache);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user