Bundled Roundcube webmail in the Mail tab, optional Microsoft 365 OAuth sign-in, collapsible menu
build-container / build (push) Successful in 2m42s
build-container / build (push) Successful in 2m42s
- docker-compose: roundcube service with inline config (Gmail/iCloud/Yahoo presets, framing allowed, persistent key); only reachable through the hub - hub passes /webmail/ through to Roundcube behind the passkey sign-in; Microsoft's sign-in page opens in the whole window and returns to the Mail tab - M365_* settings add 'Login with Microsoft 365' (README has the Entra app registration steps) - Side menu collapses to icons or hides completely (remembered per device, Ctrl/Cmd+B) - tools/webmail-test.js Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
318006dba3
commit
cdf862427e
8 files changed
+230
-11
No files matched your search
+6
-1
@@ -20,5 +20,10 @@ AUDIOBOOKS_LOGIN_LINK=
|
||||
AUDIOBOOKS_SSO_SECRET=
|
||||
#AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso
|
||||
|
||||
# Your own webmail to show inside the Mail tab (Roundcube, SnappyMail...). Leave blank to keep the provider shortcuts. See README (Mail).
|
||||
# Optional: a webmail you run elsewhere, to show in the Mail tab instead of the bundled Roundcube (it must allow framing). Leave blank normally. See README (Mail).
|
||||
WEBMAIL_URL=
|
||||
|
||||
# Microsoft 365 sign-in for the bundled webmail (optional; see README -> Mail for the Entra app registration)
|
||||
M365_CLIENT_ID=
|
||||
M365_CLIENT_SECRET=
|
||||
M365_TENANT=
|
||||
@@ -1,7 +1,7 @@
|
||||
# NE Hub
|
||||
|
||||
One installable web app (PWA) for Seafile, OnlyOffice and your four webmail accounts.
|
||||
Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide).
|
||||
Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide). On a tablet or computer the menu collapses to icons (button at the bottom of the rail) or hides completely (the menu button at the top, or Ctrl/Cmd+B); the choice is remembered on that device.
|
||||
|
||||
## Configure
|
||||
|
||||
@@ -55,10 +55,23 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
|
||||
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
|
||||
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
|
||||
- **Mail**: with `WEBMAIL_URL` set, your own webmail opens inside the app (a frame that keeps running between tabs, with reload, full screen and open-in-new-tab);
|
||||
a button there opens the Gmail / Outlook.com / Microsoft 365 / iCloud shortcuts. Without it, Mail is those shortcuts (inbox + compose, only for the apps you tick
|
||||
in Settings -> Mail apps). Gmail, iCloud and Outlook refuse to be framed, so to read all your accounts in the app, run a webmail you control and connect it to them
|
||||
over IMAP (Gmail and iCloud with an app-specific password). Roundcube example:
|
||||
- **Mail**: Roundcube comes with the project (a second container in `docker-compose.yml`), so the Mail tab is a full inbox inside the app with nothing to set up.
|
||||
The hub passes `/webmail/` through to it, so Roundcube is not exposed to the internet, sits behind your passkey sign-in, and shares the hub's address (which
|
||||
is what lets it be framed). Sign in to it once with your address and an **app password** (Gmail: turn on 2-step verification, then Google account -> App
|
||||
passwords; iCloud: appleid.apple.com -> App-Specific Passwords; Yahoo: Account security -> App password) and pick the provider in the list; it stays signed in
|
||||
for 30 days. Gmail, iCloud and Yahoo are preset. The envelope button in the Mail toolbar opens the Gmail / Outlook.com / Microsoft 365 / iCloud web
|
||||
shortcuts, and Settings -> Mail apps chooses which cards show.
|
||||
**Microsoft 365** can't use app passwords, but Roundcube can sign in with OAuth 2.0. One-time setup in the Microsoft Entra admin center
|
||||
(entra.microsoft.com): App registrations -> New registration (this directory only; Redirect URI type *Web*:
|
||||
`https://<your hub address>/webmail/index.php/login/oauth`); copy the *Application (client) ID* and *Directory (tenant) ID*; Certificates & secrets -> New client
|
||||
secret (copy its *Value*); API permissions -> Add -> *APIs my organization uses* -> Office 365 Exchange Online -> Delegated -> `IMAP.AccessAsUser.All` and
|
||||
`SMTP.Send` (plus Microsoft Graph delegated `offline_access`, `openid`, `email`, `profile`), then grant admin consent if your tenant asks. In Exchange, IMAP and
|
||||
authenticated SMTP must be allowed for the mailbox. Then put `M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_TENANT` (the tenant ID) and `PUBLIC_URL`
|
||||
(your hub address, used for the redirect) in `.env` and `docker compose up -d`. The Roundcube login then shows **Login with Microsoft 365**. Microsoft's sign-in
|
||||
page cannot be shown inside a frame, so that one step opens in the whole window and returns you to the Mail tab. Personal Outlook.com accounts are not covered.
|
||||
Roundcube's data (settings, address book, its encryption key) is in the `nehub-roundcube` volume. To change the presets, edit the `roundcube_hub` config in
|
||||
`docker-compose.yml` (needs Docker Compose 2.23.1 or newer). To use a webmail you run elsewhere instead, set `WEBMAIL_URL` (it must allow being framed:
|
||||
no `X-Frame-Options`, and `Content-Security-Policy: frame-ancestors https://<your hub address>`). With neither, Mail is just the shortcut cards.
|
||||
|
||||
roundcube:
|
||||
image: roundcube/roundcubemail:latest-apache
|
||||
|
||||
+66
-1
@@ -19,15 +19,80 @@ services:
|
||||
AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-}
|
||||
AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-}
|
||||
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
|
||||
WEBMAIL_URL: ${WEBMAIL_URL:-}
|
||||
WEBMAIL_URL: ${WEBMAIL_URL:-} # optional: a webmail elsewhere; leave empty to use the bundled Roundcube below
|
||||
ROUNDCUBE_URL: http://roundcube:80 # the bundled Roundcube; the hub shows it at /webmail/ (it is not exposed to the internet)
|
||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||
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
|
||||
SESSION_DAYS: ${SESSION_DAYS:-180} # how long you stay signed in when idle; using the app renews it
|
||||
COOKIE_SECRET: ${COOKIE_SECRET:-} # optional; auto-generated and kept in /data if empty
|
||||
depends_on:
|
||||
- roundcube
|
||||
volumes:
|
||||
- hub-data:/data # passkeys, calendar links and the session secret live here
|
||||
|
||||
# Webmail shown inside the Mail tab. Only the hub can reach it (no published port), so it sits behind the passkey sign-in.
|
||||
# Sign in to it with your mail address and an app password (Gmail, iCloud and Yahoo are preset in the config below).
|
||||
roundcube:
|
||||
image: roundcube/roundcubemail:latest-apache
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ROUNDCUBEMAIL_DB_TYPE: sqlite
|
||||
ROUNDCUBEMAIL_REQUEST_PATH: /webmail/
|
||||
ROUNDCUBEMAIL_PLUGINS: archive,zipdownload
|
||||
ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE: 30M
|
||||
# Microsoft 365 sign-in (optional, see README -> Mail). Leave empty to skip.
|
||||
M365_CLIENT_ID: ${M365_CLIENT_ID:-}
|
||||
M365_CLIENT_SECRET: ${M365_CLIENT_SECRET:-}
|
||||
M365_TENANT: ${M365_TENANT:-}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
configs:
|
||||
- source: roundcube_hub
|
||||
target: /var/roundcube/config/hub.php
|
||||
volumes:
|
||||
- roundcube-data:/var/roundcube/db # address book, settings and the encryption key
|
||||
|
||||
configs:
|
||||
roundcube_hub:
|
||||
content: |
|
||||
<?php
|
||||
// Settings for the Roundcube that comes with NE Hub.
|
||||
$$config['product_name'] = 'Mail';
|
||||
$$config['x_frame_options'] = false; // the hub shows this page inside its Mail tab (same address, so only the hub can frame it)
|
||||
$$hosts = [
|
||||
'ssl://imap.gmail.com:993' => 'Gmail',
|
||||
'ssl://imap.mail.me.com:993' => 'iCloud Mail',
|
||||
'ssl://imap.mail.yahoo.com:993' => 'Yahoo Mail',
|
||||
];
|
||||
// Microsoft 365 through OAuth 2.0 (needs an app registration, see README). It goes first because that is where OAuth sign-ins are sent.
|
||||
if (getenv('M365_CLIENT_ID')) {
|
||||
$$hosts = ['ssl://outlook.office365.com:993' => 'Microsoft 365 (use the Microsoft button)'] + $$hosts;
|
||||
$$tenant = getenv('M365_TENANT') ?: 'organizations';
|
||||
$$config['oauth_provider'] = 'generic';
|
||||
$$config['oauth_provider_name'] = 'Microsoft 365';
|
||||
$$config['oauth_client_id'] = getenv('M365_CLIENT_ID');
|
||||
$$config['oauth_client_secret'] = getenv('M365_CLIENT_SECRET');
|
||||
$$config['oauth_auth_uri'] = "https://login.microsoftonline.com/$${tenant}/oauth2/v2.0/authorize";
|
||||
$$config['oauth_token_uri'] = "https://login.microsoftonline.com/$${tenant}/oauth2/v2.0/token";
|
||||
$$config['oauth_identity_uri'] = 'https://graph.microsoft.com/oidc/userinfo';
|
||||
$$config['oauth_identity_fields'] = ['email', 'preferred_username'];
|
||||
$$config['oauth_scope'] = 'email openid profile offline_access https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/SMTP.Send';
|
||||
$$config['oauth_login_redirect'] = false;
|
||||
if (getenv('PUBLIC_URL')) $$config['oauth_redirect_uri'] = rtrim(getenv('PUBLIC_URL'), '/') . '/webmail/index.php/login/oauth';
|
||||
}
|
||||
$$config['imap_host'] = $$hosts;
|
||||
$$config['smtp_host'] = 'tls://smtp.%z:587'; // smtp.gmail.com, smtp.mail.me.com, smtp.mail.yahoo.com
|
||||
$$config['session_lifetime'] = 43200; // minutes (30 days); the hub's own sign-in is what protects access
|
||||
// Behind the hub: believe its forwarded address and https.
|
||||
if (($$_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') $$_SERVER['HTTPS'] = 'on';
|
||||
if (!empty($$_SERVER['HTTP_X_FORWARDED_HOST'])) $$_SERVER['HTTP_HOST'] = $$_SERVER['HTTP_X_FORWARDED_HOST'];
|
||||
// A fixed encryption key kept with the data, so saved settings and sessions survive restarts and updates.
|
||||
$$kf = '/var/roundcube/db/des_key';
|
||||
if (!is_file($$kf)) @file_put_contents($$kf, bin2hex(random_bytes(12)));
|
||||
if (is_file($$kf)) $$config['des_key'] = trim(file_get_contents($$kf));
|
||||
volumes:
|
||||
hub-data:
|
||||
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
||||
roundcube-data:
|
||||
name: nehub-roundcube
|
||||
+13
-2
@@ -49,6 +49,7 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
||||
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
||||
files: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
|
||||
@@ -157,7 +158,10 @@ function renderNav(active = activeTab) {
|
||||
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
|
||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>` : '');
|
||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
||||
} else $('#nav').innerHTML = brand + mine.map(tab).join('');
|
||||
} else {
|
||||
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
||||
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
|
||||
}
|
||||
}
|
||||
phone.addEventListener('change', () => renderNav());
|
||||
|
||||
@@ -169,6 +173,12 @@ function openMore() {
|
||||
|
||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
||||
let editor;
|
||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||
function applyNav() { const p = navPref(); document.body.classList.toggle('nav-mini', !!p.mini); document.body.classList.toggle('nav-hidden', !!p.hidden); }
|
||||
function setNav(patch) { try { localStorage.setItem('nav_pref', JSON.stringify({ ...navPref(), ...patch })); } catch {} applyNav(); }
|
||||
applyNav();
|
||||
addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) { e.preventDefault(); setNav({ hidden: !navPref().hidden }); } });
|
||||
// In-app Back / Forward (an installed app has no browser buttons). Each history entry remembers its position so we know what is possible.
|
||||
let navIdx = 0, navMax = 0, navStarted = false;
|
||||
const parentHash = () => { const p = location.hash.split('/'); return p.length > 3 ? p.slice(0, -1).join('/') : ''; }; // e.g. #/files/lib/a/b -> #/files/lib/a
|
||||
@@ -183,7 +193,8 @@ function drawNav() {
|
||||
const canBack = navIdx > 0 || !!parentHash(), canFwd = navIdx < navMax;
|
||||
if (!el.dataset.on) {
|
||||
el.dataset.on = 1;
|
||||
el.innerHTML = `<button class="ibtn" id="nvb" aria-label="Back" title="Back">${icon('back')}</button><button class="ibtn" id="nvf" aria-label="Forward" title="Forward">${icon('chev')}</button><span id="nvt"></span>`;
|
||||
el.innerHTML = `<button class="ibtn navtoggle" id="nvm" aria-label="Show or hide the menu" title="Show or hide the menu (Ctrl/Cmd+B)">${icon('menu')}</button><button class="ibtn" id="nvb" aria-label="Back" title="Back">${icon('back')}</button><button class="ibtn" id="nvf" aria-label="Forward" title="Forward">${icon('chev')}</button><span id="nvt"></span>`;
|
||||
$('#nvm').onclick = () => setNav({ hidden: !navPref().hidden });
|
||||
$('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); };
|
||||
$('#nvf').onclick = () => history.forward();
|
||||
}
|
||||
|
||||
@@ -318,3 +318,28 @@ body:not(.editing) #view { padding-top: 8px; }
|
||||
.mact .btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; }
|
||||
.mact .btn svg { width: 20px; height: 20px; }
|
||||
.mact .btn:not(.ghost) { background: var(--mc); color: #fff; }
|
||||
|
||||
/* Collapsible menu (tablet / computer) */
|
||||
.navtoggle { display: none; }
|
||||
.navcol { display: none; }
|
||||
@media (min-width: 768px) {
|
||||
#nav { overflow-y: auto; scrollbar-width: none; }
|
||||
.navtoggle { display: grid; }
|
||||
body.nav-hidden #nav { display: none; }
|
||||
body.nav-hidden #navbar { padding-left: 12px; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.navcol { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 14px; border: 0; background: none; border-radius: 12px; color: var(--muted); font-size: 14px; }
|
||||
.navcol:hover { background: var(--panel2); color: var(--text); }
|
||||
.navcol svg { width: 20px; height: 20px; }
|
||||
body.nav-mini #nav { width: 76px; padding: calc(var(--sat) + 12px) 0 12px var(--sal); }
|
||||
body.nav-mini #nav a { flex-direction: column; justify-content: center; gap: 2px; padding: 0; font-size: 10px; min-height: 50px; border-radius: 0; flex: none; }
|
||||
body.nav-mini #nav a svg { width: 22px; height: 22px; }
|
||||
body.nav-mini #nav a.on { background: none; }
|
||||
body.nav-mini .brand { display: block; width: 36px; height: 36px; margin: 4px auto 12px; }
|
||||
body.nav-mini .brand img { width: 36px; height: 36px; border-radius: 10px; }
|
||||
body.nav-mini .brand span { display: none; }
|
||||
body.nav-mini .navcol { flex-direction: column; justify-content: center; gap: 2px; padding: 0; min-height: 56px; font-size: 11px; }
|
||||
body.nav-mini .navcol svg { transform: rotate(180deg); }
|
||||
body.nav-mini .navcol span { display: none; }
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ self.addEventListener('activate', e => {
|
||||
});
|
||||
self.addEventListener('fetch', e => {
|
||||
const u = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
|
||||
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/webmail') || u.pathname.startsWith('/api/')) return;
|
||||
e.respondWith(fetch(e.request).then(r => {
|
||||
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
|
||||
return r;
|
||||
|
||||
@@ -10,6 +10,8 @@ const PORT = process.env.PORT || 8080;
|
||||
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
|
||||
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
|
||||
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
|
||||
// Bundled Roundcube (docker-compose): the hub sits in front of it at /webmail/, so it needs no public address, shares the hub's sign-in and can be framed.
|
||||
const ROUNDCUBE_URL = (process.env.ROUNDCUBE_URL || '').replace(/\/$/, '');
|
||||
const WEBMAIL_URL = (process.env.WEBMAIL_URL || '').replace(/\/$/, ''); // your own webmail (Roundcube, SnappyMail...) shown inside the Mail tab
|
||||
const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
|
||||
const AUDIOBOOKS_SSO_SECRET = process.env.AUDIOBOOKS_SSO_SECRET || '';
|
||||
@@ -342,6 +344,43 @@ async function plexProxy(req, res) {
|
||||
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
|
||||
}
|
||||
|
||||
// ---- Webmail: pass /webmail/* to the Roundcube container (only reachable through the hub, so it is behind the passkey sign-in).
|
||||
// Plain http.request rather than fetch: fetch retries a 401 reply (Roundcube answers a wrong password with 401) and cannot resend a streamed body.
|
||||
function proxyWebmail(req, res) {
|
||||
if (req.url === '/webmail') { res.writeHead(302, { location: '/webmail/' }); return res.end(); }
|
||||
const base = new URL(ROUNDCUBE_URL), headers = { ...req.headers, host: base.host, 'accept-encoding': 'identity',
|
||||
'x-forwarded-proto': req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http'), 'x-forwarded-host': req.headers['x-forwarded-host'] || req.headers.host || '', 'x-forwarded-for': req.socket.remoteAddress || '' };
|
||||
headers.cookie = (req.headers.cookie || '').split(/;\s*/).filter(c => c && !c.startsWith('hub_session=')).join('; '); // the hub's own sign-in never goes to Roundcube
|
||||
if (!headers.cookie) delete headers.cookie;
|
||||
delete headers.authorization;
|
||||
const up = (base.protocol === 'https:' ? require('https') : http).request({ hostname: base.hostname, port: base.port || (base.protocol === 'https:' ? 443 : 80), method: req.method,
|
||||
path: req.url.slice('/webmail'.length), headers }, r => {
|
||||
const out = { ...r.headers }; delete out.connection; delete out['keep-alive'];
|
||||
const loc = r.headers.location;
|
||||
const dest = req.headers['sec-fetch-dest'] || '';
|
||||
if (loc && /^https?:\/\//i.test(loc) && new URL(loc).origin !== base.origin && (dest === 'iframe' || dest === 'frame')) {
|
||||
// A sign-in page elsewhere (Microsoft 365) refuses to be framed: continue in the whole window instead.
|
||||
const j = JSON.stringify(loc).replace(/</g, '\\u003c'), a = loc.replace(/&/g, '&').replace(/"/g, '"');
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||
r.resume(); return res.end(`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><body style="font:16px system-ui;padding:24px;color:#888"><p>Opening the sign-in page… <a href="${a}" target="_top">Continue</a></p><script>try{top.location.href=${j}}catch(e){}</script>`);
|
||||
}
|
||||
if (req.url.startsWith('/webmail/index.php/login/oauth') && loc) { // back from the sign-in page: carry on inside the app's Mail tab
|
||||
res.writeHead(302, { 'set-cookie': r.headers['set-cookie'] || [], location: '/#/mail', 'cache-control': 'no-store' }); r.resume(); return res.end();
|
||||
}
|
||||
if (loc) { const u = new URL(loc, ROUNDCUBE_URL + '/'); out.location = u.origin === base.origin ? '/webmail' + (u.pathname.startsWith('/webmail/') ? u.pathname.slice('/webmail'.length) : u.pathname) + u.search : loc; }
|
||||
res.writeHead(r.statusCode, out); r.on('error', () => res.destroy()); r.pipe(res);
|
||||
});
|
||||
up.setTimeout(30000, () => up.destroy(new Error('timed out waiting for Roundcube')));
|
||||
up.on('error', e => {
|
||||
if (res.headersSent) return res.destroy();
|
||||
console.error('[webmail] cannot reach Roundcube:', e.code || e.message);
|
||||
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end('<!doctype html><meta name=viewport content="width=device-width"><body style="font:16px system-ui;padding:24px;color:#888"><p>Webmail is not reachable right now. It may still be starting; try reload in a moment.</p>');
|
||||
});
|
||||
res.on('close', () => up.destroy());
|
||||
req.pipe(up);
|
||||
}
|
||||
|
||||
// ---- Storage health: is /data a real mounted volume, and what is in it? (so a wiped volume is obvious)
|
||||
/** What is mounted at the data folder? Docker's own anonymous volumes look "mounted" but are lost when the container is recreated. */
|
||||
function classifyMount(mountinfoLine) {
|
||||
@@ -397,6 +436,7 @@ const ACCESS = [
|
||||
[/^\/api\/oo\/config$/, ['office', 'files']],
|
||||
[/^\/(api\/plex\/|plex\/)/, ['music']],
|
||||
[/^\/api\/audiobooks\//, ['audio']],
|
||||
[/^\/webmail(\/|$)/, ['mail']],
|
||||
[/^\/api\/cal\//, ['calendar']],
|
||||
[/^\/api\/tasks/, ['tasks']],
|
||||
[/^\/api\/(search|ddg)$/, ['search']],
|
||||
@@ -431,7 +471,7 @@ async function handleRequest(req, res) {
|
||||
const c = t => users.can(user, t);
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
|
||||
podcast: c('podcast') ? PODCAST_URL : '', webmail: c('mail') ? WEBMAIL_URL : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
|
||||
podcast: c('podcast') ? PODCAST_URL : '', webmail: c('mail') ? (WEBMAIL_URL || (ROUNDCUBE_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
|
||||
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
||||
@@ -444,6 +484,7 @@ async function handleRequest(req, res) {
|
||||
const to = audiobooksLogin(user);
|
||||
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||
}
|
||||
if (ROUNDCUBE_URL && (pathname === '/webmail' || pathname.startsWith('/webmail/'))) return proxyWebmail(req, res);
|
||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
|
||||
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
// The /webmail/ pass-through to Roundcube: sign-in required, paths, a 401 reply with a streamed body, redirects, cookies.
|
||||
// Uses a fake Roundcube. Run: node tools/webmail-test.js
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
|
||||
const root = path.join(__dirname, '..');
|
||||
let failed = 0;
|
||||
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
const SECRET = 'webmail-test-secret-'.repeat(2);
|
||||
|
||||
(async () => {
|
||||
const seen = [];
|
||||
const rc = http.createServer((rq, rs) => {
|
||||
const body = []; rq.on('data', d => body.push(d)); rq.on('end', () => {
|
||||
seen.push({ method: rq.method, url: rq.url, cookie: rq.headers.cookie || '', host: rq.headers.host, body: Buffer.concat(body).toString(), fproto: rq.headers['x-forwarded-proto'] });
|
||||
if (rq.url.startsWith('/?_task=login') && rq.method === 'POST') { rs.writeHead(401, { 'content-type': 'text/html' }); return rs.end('<p>Login failed.</p>'); }
|
||||
if (rq.url.startsWith('/?_task=login&_action=oauth')) { rs.writeHead(302, { location: 'https://login.example.test/authorize?client_id=1&state=2' }); return rs.end(); }
|
||||
if (rq.url.startsWith('/index.php/login/oauth')) { rs.writeHead(302, { location: '/webmail/?_task=mail', 'set-cookie': 'roundcube_sessid=oauth; path=/webmail/' }); return rs.end(); }
|
||||
if (rq.url === '/go') { rs.writeHead(302, { location: 'http://127.0.0.1:' + rc.address().port + '/webmail/?_task=mail' }); return rs.end(); }
|
||||
rs.writeHead(200, { 'content-type': 'text/html', 'set-cookie': ['roundcube_sessid=abc; path=/webmail/; HttpOnly', 'roundcube_sessauth=def; path=/webmail/'] }); rs.end('<h1>Roundcube</h1>');
|
||||
});
|
||||
});
|
||||
await new Promise(r => rc.listen(0, '127.0.0.1', r));
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webmail-test-')), port = 20000 + Math.floor(Math.random() * 20000);
|
||||
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: ['mail'], handle: 'h', epoch: 1, disabled: false, created: 1 }] }));
|
||||
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, ROUNDCUBE_URL: 'http://127.0.0.1:' + rc.address().port, SEAFILE_URL: 'http://127.0.0.1:9' } });
|
||||
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
|
||||
await wait(1500);
|
||||
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5, cookie = `hub_session=${exp}.${crypto.createHmac('sha256', SECRET).update('s' + exp).digest('base64url')}`;
|
||||
const call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: cookie + '; roundcube_sessid=keepme', ...(o.headers || {}) } });
|
||||
|
||||
let r = await fetch(base + '/webmail/', { redirect: 'manual' });
|
||||
ok('without the hub sign-in, webmail goes to the sign-in page and Roundcube is never contacted', r.status === 302 && r.headers.get('location') === '/login' && seen.length === 0, [r.status, seen.length]);
|
||||
r = await call('/webmail/?_task=login');
|
||||
ok('signed in: the page comes through, with the prefix removed', r.status === 200 && (await r.text()).includes('Roundcube') && seen[0].url === '/?_task=login', seen[0]);
|
||||
ok("the hub's own sign-in cookie is not passed on, but Roundcube's is", !seen[0].cookie.includes('hub_session') && seen[0].cookie.includes('roundcube_sessid=keepme'), seen[0].cookie);
|
||||
ok("Roundcube's cookies come back (both of them)", (r.headers.getSetCookie() || []).length === 2, r.headers.getSetCookie());
|
||||
r = await call('/webmail/?_task=login&_action=login', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '_user=a&_pass=b' });
|
||||
ok('a wrong password (Roundcube answers 401) shows its message instead of an error', r.status === 401 && (await r.text()).includes('Login failed'), r.status);
|
||||
ok('the form data reached Roundcube', seen[seen.length - 1].method === 'POST' && seen[seen.length - 1].body === '_user=a&_pass=b', seen[seen.length - 1]);
|
||||
r = await call('/webmail/go');
|
||||
ok('redirects stay under /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/?_task=mail', r.headers.get('location'));
|
||||
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'iframe' } }); let t0 = await r.text();
|
||||
ok('a sign-in page elsewhere (Microsoft) is opened in the whole window, not inside the frame', r.status === 200 && t0.includes('top.location.href=\"https://login.example.test/authorize?client_id=1&state=2\"'), [r.status, t0.slice(0, 200)]);
|
||||
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'document' } });
|
||||
ok('opened on its own (new tab), the redirect to the sign-in page is passed on as it is', r.status === 302 && r.headers.get('location').startsWith('https://login.example.test/'), [r.status, r.headers.get('location')]);
|
||||
r = await call('/webmail/index.php/login/oauth?code=1&state=2', { headers: { 'sec-fetch-dest': 'document' } });
|
||||
ok('coming back from the sign-in page keeps the cookies and returns to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail' && (r.headers.getSetCookie() || []).some(c => c.startsWith('roundcube_sessid=oauth')), [r.status, r.headers.get('location'), r.headers.getSetCookie()]);
|
||||
r = await call('/webmail'); ok('/webmail goes to /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/', r.status);
|
||||
r = await call('/api/config'); ok('the app is told where the webmail is', (await r.json()).webmail === '/webmail/');
|
||||
rc.close(); await new Promise(r2 => setTimeout(r2, 100));
|
||||
r = await call('/webmail/'); const t = await r.text();
|
||||
ok('with Roundcube down, a friendly page (not a crash)', r.status === 502 && t.includes('not reachable'), r.status);
|
||||
r = await call('/api/config'); ok('and the server is still running', r.status === 200);
|
||||
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll webmail checks passed'); process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
Reference in new issue
Block a user