diff --git a/.env.example b/.env.example index f487928..539a827 100644 --- a/.env.example +++ b/.env.example @@ -20,10 +20,17 @@ AUDIOBOOKS_LOGIN_LINK= AUDIOBOOKS_SSO_SECRET= #AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso -# 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). +# Optional: a webmail you run elsewhere, to show in the Mail tab instead of the bundled Cypht (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= +# Webmail (Cypht) login. Choose a password; the Mail tab signs in with it for you. Required. +CYPHT_PASSWORD= +#CYPHT_USER=hub +# Sign in with Google / Microsoft instead of app passwords (optional; see README -> Mail) +GMAIL_CLIENT_ID= +GMAIL_CLIENT_SECRET= +OUTLOOK_CLIENT_ID= +OUTLOOK_CLIENT_SECRET= +OFFICE365_CLIENT_ID= +OFFICE365_CLIENT_SECRET= M365_TENANT= diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ce4236f..4467303 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -21,7 +21,7 @@ jobs: - run: node tools/users-test.js # accounts: private data, tab limits, invites, migration - run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself - run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery - - run: node tools/webmail-test.js # the /webmail/ pass-through to Roundcube + - run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht) - uses: docker/setup-buildx-action@v3 @@ -53,11 +53,11 @@ jobs: cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max - # The webmail container (official Roundcube + the hub's settings) + # The webmail container (official Cypht + the hub's settings) - id: meta_rc uses: docker/metadata-action@v5 with: - images: git.necloud.us/nate/hub-roundcube + images: git.necloud.us/nate/hub-cypht tags: | type=raw,value=latest,enable={{is_default_branch}} type=sha,format=short @@ -65,7 +65,7 @@ jobs: - uses: docker/build-push-action@v6 with: - context: ./roundcube + context: ./cypht platforms: linux/amd64 push: true tags: ${{ steps.meta_rc.outputs.tags }} diff --git a/README.md b/README.md index 6ce415a..094ba32 100644 --- a/README.md +++ b/README.md @@ -55,38 +55,25 @@ 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**: 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. The webmail is its own image (`git.necloud.us/nate/hub-roundcube`, built by the Gitea workflow from `roundcube/`: the official Roundcube plus - `roundcube/hub.php`), so any compose tool can run it. To change the presets, edit `roundcube/hub.php`. To use a webmail you run elsewhere instead, set `WEBMAIL_URL` (it must allow being framed: +- **Mail**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab is a full webmail inside the app, built for **several + accounts at once** (iCloud, Gmail, Outlook.com, Office365, Yahoo, Fastmail and others are presets, plus any IMAP server) with combined views of them all. + The hub passes `/webmail/` through to it, so it 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). Set `CYPHT_PASSWORD` (any password you choose; required) in `.env` or your stack; the hub uses it to sign in to Cypht for you, so you + never see a second login. Then, once, in the Mail tab: **Add an E-mail Account**, pick the provider, and follow the steps (iCloud, Yahoo and most others: your + address and an **app password**, e.g. appleid.apple.com -> App-Specific Passwords; Gmail needs 2-step verification, then Google account -> App passwords). + Everything is kept in the `nehub-cypht` volume. Everyone who has the Mail tab shares this one Cypht account; for separate mailboxes give only the people who + should share it the tab, or run `docker exec php /usr/local/share/cypht/scripts/create_account.php ` and sign in as that person in Cypht + (the automatic sign-in is then skipped by logging out of Cypht once). The envelope button in the Mail toolbar still opens the Gmail / Outlook.com / Microsoft 365 / + iCloud web shortcuts, and Settings -> Mail apps chooses which of those cards show. + **Sign in with Google or Microsoft instead of app passwords** (optional): create an OAuth app and put its ID and secret in the environment of the `cypht` service + (`GMAIL_CLIENT_ID` / `GMAIL_CLIENT_SECRET` from console.cloud.google.com, with `/webmail/?page=home` as the authorized redirect URI; + `OUTLOOK_CLIENT_ID` / `OUTLOOK_CLIENT_SECRET` for a personal Outlook.com account and `OFFICE365_CLIENT_ID` / `OFFICE365_CLIENT_SECRET` plus `M365_TENANT` for a work + or school account, from a Microsoft Entra app registration with the same redirect URI and the delegated permissions `IMAP.AccessAsUser.All`, `SMTP.Send`, `offline_access`). + Set `PUBLIC_URL` (your hub address) so the redirect address is right. The provider's sign-in page cannot be shown inside a frame, so that step opens in the whole + window and returns you to the Mail tab. The Gmail and Microsoft sign-ins have not been tried against the real providers; the Microsoft ones use a patched permission + list because Cypht's own preset asks for a retired one. 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 - environment: - ROUNDCUBEMAIL_DEFAULT_HOST: ssl://imap.gmail.com # or imap.mail.me.com, etc. - ROUNDCUBEMAIL_DEFAULT_PORT: 993 - ROUNDCUBEMAIL_SMTP_SERVER: ssl://smtp.gmail.com - ROUNDCUBEMAIL_SMTP_PORT: 465 - ROUNDCUBEMAIL_PLUGINS: archive,zipdownload - volumes: [ ./roundcube:/var/roundcube/config ] # a file here can add the lines below - - Put this in a config file there: `$config['x_frame_options'] = false;` `$config['imap_host'] = ['ssl://imap.gmail.com:993' => 'Gmail', 'ssl://imap.mail.me.com:993' => 'iCloud'];` - and have the reverse proxy for your webmail send `Content-Security-Policy: frame-ancestors https://` (and no `X-Frame-Options`), so only the hub can frame it. - Keep the webmail on the same site as the hub (for example mail.necloud.us and hub.necloud.us) so its sign-in cookie works inside the frame. - ## Install on iPhone / iPad (PWA) Open the site in **Safari** (it must be the `https://` address), tap **Share -> Add to Home Screen**. The home-screen app has proper @@ -97,13 +84,14 @@ iOS keeps a separate sign-in for the home-screen app, so sign in once inside it ## Build and deploy with Gitea `.gitea/workflows/build.yml` builds the container on every push to `main` (and `v*` tags) and pushes it to -`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag). amd64 (x86) only. +`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag), and the webmail container from `cypht/` (the official Cypht plus the hub's settings) to +`git.necloud.us/nate/hub-cypht`. amd64 (x86) only. One-time setup in Gitea: create an access token with the **write:package** scope, then add it to the repo under Settings -> Actions -> Secrets as `REGISTRY_TOKEN` (optionally `REGISTRY_USER`). The runner needs Docker. On the server: - cp .env.example .env # fill in secrets, PUBLIC_URL, etc. + cp .env.example .env # fill in secrets, PUBLIC_URL, CYPHT_PASSWORD (choose one), etc. docker compose pull && docker compose up -d docker compose logs hub | grep "Setup code" # first sign-in: create your passkey diff --git a/cypht/Dockerfile b/cypht/Dockerfile new file mode 100644 index 0000000..bf7a720 --- /dev/null +++ b/cypht/Dockerfile @@ -0,0 +1,19 @@ +# Cypht webmail for NE Hub: the official image, set up to run on its own SQLite file and to live under /webmail/ behind the hub. +FROM cypht/cypht:2.13.0 +COPY nginx.conf /etc/nginx/nginx.conf +# Cypht's Outlook.com / Microsoft 365 presets still ask for the retired 'wl.imap' permission; ask for the current IMAP and SMTP ones instead. +RUN sed -i "s#'scope' => 'wl.imap'#'scope' => 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access'#" /usr/local/share/cypht/modules/nux/services.php \ + && [ "$(grep -c 'IMAP.AccessAsUser.All' /usr/local/share/cypht/modules/nux/services.php)" = 2 ] +COPY entrypoint.sh /hub-entrypoint.sh +RUN mkdir -p /var/lib/hm3 +ENV DB_CONNECTION_TYPE=host \ + DB_DRIVER=sqlite \ + DB_NAME=/var/lib/hm3/cypht.sqlite \ + SESSION_TYPE=PHP \ + AUTH_TYPE=DB \ + USER_CONFIG_TYPE=DB \ + COOKIE_PATH=/webmail/ \ + ALLOW_LONG_SESSION=true \ + LONG_SESSION_LIFETIME=30 +VOLUME /var/lib/hm3 +ENTRYPOINT ["/hub-entrypoint.sh"] diff --git a/cypht/entrypoint.sh b/cypht/entrypoint.sh new file mode 100755 index 0000000..cae3fdc --- /dev/null +++ b/cypht/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Cypht's own start-up creates its SQLite file as root, but the web part runs as www-data: make the file and folder www-data's first. +set -e +DB="${DB_NAME:-/var/lib/hm3/cypht.sqlite}"; DIR=$(dirname "$DB") +mkdir -p "$DIR"; [ -f "$DB" ] || touch "$DB" +chown www-data:www-data "$DIR" "$DB" +# ...and again once its set-up has run (it may have added files beside it). +( i=0; while [ $i -lt 60 ]; do sleep 1; chown www-data:www-data "$DIR" "$DB" 2>/dev/null; chown www-data:www-data "$DB"-* 2>/dev/null; i=$((i+1)); done ) & + +# OAuth sign-in (Gmail, Outlook.com, Microsoft 365): Cypht needs the address Google / Microsoft send you back to, which is the hub's /webmail/ page. +HUB="${PUBLIC_URL:-http://localhost:8080}"; HUB="${HUB%/}" +: "${GMAIL_CLIENT_URI:=$HUB/webmail/?page=home}"; : "${OUTLOOK_CLIENT_URI:=$HUB/webmail/?page=home}"; : "${OFFICE365_CLIENT_URI:=$HUB/webmail/?page=home}" +# Microsoft's current (v2) endpoints: personal accounts use 'consumers', work or school accounts use your tenant. +MS=https://login.microsoftonline.com; T="${M365_TENANT:-organizations}" +: "${OUTLOOK_AUTH_URI:=$MS/consumers/oauth2/v2.0/authorize}"; : "${OUTLOOK_TOKEN_URI:=$MS/consumers/oauth2/v2.0/token}"; : "${OUTLOOK_REFRESH_URI:=$MS/consumers/oauth2/v2.0/token}" +: "${OFFICE365_AUTH_URI:=$MS/$T/oauth2/v2.0/authorize}"; : "${OFFICE365_TOKEN_URI:=$MS/$T/oauth2/v2.0/token}"; : "${OFFICE365_REFRESH_URI:=$MS/$T/oauth2/v2.0/token}" +export GMAIL_CLIENT_URI OUTLOOK_CLIENT_URI OFFICE365_CLIENT_URI OUTLOOK_AUTH_URI OUTLOOK_TOKEN_URI OUTLOOK_REFRESH_URI OFFICE365_AUTH_URI OFFICE365_TOKEN_URI OFFICE365_REFRESH_URI +exec docker-entrypoint.sh "$@" diff --git a/cypht/nginx.conf b/cypht/nginx.conf new file mode 100644 index 0000000..f757e37 --- /dev/null +++ b/cypht/nginx.conf @@ -0,0 +1,46 @@ +user www-data; +worker_processes 4; +pid /run/nginx.pid; +events { + worker_connections 768; +} +http { + sendfile off; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + gzip on; + gzip_disable "msie6"; + server { + listen 80; + server_name localhost; + index index.php; + root /var/www; + client_max_body_size 60M; + # The hub shows this at /webmail/. Cypht builds its links from the address it was asked for ($request_uri, which stays as asked), + # so it is handed the /webmail/ address and the files are found here without the prefix. + location = /webmail { return 302 /webmail/; } + location /webmail/ { + rewrite ^/webmail/(.*)$ /$1 last; + } + location / { + try_files $uri /index.php$is_args$args; + } + location ~ \.php { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param SCRIPT_NAME $fastcgi_script_name; + fastcgi_index index.php; + fastcgi_pass 127.0.0.1:9000; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 581152d..f28fb4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,10 @@ 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:-} # 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) + WEBMAIL_URL: ${WEBMAIL_URL:-} # optional: a webmail elsewhere; leave empty to use the bundled Cypht below + WEBMAIL_BACKEND_URL: http://cypht:80 # the bundled webmail (Cypht); the hub shows it at /webmail/ (it is not exposed to the internet) + CYPHT_USER: ${CYPHT_USER:-hub} # the webmail's login; the Mail tab signs in with it for you + CYPHT_PASSWORD: ${CYPHT_PASSWORD:-} 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) @@ -28,27 +30,33 @@ services: 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 + - cypht 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 roundcube/hub.php). - roundcube: - image: git.necloud.us/nate/hub-roundcube:latest # built by the Gitea workflow from ./roundcube; `docker compose build` builds locally instead - build: ./roundcube + # Webmail shown inside the Mail tab (Cypht: several mail accounts in one place). Only the hub can reach it (no published port), + # so it sits behind the passkey sign-in. Add your accounts once inside it (Gmail, iCloud, Outlook.com, Office365, Yahoo and others are presets). + cypht: + image: git.necloud.us/nate/hub-cypht:latest # built by the Gitea workflow from ./cypht; `docker compose build` builds locally instead + build: ./cypht restart: unless-stopped environment: - # 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:-} + AUTH_USERNAME: ${CYPHT_USER:-hub} + AUTH_PASSWORD: ${CYPHT_PASSWORD:?Choose a password for the webmail and set CYPHT_PASSWORD} # the hub also uses it to sign you in PUBLIC_URL: ${PUBLIC_URL:-} + # Sign in with Google / Microsoft instead of an app password (optional, see README -> Mail). Leave empty to skip. + GMAIL_CLIENT_ID: ${GMAIL_CLIENT_ID:-} + GMAIL_CLIENT_SECRET: ${GMAIL_CLIENT_SECRET:-} + OUTLOOK_CLIENT_ID: ${OUTLOOK_CLIENT_ID:-} # personal Outlook.com / Hotmail + OUTLOOK_CLIENT_SECRET: ${OUTLOOK_CLIENT_SECRET:-} + OFFICE365_CLIENT_ID: ${OFFICE365_CLIENT_ID:-} # work or school Microsoft 365 + OFFICE365_CLIENT_SECRET: ${OFFICE365_CLIENT_SECRET:-} + M365_TENANT: ${M365_TENANT:-} volumes: - - roundcube-data:/var/roundcube/db # address book, settings and the encryption key + - cypht-data:/var/lib/hm3 # the webmail's database: your accounts and settings volumes: hub-data: name: nehub-data # fixed name so the data survives redeploys and renamed stacks - roundcube-data: - name: nehub-roundcube + cypht-data: + name: nehub-cypht diff --git a/public/app.js b/public/app.js index 34996f1..9871203 100644 --- a/public/app.js +++ b/public/app.js @@ -733,13 +733,34 @@ function viewWebmail() { setSt('Opening…'); let f = $('#audiowrap iframe[data-k="mail"]'); if (f) f.remove(); showFrame('mail', cfg.webmail + (fresh ? (cfg.webmail.includes('?') ? '&' : '?') + '_=' + Date.now() : ''), 'Mail', 'fullscreen; clipboard-write; clipboard-read'); f = $('#audiowrap iframe[data-k="mail"]'); let done = false; - f.addEventListener('load', () => { done = true; setSt(''); }); + f.addEventListener('load', () => { done = true; setSt(''); webmailHooks(f); }); setTimeout(() => { if (!done) setSt('Still loading… if it says it refused to connect, the webmail must allow framing (see the README)'); }, 10000); }; if (!old) open(false); else { showFrame('mail', '', 'Mail', ''); setSt(''); } $('#ar').onclick = () => open(true); $('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="mail"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); }; } +// The webmail page shares our address, so we can help it from outside: sign in for you, and send links that cannot be framed to the right place. +const SIGNIN_HOSTS = /^(accounts\.google\.com|login\.microsoftonline\.com|login\.live.com|appleid\.apple\.com|github\.com)$/; +async function webmailHooks(f) { + let d; try { d = f.contentDocument; if (!d || !d.body) return; } catch { return; } + if (!d.__hubHooks) { + d.__hubHooks = true; + d.addEventListener('click', e => { + const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented) return; + let u; try { u = new URL(a.href, d.baseURI); } catch { return; } + if (!/^https?:$/.test(u.protocol) || u.origin === location.origin) return; + e.preventDefault(); e.stopPropagation(); + if (SIGNIN_HOSTS.test(u.hostname)) location.href = u.href; // a provider's sign-in page refuses to be framed: use the whole window + else window.open(u.href, '_blank', 'noopener'); + }, true); + } + const u = d.querySelector('input[name=username]'), p = d.querySelector('input[name=password]'), form = u && u.form; + if (form && p && !d.querySelector('.error, .danger, .alert-danger') && !f.dataset.tried) { // the webmail's own login box: fill it in (once, so a wrong password is not retried) + f.dataset.tried = 1; + try { const c = await jfetch('/api/webmail/login'); if (c.user && c.pass) { u.value = c.user; p.value = c.pass; form.submit(); } } catch {} + } +} function viewMailCards() { const hints = mailHints(), on = mailOn(), shown = MAIL.filter(m => on.includes(m.id)), anyHint = shown.some(m => hints[m.id]); $('#view').innerHTML = `
${cfg.webmail ? `` : ''}

Mail

diff --git a/roundcube/Dockerfile b/roundcube/Dockerfile deleted file mode 100644 index 05ec62a..0000000 --- a/roundcube/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -# Roundcube webmail for NE Hub: the official image plus the hub's settings (so no config file has to be mounted). -FROM roundcube/roundcubemail:latest-apache -COPY hub.php /var/roundcube/config/hub.php -ENV ROUNDCUBEMAIL_DB_TYPE=sqlite \ - ROUNDCUBEMAIL_REQUEST_PATH=/webmail/ \ - ROUNDCUBEMAIL_PLUGINS=archive,zipdownload \ - ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=30M diff --git a/roundcube/hub.php b/roundcube/hub.php deleted file mode 100644 index dab7034..0000000 --- a/roundcube/hub.php +++ /dev/null @@ -1,35 +0,0 @@ - '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)); diff --git a/server.js b/server.js index c690665..b0a0e85 100644 --- a/server.js +++ b/server.js @@ -10,9 +10,12 @@ 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 +// Bundled webmail (Cypht, 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 WEBMAIL_BACKEND_URL = (process.env.WEBMAIL_BACKEND_URL || '').replace(/\/$/, ''); +// The webmail's own login (one shared account, see README). When the hub knows it, the Mail tab signs in for you. +const CYPHT_USER = process.env.CYPHT_USER || 'hub', CYPHT_PASSWORD = process.env.CYPHT_PASSWORD || ''; +const isWebmailPath = p => p === '/webmail' || p.startsWith('/webmail/'); +const WEBMAIL_URL = (process.env.WEBMAIL_URL || '').replace(/\/$/, ''); // your own webmail 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 || ''; // Back-door sign-in for the audiobooks site. AUDIOBOOKS_LOGIN_LINK is the site's own secret link (the key is part of it); anyone who has it @@ -344,36 +347,39 @@ 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. +// ---- Webmail: pass /webmail/* to the webmail container. It is only reachable through the hub, +// so it sits behind the passkey sign-in. Plain http.request rather than fetch: fetch retries a 401 reply 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', + const base = new URL(WEBMAIL_BACKEND_URL); + const headers = { ...req.headers, '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 + headers.cookie = (req.headers.cookie || '').split(/;\s*/).filter(c => c && !c.startsWith('hub_session=')).join('; '); // the hub's own sign-in never goes to the webmail 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 => { + path: req.url, headers }, r => { // (the /webmail/ address is kept: the webmail builds its links from it) 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 loc = r.headers.location, dest = req.headers['sec-fetch-dest'] || ''; + if (loc && /^https?:\/\//i.test(loc) && new URL(loc).origin !== base.origin && new URL(loc).origin !== 'http://' + (req.headers.host || '') && new URL(loc).origin !== 'https://' + (req.headers.host || '') && (dest === 'iframe' || dest === 'frame')) { + // A sign-in page elsewhere 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 + if (loc && dest === 'document' && /[?&]state=nux_authorization/.test(req.url)) { // back from Google / Microsoft in the whole window: carry on inside the 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; } + if (loc) { // keep redirects on the hub, under /webmail/ (the webmail thinks it lives at the root) + const u = new URL(loc, 'http://' + (req.headers.host || 'hub') + '/'); + if (u.origin === base.origin || u.host === req.headers.host) out.location = (u.pathname.startsWith('/webmail/') ? '' : '/webmail') + u.pathname + u.search; // (the webmail may answer as if it lived at the root) + } 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.setTimeout(30000, () => up.destroy(new Error('timed out waiting for the webmail'))); up.on('error', e => { if (res.headersSent) return res.destroy(); - console.error('[webmail] cannot reach Roundcube:', e.code || e.message); + console.error('[webmail] cannot reach the webmail:', 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.

'); }); @@ -436,7 +442,7 @@ const ACCESS = [ [/^\/api\/oo\/config$/, ['office', 'files']], [/^\/(api\/plex\/|plex\/)/, ['music']], [/^\/api\/audiobooks\//, ['audio']], - [/^\/webmail(\/|$)/, ['mail']], + [/^\/(webmail(\/|$)|api\/webmail\/)/, ['mail']], [/^\/api\/cal\//, ['calendar']], [/^\/api\/tasks/, ['tasks']], [/^\/api\/(search|ddg)$/, ['search']], @@ -471,7 +477,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 || (ROUNDCUBE_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD })); + podcast: c('podcast') ? PODCAST_URL : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_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); @@ -484,7 +490,8 @@ 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 (pathname === '/api/webmail/login') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(WEBMAIL_BACKEND_URL && CYPHT_PASSWORD ? { user: CYPHT_USER, pass: CYPHT_PASSWORD } : {})); } + if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) 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 index 7acf809..1ec3b15 100644 --- a/tools/webmail-test.js +++ b/tools/webmail-test.js @@ -1,6 +1,6 @@ #!/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 +// The /webmail/ pass-through to the webmail (Cypht): sign-in required, paths, a 401 reply with a streamed body, redirects, cookies, the sign-in helper. +// Uses a fake webmail. 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'); @@ -15,44 +15,48 @@ const SECRET = 'webmail-test-secret-'.repeat(2); 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

'); + if (rq.url.startsWith('/webmail/?_task=login') && rq.method === 'POST') { rs.writeHead(401, { 'content-type': 'text/html' }); return rs.end('

Login failed.

'); } + if (rq.url.startsWith('/webmail/?_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('/webmail/?page=home&state=nux_authorization')) { rs.writeHead(303, { location: '/?page=servers', 'set-cookie': 'hm_session=oauth; path=/webmail/' }); return rs.end(); } + if (rq.url === '/webmail/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': ['hm_session=abc; path=/webmail/; HttpOnly', 'hm_auth=def; path=/webmail/'] }); rs.end('

the webmail

'); }); }); 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' } }); + const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, WEBMAIL_BACKEND_URL: 'http://127.0.0.1:' + rc.address().port, CYPHT_USER: 'hub', CYPHT_PASSWORD: 'secret-pass', 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 || {}) } }); + const call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: cookie + '; hm_session=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]); + ok('without the hub sign-in, webmail goes to the sign-in page and the webmail 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()); + ok('signed in: the page comes through, with the prefix removed', r.status === 200 && (await r.text()).includes('the webmail') && seen[0].url === '/webmail/?_task=login', seen[0]); + ok("the hub's own sign-in cookie is not passed on, but the webmail's is", !seen[0].cookie.includes('hub_session') && seen[0].cookie.includes('hm_session=keepme'), seen[0].cookie); + ok("the webmail'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]); + ok('a wrong password (the webmail 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 the webmail', 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/?page=home&state=nux_authorization&code=1', { 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('hm_session=oauth')), [r.status, r.headers.get('location'), r.headers.getSetCookie()]); + r = await call('/api/webmail/login'); const lg = await r.json(); + ok('the Mail tab can sign in to the webmail for you', lg.user === 'hub' && lg.pass === 'secret-pass', lg); + r = await fetch(base + '/api/webmail/login', { redirect: 'manual' }); + ok('but only someone signed in to the hub', r.status === 302 || r.status === 401, r.status); 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); + ok('with the webmail 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);