mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Add directory board widget renderer with scrolling, anti-burn-in, dark/light themes
Lobby-style tenant/room directory with vertical marquee, seamless loop via content cloning, pixel shift + bg pulse for anti-burn-in, rotating background images with crossfade. Supports logo, title, footer, subtitles per entry, and Available (green) state. All user strings rendered via textContent in browser — no server-side HTML escaping of entries needed. Also refactors render dispatch into renderWidgetHtml() and adds a POST /preview endpoint that inlines user-owned image content as base64 data URIs so the editor can preview unsaved widgets. Preview is gated by: - image/* MIME only - 10 MB size cap - user_id ownership check - path traversal guard via basename + resolve Unknown widget_type on /preview returns 400. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a981171c94
commit
08a83c9ba9
|
|
@ -1,7 +1,34 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
|
const appConfig = require('../config');
|
||||||
|
|
||||||
|
// For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs,
|
||||||
|
// scoped to the current user. Lets the srcdoc preview iframe show logos/bg images
|
||||||
|
// before the widget is saved (post-save they're reachable via the widget-reference gate).
|
||||||
|
const MAX_INLINE_BYTES = 10 * 1024 * 1024; // 10MB cap — base64 expands ~1.33x
|
||||||
|
const MIME_RE = /^image\/[a-zA-Z0-9.+-]+$/;
|
||||||
|
function inlineUserContent(html, userId) {
|
||||||
|
return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => {
|
||||||
|
const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, user_id FROM content WHERE id = ?').get(id);
|
||||||
|
if (!c || c.user_id !== userId) return match;
|
||||||
|
const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath;
|
||||||
|
if (!filename) return match;
|
||||||
|
const mime = kind === 'thumbnail' ? 'image/jpeg' : c.mime_type;
|
||||||
|
if (!mime || !MIME_RE.test(mime)) return match;
|
||||||
|
const safe = path.resolve(appConfig.contentDir, path.basename(filename));
|
||||||
|
if (!safe.startsWith(path.resolve(appConfig.contentDir))) return match;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(safe);
|
||||||
|
if (!st.isFile() || st.size > MAX_INLINE_BYTES) return match;
|
||||||
|
const buf = fs.readFileSync(safe);
|
||||||
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
||||||
|
} catch { return match; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Escape HTML to prevent XSS
|
// Escape HTML to prevent XSS
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
|
|
@ -89,37 +116,37 @@ router.delete('/:id', (req, res) => {
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board']);
|
||||||
|
function renderWidgetHtml(type, config) {
|
||||||
|
config = config || {};
|
||||||
|
switch (type) {
|
||||||
|
case 'clock': return renderClock(config);
|
||||||
|
case 'weather': return renderWeather(config);
|
||||||
|
case 'rss': return renderRSS(config);
|
||||||
|
case 'text': return renderText(config);
|
||||||
|
case 'webpage': return renderWebpage(config);
|
||||||
|
case 'social': return renderSocial(config);
|
||||||
|
case 'directory-board': return renderDirectoryBoard(config);
|
||||||
|
default: return '<html><body style="color:white;background:black;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><h1>Unknown widget</h1></body></html>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render widget as HTML page
|
// Render widget as HTML page
|
||||||
router.get('/:id/render', (req, res) => {
|
router.get('/:id/render', (req, res) => {
|
||||||
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
|
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
|
||||||
if (!widget) return res.status(404).send('Widget not found');
|
if (!widget) return res.status(404).send('Widget not found');
|
||||||
|
|
||||||
const config = JSON.parse(widget.config || '{}');
|
const config = JSON.parse(widget.config || '{}');
|
||||||
let html = '';
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
res.send(renderWidgetHtml(widget.widget_type, config));
|
||||||
switch (widget.widget_type) {
|
});
|
||||||
case 'clock':
|
|
||||||
html = renderClock(config);
|
|
||||||
break;
|
|
||||||
case 'weather':
|
|
||||||
html = renderWeather(config);
|
|
||||||
break;
|
|
||||||
case 'rss':
|
|
||||||
html = renderRSS(config);
|
|
||||||
break;
|
|
||||||
case 'text':
|
|
||||||
html = renderText(config);
|
|
||||||
break;
|
|
||||||
case 'webpage':
|
|
||||||
html = renderWebpage(config);
|
|
||||||
break;
|
|
||||||
case 'social':
|
|
||||||
html = renderSocial(config);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
html = '<html><body style="color:white;background:black;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><h1>Unknown widget</h1></body></html>';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Preview unsaved widget from config (used by editor Preview button)
|
||||||
|
router.post('/preview', (req, res) => {
|
||||||
|
const { widget_type, config } = req.body || {};
|
||||||
|
if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' });
|
||||||
|
if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' });
|
||||||
|
let html = renderWidgetHtml(widget_type, config || {});
|
||||||
|
if (req.user && req.user.id) html = inlineUserContent(html, req.user.id);
|
||||||
res.setHeader('Content-Type', 'text/html');
|
res.setHeader('Content-Type', 'text/html');
|
||||||
res.send(html);
|
res.send(html);
|
||||||
});
|
});
|
||||||
|
|
@ -244,4 +271,306 @@ function renderSocial(c) {
|
||||||
</div></body></html>`;
|
</div></body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory Board — lobby tenant directory with scrolling content, header/footer,
|
||||||
|
// rotating background images, and anti-burn-in motion (pixel shift, bg pulse).
|
||||||
|
// All user-supplied strings are rendered via textContent in-browser, not inlined
|
||||||
|
// into HTML, so no server-side HTML escaping is needed for entries/categories.
|
||||||
|
function renderDirectoryBoard(c) {
|
||||||
|
const configJson = JSON.stringify(c || {}).replace(/</g, '\\u003c');
|
||||||
|
return `<!DOCTYPE html><html lang="en"><head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Directory</title>
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
html, body { width:100%; height:100%; overflow:hidden; }
|
||||||
|
body {
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||||
|
color:#fff;
|
||||||
|
background:#1a1a2e;
|
||||||
|
animation: bg-pulse 60s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
body.light { color:#1a1a2e; background:#f5f5f5; animation: bg-pulse-light 60s ease-in-out infinite; }
|
||||||
|
@keyframes bg-pulse { 0%,100% { background:#1a1a2e; } 50% { background:#1b1b30; } }
|
||||||
|
@keyframes bg-pulse-light { 0%,100% { background:#f5f5f5; } 50% { background:#ededf0; } }
|
||||||
|
|
||||||
|
.page { position:fixed; inset:0; overflow:hidden; transition: transform 1.5s ease; will-change: transform; }
|
||||||
|
|
||||||
|
.bg-layer { position:absolute; inset:0; z-index:0; }
|
||||||
|
.bg-img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition: opacity 2s ease-in-out; }
|
||||||
|
.bg-img.active { opacity:0.30; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position:absolute; top:0; left:0; right:0; z-index:2;
|
||||||
|
padding:32px 48px 24px; text-align:center;
|
||||||
|
background: linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0));
|
||||||
|
}
|
||||||
|
body.light .header { background: linear-gradient(to bottom, rgba(255,255,255,0.75), rgba(255,255,255,0)); }
|
||||||
|
.header img.logo { max-height:160px; max-width:440px; object-fit:contain; margin-bottom:16px; }
|
||||||
|
.header h1 { font-size:72px; font-weight:600; letter-spacing:0.02em; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position:absolute; bottom:0; left:0; right:0; z-index:2;
|
||||||
|
padding:22px 48px; text-align:center;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.65), rgba(0,0,0,0));
|
||||||
|
font-size:28px; color:#fff; line-height:1.3;
|
||||||
|
}
|
||||||
|
body.light .footer { color:#1a1a2e; background: linear-gradient(to top, rgba(255,255,255,0.85), rgba(255,255,255,0)); }
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
position:absolute; left:0; right:0; z-index:1;
|
||||||
|
overflow:hidden;
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
|
||||||
|
}
|
||||||
|
.track { position:absolute; top:0; left:0; right:0; will-change: transform; }
|
||||||
|
.block { padding:0 48px 24px; }
|
||||||
|
.block + .block { padding-top:24px; }
|
||||||
|
|
||||||
|
.category { padding:36px 0 16px; }
|
||||||
|
.category h2 {
|
||||||
|
text-align:center;
|
||||||
|
font-size:52px;
|
||||||
|
font-weight:500;
|
||||||
|
letter-spacing:0.08em;
|
||||||
|
text-transform:uppercase;
|
||||||
|
opacity:0.9;
|
||||||
|
padding-bottom:14px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.15);
|
||||||
|
margin-bottom:22px;
|
||||||
|
}
|
||||||
|
body.light .category h2 { border-bottom-color: rgba(0,0,0,0.12); }
|
||||||
|
|
||||||
|
.entries { display:grid; gap:14px 36px; }
|
||||||
|
.entries[data-cols="auto"] { grid-template-columns: repeat(auto-fit, minmax(440px, 1fr)); }
|
||||||
|
.entries[data-cols="1"] { grid-template-columns: 1fr; }
|
||||||
|
.entries[data-cols="2"] { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.entries[data-cols="3"] { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.entries[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
|
||||||
|
.entry { font-size:38px; line-height:1.35; color:#fff; display:flex; gap:14px; align-items:baseline; }
|
||||||
|
.entry .id { font-weight:600; min-width:3.5em; flex-shrink:0; }
|
||||||
|
.entry .text { display:flex; flex-direction:column; flex:1; min-width:0; }
|
||||||
|
.entry .nm { font-weight:400; }
|
||||||
|
.entry .sub { font-size:0.55em; opacity:0.65; margin-top:4px; line-height:1.3; font-weight:400; }
|
||||||
|
.entry.available { color:#00ff00; }
|
||||||
|
.entry.available .id { color:#00ff00; }
|
||||||
|
body.light .entry { color:#1a1a2e; }
|
||||||
|
body.light .entry.available, body.light .entry.available .id { color:#059669; }
|
||||||
|
|
||||||
|
.gap { height:120px; }
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.header h1 { font-size:54px; }
|
||||||
|
.header img.logo { max-height:120px; }
|
||||||
|
.category h2 { font-size:40px; }
|
||||||
|
.entry { font-size:28px; }
|
||||||
|
.footer { font-size:22px; padding:16px 32px; }
|
||||||
|
.entries[data-cols="auto"] { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page" id="page">
|
||||||
|
<div class="bg-layer" id="bgLayer"></div>
|
||||||
|
<header class="header" id="header"></header>
|
||||||
|
<div class="scroller" id="scroller">
|
||||||
|
<div class="track" id="track"></div>
|
||||||
|
</div>
|
||||||
|
<footer class="footer" id="footer"></footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var cfg = ${configJson};
|
||||||
|
var SPEEDS = { slow: 20, medium: 45, fast: 75 };
|
||||||
|
|
||||||
|
if (cfg.theme === 'light') document.body.classList.add('light');
|
||||||
|
var GAP_PX = 100;
|
||||||
|
var MIN_SCROLL_PX_SEC = 5; // anti-burn-in minimum when content fits
|
||||||
|
|
||||||
|
// ----- header -----
|
||||||
|
var header = document.getElementById('header');
|
||||||
|
function safeImgUrl(u) {
|
||||||
|
return typeof u === 'string' && (u.indexOf('/') === 0 || /^https?:\\/\\//.test(u) || /^data:image\\//.test(u)) ? u : '';
|
||||||
|
}
|
||||||
|
var logoSrc = safeImgUrl(cfg.logo_url);
|
||||||
|
if (logoSrc) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = 'logo';
|
||||||
|
img.src = logoSrc;
|
||||||
|
img.alt = '';
|
||||||
|
header.appendChild(img);
|
||||||
|
}
|
||||||
|
if (cfg.title) {
|
||||||
|
var h1 = document.createElement('h1');
|
||||||
|
h1.textContent = cfg.title;
|
||||||
|
header.appendChild(h1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- footer -----
|
||||||
|
var footer = document.getElementById('footer');
|
||||||
|
footer.textContent = cfg.footer_text || '';
|
||||||
|
|
||||||
|
// ----- background images crossfade -----
|
||||||
|
var bgLayer = document.getElementById('bgLayer');
|
||||||
|
var bgs = Array.isArray(cfg.background_images) ? cfg.background_images.map(safeImgUrl).filter(Boolean) : [];
|
||||||
|
var bgEls = [];
|
||||||
|
bgs.forEach(function(url){
|
||||||
|
var el = document.createElement('img');
|
||||||
|
el.className = 'bg-img';
|
||||||
|
el.src = url;
|
||||||
|
el.alt = '';
|
||||||
|
bgLayer.appendChild(el);
|
||||||
|
bgEls.push(el);
|
||||||
|
});
|
||||||
|
if (bgEls.length > 0) {
|
||||||
|
bgEls[0].classList.add('active');
|
||||||
|
if (bgEls.length > 1) {
|
||||||
|
var idx = 0;
|
||||||
|
setInterval(function(){
|
||||||
|
bgEls[idx].classList.remove('active');
|
||||||
|
idx = (idx + 1) % bgEls.length;
|
||||||
|
bgEls[idx].classList.add('active');
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- layout the scroller between header and footer -----
|
||||||
|
var scroller = document.getElementById('scroller');
|
||||||
|
function layoutScroller() {
|
||||||
|
var headerH = header.getBoundingClientRect().height;
|
||||||
|
var footerH = footer.getBoundingClientRect().height;
|
||||||
|
scroller.style.top = headerH + 'px';
|
||||||
|
scroller.style.bottom = footerH + 'px';
|
||||||
|
}
|
||||||
|
layoutScroller();
|
||||||
|
window.addEventListener('resize', layoutScroller);
|
||||||
|
|
||||||
|
// ----- build directory content -----
|
||||||
|
var cols = cfg.columns || 'auto';
|
||||||
|
if (['auto','1','2','3','4'].indexOf(String(cols)) === -1) cols = 'auto';
|
||||||
|
|
||||||
|
function buildBlock() {
|
||||||
|
var block = document.createElement('div');
|
||||||
|
block.className = 'block';
|
||||||
|
var cats = Array.isArray(cfg.categories) ? cfg.categories : [];
|
||||||
|
cats.forEach(function(cat){
|
||||||
|
var catEl = document.createElement('div');
|
||||||
|
catEl.className = 'category';
|
||||||
|
var h2 = document.createElement('h2');
|
||||||
|
h2.textContent = cat.name || '';
|
||||||
|
catEl.appendChild(h2);
|
||||||
|
var entries = document.createElement('div');
|
||||||
|
entries.className = 'entries';
|
||||||
|
entries.setAttribute('data-cols', String(cols));
|
||||||
|
(cat.entries || []).forEach(function(e){
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'entry' + (e.available ? ' available' : '');
|
||||||
|
var id = document.createElement('span');
|
||||||
|
id.className = 'id';
|
||||||
|
id.textContent = (e.identifier || '') + ':';
|
||||||
|
var text = document.createElement('div');
|
||||||
|
text.className = 'text';
|
||||||
|
var nm = document.createElement('span');
|
||||||
|
nm.className = 'nm';
|
||||||
|
nm.textContent = e.name || '';
|
||||||
|
text.appendChild(nm);
|
||||||
|
if (e.subtitle) {
|
||||||
|
var sub = document.createElement('span');
|
||||||
|
sub.className = 'sub';
|
||||||
|
sub.textContent = e.subtitle;
|
||||||
|
text.appendChild(sub);
|
||||||
|
}
|
||||||
|
row.appendChild(id);
|
||||||
|
row.appendChild(text);
|
||||||
|
entries.appendChild(row);
|
||||||
|
});
|
||||||
|
catEl.appendChild(entries);
|
||||||
|
block.appendChild(catEl);
|
||||||
|
});
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
var track = document.getElementById('track');
|
||||||
|
var baseBlock = buildBlock();
|
||||||
|
track.appendChild(baseBlock);
|
||||||
|
|
||||||
|
// ----- measure + clone enough copies to fill (seamless loop) -----
|
||||||
|
function setupScroll() {
|
||||||
|
// remove any previous clones (on resize)
|
||||||
|
while (track.children.length > 1) track.removeChild(track.lastChild);
|
||||||
|
var gap = document.createElement('div');
|
||||||
|
gap.className = 'gap';
|
||||||
|
track.appendChild(gap);
|
||||||
|
|
||||||
|
var baseH = baseBlock.getBoundingClientRect().height;
|
||||||
|
var cycleH = baseH + GAP_PX; // distance to translate per loop
|
||||||
|
var viewH = scroller.getBoundingClientRect().height || window.innerHeight;
|
||||||
|
|
||||||
|
// Clone enough times so track fills scroller + at least one full cycle
|
||||||
|
// Minimum 1 clone (so we can loop). Target: track_height >= view + cycle.
|
||||||
|
var cloneCount = Math.max(1, Math.ceil((viewH + cycleH) / cycleH));
|
||||||
|
for (var i = 0; i < cloneCount; i++) {
|
||||||
|
track.appendChild(buildBlock());
|
||||||
|
if (i < cloneCount - 1) {
|
||||||
|
var g = document.createElement('div');
|
||||||
|
g.className = 'gap';
|
||||||
|
track.appendChild(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// speed
|
||||||
|
var contentFits = baseH <= viewH;
|
||||||
|
var speedName = cfg.scroll_speed || 'medium';
|
||||||
|
var speedPxSec = SPEEDS[speedName] || SPEEDS.medium;
|
||||||
|
if (contentFits) speedPxSec = MIN_SCROLL_PX_SEC;
|
||||||
|
|
||||||
|
var duration = cycleH / speedPxSec;
|
||||||
|
|
||||||
|
// inject keyframes
|
||||||
|
var oldStyle = document.getElementById('scroll-kf');
|
||||||
|
if (oldStyle) oldStyle.remove();
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.id = 'scroll-kf';
|
||||||
|
style.textContent =
|
||||||
|
'@keyframes dir-scroll { from { transform: translateY(0); } to { transform: translateY(-' + cycleH + 'px); } }' +
|
||||||
|
'.track { animation: dir-scroll ' + duration + 's linear infinite; }';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for images (logo + bgs) to load before measuring, so heights are correct
|
||||||
|
var pendingImgs = Array.from(document.images).filter(function(i){ return !i.complete; });
|
||||||
|
if (pendingImgs.length === 0) {
|
||||||
|
setupScroll();
|
||||||
|
} else {
|
||||||
|
var done = 0;
|
||||||
|
pendingImgs.forEach(function(i){
|
||||||
|
var onDone = function(){ done++; if (done === pendingImgs.length) setupScroll(); };
|
||||||
|
i.addEventListener('load', onDone, { once:true });
|
||||||
|
i.addEventListener('error', onDone, { once:true });
|
||||||
|
});
|
||||||
|
// hard timeout so we never hang
|
||||||
|
setTimeout(function(){ if (document.getElementById('scroll-kf') == null) setupScroll(); }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-layout on resize (debounced)
|
||||||
|
var rT;
|
||||||
|
window.addEventListener('resize', function(){
|
||||||
|
clearTimeout(rT);
|
||||||
|
rT = setTimeout(function(){ layoutScroller(); setupScroll(); }, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- pixel shift (anti-burn-in): every 5 min, shift .page 0-3px random dir -----
|
||||||
|
var page = document.getElementById('page');
|
||||||
|
setInterval(function(){
|
||||||
|
var dx = Math.floor(Math.random() * 7) - 3; // -3..+3
|
||||||
|
var dy = Math.floor(Math.random() * 7) - 3;
|
||||||
|
page.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue