diff --git a/.env.example b/.env.example index e8508ce..f487928 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 15f9d13..a529be1 100644 --- a/README.md +++ b/README.md @@ -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:///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://`). With neither, Mail is just the shortcut cards. roundcube: image: roundcube/roundcubemail:latest-apache diff --git a/docker-compose.yml b/docker-compose.yml index 54de8b4..f673934 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: | + '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 diff --git a/public/app.js b/public/app.js index 3ddabd5..34996f1 100644 --- a/public/app.js +++ b/public/app.js @@ -49,6 +49,7 @@ async function loadSettings() { } const ICONS = { + menu: '', home: '', mail: '', files: '', @@ -157,7 +158,10 @@ function renderNav(active = activeTab) { $('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') + (rest.length ? `${icon('more')}More` : ''); 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('') + `
`; + $('#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 = ``; + el.innerHTML = ``; + $('#nvm').onclick = () => setNav({ hidden: !navPref().hidden }); $('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); }; $('#nvf').onclick = () => history.forward(); } diff --git a/public/style.css b/public/style.css index b6be187..f8c4271 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } +} diff --git a/public/sw.js b/public/sw.js index 40c5bc5..ede84f2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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; diff --git a/server.js b/server.js index 4ad0382..c690665 100644 --- a/server.js +++ b/server.js @@ -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(/

Opening the sign-in page… Continue

`); + } + 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('

Webmail is not reachable right now. It may still be starting; try reload in a moment.

'); + }); + 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); diff --git a/tools/webmail-test.js b/tools/webmail-test.js new file mode 100644 index 0000000..7acf809 --- /dev/null +++ b/tools/webmail-test.js @@ -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('

Login failed.

'); } + 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('

Roundcube

'); + }); + }); + 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); +})();