From bd58fe73cc8591b53bbab4a188c07b1b0cf09d04 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:01:50 -0400 Subject: [PATCH] Add a Verse admin tab that opens versebyversewithnate.us/admin 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 --- .env.example | 1 + docker-compose.yml | 1 + public/app.js | 18 ++++++++++++++---- server.js | 3 ++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 1f99615..23b0654 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,4 @@ COOKIE_SECRET= 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. HUB_SETUP_CODE= +VERSE_URL=https://versebyversewithnate.us/admin diff --git a/docker-compose.yml b/docker-compose.yml index 1e2facd..e8fa5f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us} ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.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 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 diff --git a/public/app.js b/public/app.js index dafeae5..dbc17e0 100644 --- a/public/app.js +++ b/public/app.js @@ -38,6 +38,7 @@ const ICONS = { more: '', cal: '', search: '', + book: '', trash: '', }; const icon = (n, cls = '') => `${ICONS[n]}`; @@ -61,7 +62,7 @@ function mailUrl(m, kind) { 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) 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' }); // ---- Navigation -const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['settings', 'Settings']]; -const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' }; +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', 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 phone = matchMedia('(max-width: 767px)'); let activeTab = 'home'; @@ -120,7 +121,7 @@ function openMore() { 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; function route() { const [, id = 'home', ...rest] = location.hash.split('/'); @@ -157,6 +158,7 @@ function viewHome() { ${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')} ${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')} ${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')}

Mail

@@ -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, // because a page can't type into another site's login form. 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 = `

Verse admin

${esc(host + new URL(url).pathname)}

+

This site doesn't allow being shown inside another app, so it opens in its own tab.

+
`; +} + function viewAudio() { const url = cfg.audiobooks, c = abCreds(); $('#view').innerHTML = `

Audio

diff --git a/server.js b/server.js index 277a632..a24911c 100644 --- a/server.js +++ b/server.js @@ -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 auth = require('./auth'); const cal = require('./cal'); +const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin'; const PUBLIC = path.join(__dirname, 'public'); const TYPES = { @@ -282,7 +283,7 @@ http.createServer((req, res) => { } if (req.url === '/api/config') { 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.startsWith('/api/oo/callback')) return ooCallback(req, res);