Add a Verse admin tab that opens versebyversewithnate.us/admin
build-container / build (push) Successful in 2m36s

The site sends X-Frame-Options: SAMEORIGIN, so it cannot be embedded; the tab
and a Home tile open it in its own browser tab. Address is VERSE_URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 15:01:50 -04:00
1 parent 6ec98d2527
commit bd58fe73cc
4 files changed
+18 -5

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ COOKIE_SECRET=
AUDIOBOOKS_URL=https://audiobooks.necloud.us AUDIOBOOKS_URL=https://audiobooks.necloud.us
# First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists. # First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists.
HUB_SETUP_CODE= HUB_SETUP_CODE=
VERSE_URL=https://versebyversewithnate.us/admin
+1
View File
@@ -15,6 +15,7 @@ services:
SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us} SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us}
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us} ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us}
AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us} AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us}
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here) PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
+14 -4
View File
@@ -38,6 +38,7 @@ const ICONS = {
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>', more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>', cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>', search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>', trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
}; };
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`; const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
@@ -61,7 +62,7 @@ function mailUrl(m, kind) {
return base; // iCloud has no sign-in hint return base; // iCloud has no sign-in hint
} }
let cfg = { audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' }; let cfg = { verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
// ---- Seafile client (via same-origin proxy) // ---- Seafile client (via same-origin proxy)
const sf = { const sf = {
@@ -95,8 +96,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation // ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['settings', 'Settings']]; const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['verse', 'Verse admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' }; const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More" const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const phone = matchMedia('(max-width: 767px)'); const phone = matchMedia('(max-width: 767px)');
let activeTab = 'home'; let activeTab = 'home';
@@ -120,7 +121,7 @@ function openMore() {
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
} }
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit }; const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
let editor; let editor;
function route() { function route() {
const [, id = 'home', ...rest] = location.hash.split('/'); const [, id = 'home', ...rest] = location.hash.split('/');
@@ -157,6 +158,7 @@ function viewHome() {
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')} ${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')} ${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')} ${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
${tile(cfg.verse, '#0f9d9a', 'book', 'Verse admin', 'Opens in a new tab', true)}
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')} ${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
</div> </div>
<h2>Mail</h2><div class="grid"> <h2>Mail</h2><div class="grid">
@@ -507,6 +509,14 @@ async function newOfficeDoc(x, label) {
// ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap, // ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap,
// because a page can't type into another site's login form. // because a page can't type into another site's login form.
const abCreds = () => store.get('ab_creds', { u: '', p: '' }); const abCreds = () => store.get('ab_creds', { u: '', p: '' });
// Verse by Verse admin: the site refuses to be framed (X-Frame-Options), so this is a launcher.
function viewVerse() {
const url = cfg.verse, host = new URL(url).host;
$('#view').innerHTML = `<div class="wrap"><h1>Verse admin</h1><p class="sub">${esc(host + new URL(url).pathname)}</p>
<div class="card" style="max-width:none"><p style="margin-top:0">This site doesn't allow being shown inside another app, so it opens in its own tab.</p>
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('book')}Open admin</a></div></div></div>`;
}
function viewAudio() { function viewAudio() {
const url = cfg.audiobooks, c = abCreds(); const url = cfg.audiobooks, c = abCreds();
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1> $('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
+2 -1
View File
@@ -11,6 +11,7 @@ const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.neclou
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, ''); const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const auth = require('./auth'); const auth = require('./auth');
const cal = require('./cal'); const cal = require('./cal');
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
const PUBLIC = path.join(__dirname, 'public'); const PUBLIC = path.join(__dirname, 'public');
const TYPES = { const TYPES = {
@@ -282,7 +283,7 @@ http.createServer((req, res) => {
} }
if (req.url === '/api/config') { if (req.url === '/api/config') {
res.writeHead(200, { 'content-type': 'application/json' }); res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, editor: true, jwt: !!JWT_SECRET })); return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, verse: VERSE_URL, editor: true, jwt: !!JWT_SECRET }));
} }
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res); if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res); if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);