13 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 5c224521d3 Add step-by-step Microsoft app registration guide for Mail
build-container / build (push) Successful in 21m40s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:12:18 -04:00
nmemmertandClaude Sonnet 5.5 a9c277b497 Mail: Outlook.com inside the app through Microsoft Graph (folders, reader, compose, reply, unread badge); Cypht webmail removed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:09:11 -04:00
nmemmertandClaude Sonnet 5.5 1564941ea5 Split app.js into one script per feature; versioning covers every public file; README for the new features
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:57:51 -04:00
nmemmertandClaude Sonnet 5.5 c1f4a220f9 Web Push reminders for tasks and events (Settings -> Reminders)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:56:01 -04:00
nmemmertandClaude Sonnet 5.5 a0f1efc4ef Version history in the file row menu too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:54:10 -04:00
nmemmertandClaude Sonnet 5.5 70d69e7441 File version history (list, download, restore) for Seafile files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:54:06 -04:00
nmemmertandClaude Sonnet 5.5 561b6181a0 Audit log (admin Activity list) and passkey recovery codes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:53:41 -04:00
nmemmertandClaude Sonnet 5.5 c8399ccaa7 Plex playlists you can edit (create, add, remove, rename, delete, save queue), sleep timer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:52:29 -04:00
nmemmertandClaude Sonnet 5.5 fa1452d23d Calendar week and day views
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:50:38 -04:00
nmemmertandClaude Sonnet 5.5 e26b38a119 Repeating tasks and subtasks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:49:03 -04:00
nmemmertandClaude Sonnet 5.5 4625848048 Offline shell (precache, offline notice) and accessibility layer (skip link, button names, sheet focus and Escape)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:46:10 -04:00
nmemmertandClaude Sonnet 5.5 df8c6ce586 Command palette (Ctrl/Cmd-K), quick add with natural dates, links in search; links.js split out of app.js
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:45:33 -04:00
nmemmertandClaude Sonnet 5.5 1f8c9493b7 Harden the server and add a Links page
Security headers, cross-site write refusal, atomic JSON writes, admin backup download, /healthz and Docker health check, OnlyOffice requires a JWT secret, npm test, CI audit, arm64 hub image. New Links tab for personal bookmarks, saved with per-person settings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:43:34 -04:00
60 changed files with 4071 additions and 2609 deletions

No files matched your search

+4 -14
View File
@@ -20,17 +20,7 @@ AUDIOBOOKS_LOGIN_LINK=
AUDIOBOOKS_SSO_SECRET= AUDIOBOOKS_SSO_SECRET=
#AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso #AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso
# 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). # Mail (Outlook.com personal accounts, through Microsoft Graph). Register an app at entra.microsoft.com (see README -> Mail),
WEBMAIL_URL= # then put its Application (client) ID here. The secret is only needed if you registered it as a "Web" app.
MS_CLIENT_ID=
# Webmail (Cypht) login. Choose a password; the Mail tab signs in with it for you. Required. MS_CLIENT_SECRET=
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=
+6 -20
View File
@@ -17,14 +17,18 @@ jobs:
with: with:
node-version: 22 node-version: 22
- run: npm ci - run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: node tools/smoke-test.js - run: node tools/smoke-test.js
- run: node tools/tasks-test.js # repeating tasks and subtasks
- run: node tools/push-test.js # reminders: when they are sent, never twice
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration - 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/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/player-test.js # music player: queue, shuffle/repeat, stall recovery
- run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered - run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
- run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht)
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
# Repo secrets needed (Settings -> Actions -> Secrets): # Repo secrets needed (Settings -> Actions -> Secrets):
@@ -48,31 +52,13 @@ jobs:
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache
cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max
# The webmail container (official Cypht + the hub's settings)
- id: meta_rc
uses: docker/metadata-action@v5
with:
images: git.necloud.us/nate/hub-cypht
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=ref,event=tag
- uses: docker/build-push-action@v6
with:
context: ./cypht
platforms: linux/amd64
push: true
tags: ${{ steps.meta_rc.outputs.tags }}
labels: ${{ steps.meta_rc.outputs.labels }}
# The lyrics container (Lyrica, pinned) # The lyrics container (Lyrica, pinned)
- id: meta_ly - id: meta_ly
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
+2 -1
View File
@@ -2,11 +2,12 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js signal.js lyrics.js ./ COPY *.js ./
COPY public ./public COPY public ./public
ENV PORT=8080 DATA_DIR=/data ENV PORT=8080 DATA_DIR=/data
RUN mkdir -p /data && chown node:node /data RUN mkdir -p /data && chown node:node /data
VOLUME /data VOLUME /data
EXPOSE 8080 EXPOSE 8080
USER node USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
CMD ["node", "server.js"] CMD ["node", "server.js"]
+17 -24
View File
@@ -1,6 +1,6 @@
# NE Hub # NE Hub
One installable web app (PWA) for Seafile, OnlyOffice and your four webmail accounts. One installable web app (PWA) for Seafile, OnlyOffice, Plex, Signal, Outlook.com mail and more.
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. 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 ## Configure
@@ -25,7 +25,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile. - **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile.
- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume). - **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume).
- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail, - **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail,
Calendar, Tasks, Search; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events, Calendar, Tasks, Search, Links; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events,
settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users/<id>/`). Limits are enforced by the server, not just hidden. settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users/<id>/`). Limits are enforced by the server, not just hidden.
You can change a person's tabs, sign them out everywhere, turn them off, or delete them and their data. An existing single-user install becomes the admin You can change a person's tabs, sign them out everywhere, turn them off, or delete them and their data. An existing single-user install becomes the admin
automatically on first start (the original files are left in place as a backup). automatically on first start (the original files are left in place as a backup).
@@ -64,27 +64,21 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
not copy older ones to a new device) and they are kept in `/data/signal`. The link keys live in the `nehub-signal` volume (a Docker *volume*, not a host folder: the bridge runs as user 1000 and a root-owned host folder gives "permission denied"; if you do want a folder, `chown -R 1000:1000` it first): whoever gets into your server can read your chats, not copy older ones to a new device) and they are kept in `/data/signal`. The link keys live in the `nehub-signal` volume (a Docker *volume*, not a host folder: the bridge runs as user 1000 and a root-owned host folder gives "permission denied"; if you do want a folder, `chown -R 1000:1000` it first): whoever gets into your server can read your chats,
so only give the **Signal** tab to people you would trust with your Signal (it is one shared account, admins always have it). Signal drops a linked device that is so only give the **Signal** tab to people you would trust with your Signal (it is one shared account, admins always have it). Signal drops a linked device that is
not connected for about a month; the hub stays connected, so this only matters if the stack is switched off. not connected for about a month; the hub stays connected, so this only matters if the stack is switched off.
- **Links**: your own bookmarks page. Add a name, address and optional group; filter as you type; opens in a new tab. Saved with your settings on the server, private to you, same on every device. Only http(s) addresses are accepted.
- **Security and backups**: every response carries `nosniff`, a `frame-ancestors 'self'` policy and a same-origin referrer policy; writes that come from another website are refused; JSON files are written atomically (temp file, then rename). Admins get **Settings -> Storage -> Download a backup** (a .tar.gz of `/data`; keep it private). `/healthz` answers without signing in and the container has a health check. In-app OnlyOffice editing now requires `ONLYOFFICE_JWT_SECRET` (or `ALLOW_UNSIGNED_OO=1` for testing). `npm test` runs every test script; CI also runs `npm audit`.
- **Command palette and quick add**: press **Ctrl/Cmd-K** anywhere (or the Command palette button on Home) to jump to any page, create things, open a saved link or find a task/event. Typing a sentence offers **Add task**, and understands a date at the end ("Call mom tomorrow", "Pay rent in 3 days", "Dentist fri", "2026-12-25"). **Quick add** on Home does the same plus new event and new link. Search also finds your links.
- **Tasks**: repeating tasks (daily, weekdays, weekly, monthly, yearly; finishing one moves it to its next date) and one level of subtasks, in NE Hub lists (iCloud reminder lists stay as Apple stores them).
- **Calendar**: Month, Week, Day and Agenda. Week and Day show a time grid with overlapping events side by side, an all-day strip and a "now" line; tap an empty slot to add an event.
- **Music playlists and sleep timer**: playlists can be created, added to ("Add to playlist" on any track, or "Save as playlist" from the queue), renamed, have tracks removed and be deleted. The hub only lets playlist writes through to Plex; everything else on the Plex server stays read-only. The Sleep button (Now Playing card, or the queue) fades the music out after 15-90 minutes or when the song ends.
- **Offline and accessibility**: the app page and its scripts are cached so it opens without a connection, with a notice while offline. Icon buttons have names, there is a skip link, sheets close with Escape and keep focus inside, and the command palette is a proper combobox.
- **Reminders (Web Push)**: Settings -> Reminders turns on notifications on that device for tasks due today (one morning summary, at the time you choose) and events about to start. Works with the app closed; on iPhone/iPad only from the installed home-screen app. Keys are made once in `/data/vapid.json`; set `VAPID_SUBJECT` to a `mailto:` address if you want one in the push messages. Needs HTTPS.
- **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords.
- **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet.
- **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`.
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. - **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. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
- **Mail**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab can open a full webmail inside the app (the shortcut cards are the default page; the **Open webmail** button there opens it, and the envelope button in - **Mail**: your **Outlook.com** (personal) mailbox inside the app: folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB), reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab and a Home card. It uses Microsoft Graph, because Outlook.com no longer allows IMAP passwords. Each person connects their own mailbox (Mail tab -> Connect Outlook.com, sign-in is on Microsoft's own page); the hub keeps only the sign-in token, encrypted, in that person's folder, and everything is private to them. Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: drafts autosave, contacts, several mailboxes per person, Microsoft 365 work accounts, Gmail/iCloud (those can be added the same way later).
the webmail goes back), built for **several **One-time setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** into `MS_CLIENT_ID`. If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it.
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 <cypht container> php /usr/local/share/cypht/scripts/create_account.php <name> <password>` and sign in as that person in Cypht
(the automatic sign-in is then skipped by logging out of Cypht once). The default Mail page is 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 `<your hub address>/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://<your hub address>`). With neither, Mail is just the shortcut cards.
## Install on iPhone / iPad (PWA) ## Install on iPhone / iPad (PWA)
@@ -96,14 +90,13 @@ iOS keeps a separate sign-in for the home-screen app, so sign in once inside it
## Build and deploy with Gitea ## Build and deploy with Gitea
`.gitea/workflows/build.yml` builds the container on every push to `main` (and `v*` tags) and pushes it to `.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), and the webmail container from `cypht/` (the official Cypht plus the hub's settings) to `git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag) and the lyrics container from `lyrica/` to `git.necloud.us/nate/hub-lyrica`. The hub image is amd64 and arm64; the lyrics image is amd64 only.
`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 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. Settings -> Actions -> Secrets as `REGISTRY_TOKEN` (optionally `REGISTRY_USER`). The runner needs Docker.
On the server: On the server:
cp .env.example .env # fill in secrets, PUBLIC_URL, CYPHT_PASSWORD (choose one), etc. cp .env.example .env # fill in secrets, PUBLIC_URL, MS_CLIENT_ID (for Mail), etc.
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
docker compose logs hub | grep "Setup code" # first sign-in: create your passkey docker compose logs hub | grep "Setup code" # first sign-in: create your passkey
+5
View File
@@ -2,6 +2,7 @@
// sign them out everywhere, turn them off, or delete them and their data. Admin only (enforced by the caller). // sign them out everywhere, turn them off, or delete them and their data. Admin only (enforced by the caller).
const users = require('./users'); const users = require('./users');
const auth = require('./auth'); const auth = require('./auth');
const audit = require('./audit');
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
@@ -15,18 +16,22 @@ async function handle(req, res, me) {
if (!id && req.method === 'POST') { if (!id && req.method === 'POST') {
const b = JSON.parse(await readBody(req) || '{}'); const b = JSON.parse(await readBody(req) || '{}');
const u = users.create({ name: b.name, role: 'user', tabs: b.tabs }); const u = users.create({ name: b.name, role: 'user', tabs: b.tabs });
audit.log('person added', { who: me.name, detail: u.name }, req);
return json(res, 200, { user: row(u), code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); return json(res, 200, { user: row(u), code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES });
} }
const u = users.get(id); if (!u) return json(res, 404, { error: 'Person not found' }); const u = users.get(id); if (!u) return json(res, 404, { error: 'Person not found' });
if (action === 'invite' && req.method === 'POST') audit.log('invite created', { who: me.name, detail: u.name }, req);
if (action === 'invite' && req.method === 'POST') return json(res, 200, { code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); // for a new device or to recover access if (action === 'invite' && req.method === 'POST') return json(res, 200, { code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); // for a new device or to recover access
if (!action && req.method === 'PUT') { if (!action && req.method === 'PUT') {
const b = JSON.parse(await readBody(req) || '{}'); const b = JSON.parse(await readBody(req) || '{}');
if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' }); if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' });
audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req);
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere }); users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere });
return json(res, 200, { user: row(users.get(id)) }); return json(res, 200, { user: row(users.get(id)) });
} }
if (!action && req.method === 'DELETE') { if (!action && req.method === 'DELETE') {
if (u.id === me.id) return json(res, 400, { error: 'You cannot delete your own account' }); if (u.id === me.id) return json(res, 400, { error: 'You cannot delete your own account' });
audit.log('person removed', { who: me.name, detail: u.name }, req);
users.remove(id); auth.removeCredsOf(id); users.remove(id); auth.removeCredsOf(id);
return json(res, 200, { ok: true }); return json(res, 200, { ok: true });
} }
+8
View File
@@ -0,0 +1,8 @@
// Write a file so a crash or power cut never leaves half of it: write to a temp file, then rename over the original.
const fs = require('fs');
const crypto = require('crypto');
module.exports = function writeFileAtomic(file, data, opts = { mode: 0o600 }) {
const tmp = file + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
try { fs.writeFileSync(tmp, data, opts); fs.renameSync(tmp, file); }
catch (e) { try { fs.unlinkSync(tmp); } catch {} throw e; }
};
+26
View File
@@ -0,0 +1,26 @@
// Audit log: who signed in, who was added or changed, what was downloaded. One JSON line per event in /data/audit.log
// (rotated at about 1 MB, keeping one older file). Contains no passwords, codes or message text.
const fs = require('fs');
const path = require('path');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'audit.log'), MAX = 1024 * 1024;
const ipOf = req => String((req && (req.headers['x-forwarded-for'] || (req.socket && req.socket.remoteAddress))) || '').split(',')[0].trim();
/** log('sign-in', { who: 'Nate', ip, detail: 'iPhone' }) */
function log(event, o = {}, req) {
try {
const line = JSON.stringify({ t: Date.now(), event, who: o.who || '', detail: o.detail || '', ip: o.ip || ipOf(req) }) + '\n';
try { if (fs.statSync(FILE).size > MAX) fs.renameSync(FILE, FILE + '.1'); } catch {}
fs.appendFileSync(FILE, line, { mode: 0o600 });
} catch (e) { console.error('[audit] could not write:', e.message); }
}
/** Newest first. */
function recent(n = 200) {
const rows = [];
for (const f of [FILE, FILE + '.1']) {
let text = ''; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
for (const l of text.split('\n').reverse()) { if (!l) continue; try { rows.push(JSON.parse(l)); } catch {} if (rows.length >= n) return rows; }
}
return rows;
}
module.exports = { log, recent };
+34 -8
View File
@@ -3,17 +3,19 @@
// Everyone else enrols with a one-time invite code the admin creates; a signed-in person can also invite one of their own devices. // Everyone else enrols with a one-time invite code the admin creates; a signed-in person can also invite one of their own devices.
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const atomicWrite = require('./atomic');
const path = require('path'); const path = require('path');
const { const {
generateRegistrationOptions, verifyRegistrationResponse, generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse, generateAuthenticationOptions, verifyAuthenticationResponse,
} = require('@simplewebauthn/server'); } = require('@simplewebauthn/server');
const users = require('./users'); const users = require('./users');
const audit = require('./audit');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch { return d; } }; const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch { return d; } };
const writeJson = (f, v) => fs.writeFileSync(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 }); const writeJson = (f, v) => atomicWrite(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 });
// Signing secret for cookies: env wins, otherwise generated once and kept so sessions survive restarts. // Signing secret for cookies: env wins, otherwise generated once and kept so sessions survive restarts.
let secret = process.env.COOKIE_SECRET; let secret = process.env.COOKIE_SECRET;
@@ -105,6 +107,22 @@ function newInvite(uid) {
const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; }; const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; };
const inviteFor = c => { const k = normInvite(c), v = k && invites.get(k); return v && v.exp > Date.now() && users.get(v.uid) && !users.get(v.uid).disabled ? { code: k, uid: v.uid } : null; }; const inviteFor = c => { const k = normInvite(c), v = k && invites.get(k); return v && v.exp > Date.now() && users.get(v.uid) && !users.get(v.uid).disabled ? { code: k, uid: v.uid } : null; };
// Recovery codes: 8 one-time codes per person, made in Settings. Each can enrol a new passkey (like an invite) if every device is lost.
// Only keyed hashes are stored, so the file does not reveal them.
const recovery = readJson('recovery.json', {}); // uid -> [hash]
const saveRecovery = () => writeJson('recovery.json', recovery);
const recHash = code => mac('r' + normInvite(code));
function newRecoveryCodes(uid) {
const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789', codes = [];
for (let i = 0; i < 8; i++) { let c = ''; for (const b of crypto.randomBytes(8)) c += a[b % a.length]; codes.push(c.slice(0, 4) + '-' + c.slice(4)); }
recovery[uid] = codes.map(recHash); saveRecovery();
return codes;
}
const recoveryFor = c => { const k = normInvite(c); if (!k) return null; const h = recHash(k); const uid = Object.keys(recovery).find(u => recovery[u].includes(h)); const u = uid && users.get(uid); return u && !u.disabled ? { code: k, uid, recovery: true } : null; };
const useRecovery = c => { const h = recHash(c), uid = Object.keys(recovery).find(u => recovery[u].includes(h)); if (uid) { recovery[uid] = recovery[uid].filter(x => x !== h); saveRecovery(); } };
/** An invite or a recovery code, whichever this is. */
const claimFor = c => inviteFor(c) || recoveryFor(c);
const RATE_LIMIT = Math.max(1, parseInt(process.env.AUTH_RATE_LIMIT, 10) || 10); // attempts per minute per address; raised only by the tests const RATE_LIMIT = Math.max(1, parseInt(process.env.AUTH_RATE_LIMIT, 10) || 10); // attempts per minute per address; raised only by the tests
const hits = new Map(); // crude per-IP throttle on anything that checks a secret const hits = new Map(); // crude per-IP throttle on anything that checks a secret
const throttled = req => { const throttled = req => {
@@ -136,6 +154,7 @@ async function handle(req, res) {
const me = currentUser(req); const me = currentUser(req);
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: !!me }); if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: !!me });
if (p === '/api/auth/logout' && req.method === 'POST') { if (p === '/api/auth/logout' && req.method === 'POST') {
if (me) audit.log('sign-out', { who: me.name }, req);
addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
return send(res, 200, { ok: true }); return send(res, 200, { ok: true });
} }
@@ -147,7 +166,7 @@ async function handle(req, res) {
if (me) { kind = 'reg-authed'; uid = me.id; name = me.name; handle = me.handle; } if (me) { kind = 'reg-authed'; uid = me.id; name = me.name; handle = me.handle; }
else { else {
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
const inv = body.inviteCode && inviteFor(body.inviteCode); const inv = body.inviteCode && claimFor(body.inviteCode);
if (!users.all().length && equalStr(body.setupCode || '', SETUP_CODE)) { kind = 'reg-setup'; name = 'Admin'; handle = crypto.randomBytes(32).toString('base64url'); } if (!users.all().length && equalStr(body.setupCode || '', SETUP_CODE)) { kind = 'reg-setup'; name = 'Admin'; handle = crypto.randomBytes(32).toString('base64url'); }
else if (inv) { kind = 'reg-invite'; uid = inv.uid; const u = users.get(uid); name = u.name; handle = u.handle; } else if (inv) { kind = 'reg-invite'; uid = inv.uid; const u = users.get(uid); name = u.name; handle = u.handle; }
else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' }); else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' });
@@ -158,7 +177,7 @@ async function handle(req, res) {
excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })), excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
}); });
return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && inviteFor(body.inviteCode) ? inviteFor(body.inviteCode).code : undefined }), options }); return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && claimFor(body.inviteCode) ? claimFor(body.inviteCode).code : undefined }), options });
} }
if (p === '/api/auth/register/verify' && req.method === 'POST') { if (p === '/api/auth/register/verify' && req.method === 'POST') {
if (!me && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); if (!me && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
@@ -167,7 +186,7 @@ async function handle(req, res) {
const ex = rec.extra || {}; const ex = rec.extra || {};
const okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid const okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid
: rec.kind === 'reg-setup' ? !users.all().length : rec.kind === 'reg-setup' ? !users.all().length
: rec.kind === 'reg-invite' ? !!inviteFor(ex.code) && !!users.get(ex.uid) : false; : rec.kind === 'reg-invite' ? !!claimFor(ex.code) && !!users.get(ex.uid) : false;
if (!okKind) return send(res, 403, { error: 'Not allowed' }); if (!okKind) return send(res, 403, { error: 'Not allowed' });
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID }); const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID });
if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' }); if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' });
@@ -177,7 +196,9 @@ async function handle(req, res) {
store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [], store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [],
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id }); name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id });
save(); save();
if (rec.kind === 'reg-invite') invites.delete(ex.code); // single use if (rec.kind === 'reg-invite') { const wasRecovery = !inviteFor(ex.code); invites.delete(ex.code); useRecovery(ex.code); audit.log(wasRecovery ? 'recovery code used' : 'passkey added with invite', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req); } // single use
else if (rec.kind === 'reg-setup') audit.log('first admin created', { who: user.name }, req);
else audit.log('passkey added', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req);
if (!me) startSession(req, res, user); if (!me) startSession(req, res, user);
return send(res, 200, { ok: true }); return send(res, 200, { ok: true });
} }
@@ -191,27 +212,32 @@ async function handle(req, res) {
const rec = takeRecord(body.cid); const rec = takeRecord(body.cid);
const cred = store.creds.find(c => c.id === body.response?.id); const cred = store.creds.find(c => c.id === body.response?.id);
const user = cred && users.get(cred.uid); const user = cred && users.get(cred.uid);
if (!rec || rec.kind !== 'login' || !cred || !user) return send(res, 400, { error: 'Passkey not recognised' }); if (!rec || rec.kind !== 'login' || !cred || !user) { audit.log('sign-in failed', { detail: 'passkey not recognised' }, req); return send(res, 400, { error: 'Passkey not recognised' }); }
if (user.disabled) return send(res, 403, { error: 'This account has been turned off' }); if (user.disabled) return send(res, 403, { error: 'This account has been turned off' });
const v = await verifyAuthenticationResponse({ const v = await verifyAuthenticationResponse({
response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID, response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID,
credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports }, credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports },
}); });
if (!v.verified) return send(res, 401, { error: 'Sign-in failed' }); if (!v.verified) { audit.log('sign-in failed', { who: user.name, detail: 'verification failed' }, req); return send(res, 401, { error: 'Sign-in failed' }); }
cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save(); cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save();
startSession(req, res, user); startSession(req, res, user);
audit.log('sign-in', { who: user.name, detail: cred.name }, req);
return send(res, 200, { ok: true }); return send(res, 200, { ok: true });
} }
// Everything below needs a signed-in session. // Everything below needs a signed-in session.
if (!me) return send(res, 401, { error: 'Not signed in' }); if (!me) return send(res, 401, { error: 'Not signed in' });
if (p === '/api/auth/invite' && req.method === 'POST') { // an invite for one of my own devices if (p === '/api/auth/invite' && req.method === 'POST') { // an invite for one of my own devices
audit.log('invite created for a device', { who: me.name }, req);
return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES }); return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES });
} }
if (p === '/api/auth/recovery' && req.method === 'GET') return send(res, 200, { remaining: (recovery[me.id] || []).length });
if (p === '/api/auth/recovery' && req.method === 'POST') { audit.log('recovery codes created', { who: me.name }, req); return send(res, 200, { codes: newRecoveryCodes(me.id) }); }
if (p === '/api/auth/passkeys' && req.method === 'GET') { if (p === '/api/auth/passkeys' && req.method === 'GET') {
return send(res, 200, credsOf(me.id).map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null }))); return send(res, 200, credsOf(me.id).map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
} }
if (p === '/api/auth/passkeys' && req.method === 'DELETE') { if (p === '/api/auth/passkeys' && req.method === 'DELETE') {
const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' }); const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' });
audit.log('passkey removed', { who: me.name, detail: (mine.find(c => c.id === body.id) || {}).name }, req);
store.creds = store.creds.filter(c => c.id !== body.id); save(); store.creds = store.creds.filter(c => c.id !== body.id); save();
return send(res, 200, { ok: true, remaining: credsOf(me.id).length }); return send(res, 200, { ok: true, remaining: credsOf(me.id).length });
} }
@@ -220,6 +246,6 @@ async function handle(req, res) {
} }
const passkeyCount = uid => credsOf(uid).length; const passkeyCount = uid => credsOf(uid).length;
function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); } function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); delete recovery[uid]; saveRecovery(); }
module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf }; module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
+18
View File
@@ -0,0 +1,18 @@
// Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz.
// Treat the file like a password: it contains the session secret and encrypted sign-in tokens.
const { spawn } = require('child_process');
const path = require('path');
const audit = require('./audit');
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
function handle(req, res, who) {
if (req.method !== 'GET') { res.writeHead(405); return res.end(); }
const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); });
tar.stderr.on('data', d => console.warn('[backup]', String(d).trim()));
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
tar.stdout.pipe(res);
res.on('close', () => tar.kill());
audit.log('backup downloaded', { who: who || '' }, req);
}
module.exports = { handle };
+3 -2
View File
@@ -1,6 +1,7 @@
// Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars). // Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars).
// Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window. // Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window.
const fs = require('fs'); const fs = require('fs');
const atomicWrite = require('./atomic');
const path = require('path'); const path = require('path');
const dns = require('dns').promises; const dns = require('dns').promises;
const net = require('net'); const net = require('net');
@@ -12,7 +13,7 @@ const users = require('./users');
const here = f => path.join(users.dir(), f); // each person's own files const here = f => path.join(users.dir(), f); // each person's own files
const FILE_NAME = 'calendars.json'; const FILE_NAME = 'calendars.json';
const load = () => { try { return JSON.parse(fs.readFileSync(here(FILE_NAME), 'utf8')); } catch { return []; } }; const load = () => { try { return JSON.parse(fs.readFileSync(here(FILE_NAME), 'utf8')); } catch { return []; } };
const save = v => fs.writeFileSync(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 }); const save = v => atomicWrite(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 });
// ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses. // ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses.
const isPrivate = ip => { const isPrivate = ip => {
@@ -224,7 +225,7 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD
// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed. // ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed.
const EV_FILE = 'events.json', FEED_FILE = 'feed.json'; const EV_FILE = 'events.json', FEED_FILE = 'feed.json';
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const writeJ = (f, v) => fs.writeFileSync(here(f), JSON.stringify(v, null, 2), { mode: 0o600 }); const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
const loadLocal = () => readJ(here(EV_FILE), []); const loadLocal = () => readJ(here(EV_FILE), []);
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' }; const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
function feedToken(reset) { function feedToken(reset) {
+2 -1
View File
@@ -2,6 +2,7 @@
// Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. // Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords.
// Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted. // Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted.
const fs = require('fs'); const fs = require('fs');
const atomicWrite = require('./atomic');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const auth = require('./auth'); const auth = require('./auth');
@@ -18,7 +19,7 @@ const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipher
const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12)); const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12));
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); }; d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); };
function load() { try { return unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data); } catch { return null; } } function load() { try { return unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data); } catch { return null; } }
function save(o) { fs.writeFileSync(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); } function save(o) { atomicWrite(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); }
function wipe() { try { fs.unlinkSync(file()); } catch {} } function wipe() { try { fs.unlinkSync(file()); } catch {} }
// ---- HTTP + XML // ---- HTTP + XML
-19
View File
@@ -1,19 +0,0 @@
# 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"]
-18
View File
@@ -1,18 +0,0 @@
#!/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 "$@"
-46
View File
@@ -1,46 +0,0 @@
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;
}
}
}
+2 -31
View File
@@ -19,10 +19,8 @@ services:
AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-} AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-}
AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-} AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-}
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin} VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
WEBMAIL_URL: ${WEBMAIL_URL:-} # optional: a webmail elsewhere; leave empty to use the bundled Cypht below MS_CLIENT_ID: ${MS_CLIENT_ID:-} # Outlook.com mail (Microsoft app registration, see README -> Mail); leave empty to switch Mail off
WEBMAIL_BACKEND_URL: http://127.0.0.1:80 # the bundled webmail (Cypht, below, shares this container's network); the hub shows it at /webmail/ MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
CYPHT_USER: ${CYPHT_USER:-hub} # the webmail's login; the Mail tab signs in with it for you
CYPHT_PASSWORD: ${CYPHT_PASSWORD:-}
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/} PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
@@ -34,31 +32,6 @@ services:
volumes: volumes:
- hub-data:/data # passkeys, calendar links and the session secret live here - hub-data:/data # passkeys, calendar links and the session secret live here
# 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
# Shares the hub's network, so the hub reaches it at 127.0.0.1 whatever network mode or DNS the host uses, and nothing else can reach it.
network_mode: "service:hub"
depends_on:
- hub
environment:
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:
- cypht-data:/var/lib/hm3 # the webmail's database: your accounts and settings
# Signal messages (the Signal tab). This links to your Signal account as one more linked device (like Signal Desktop) through the community # Signal messages (the Signal tab). This links to your Signal account as one more linked device (like Signal Desktop) through the community
# signal-cli-rest-api bridge. Not an official Signal product; its link to your account (keys) is kept in the signal-data volume. See README -> Signal. # signal-cli-rest-api bridge. Not an official Signal product; its link to your account (keys) is kept in the signal-data volume. See README -> Signal.
signal: signal:
@@ -88,7 +61,5 @@ services:
volumes: volumes:
hub-data: hub-data:
name: nehub-data # fixed name so the data survives redeploys and renamed stacks name: nehub-data # fixed name so the data survives redeploys and renamed stacks
cypht-data:
name: nehub-cypht
signal-data: signal-data:
name: nehub-signal name: nehub-signal
+74
View File
@@ -0,0 +1,74 @@
# Setting up Outlook.com mail (Microsoft app registration)
NE Hub reads and sends your Outlook.com (personal) mail through Microsoft Graph. Microsoft requires every app that does this to be registered once. It is free and takes about five minutes. The steps follow the Entra portal; Microsoft changes the layout now and then, so a label may differ slightly.
## 1. Open the registration page
1. Go to **entra.microsoft.com** and sign in with any Microsoft account. It does not have to be the mailbox you will read.
2. If it says you need a directory or Azure subscription, accept the free "Default Directory" it offers. The free tier is enough and costs nothing.
3. In the left menu open **Entra ID -> App registrations -> New registration**.
## 2. Fill in the registration
- **Name:** `NE Hub`.
- **Supported account types:** choose **Personal Microsoft accounts only**. This matches the hub, which signs in through Microsoft's "consumers" endpoint.
- **Redirect URI:** set the platform to **Web** and enter `https://hub.necloud.us/api/mail/callback`. Use your real hub address. It must match `PUBLIC_URL` in your `.env` exactly: same `https`, same host, no trailing slash.
- Press **Register**.
## 3. Copy the client ID
On the app's **Overview** page, copy the **Application (client) ID** (a long GUID). It goes in `MS_CLIENT_ID`.
## 4. Add the mail permissions
1. Open **API permissions -> Add a permission -> Microsoft Graph -> Delegated permissions**.
2. Tick these four and press **Add permissions**:
- `Mail.ReadWrite`
- `Mail.Send`
- `User.Read`
- `offline_access`
3. You do not need "Grant admin consent". Personal accounts approve these themselves the first time they connect.
## 5. Create a client secret
1. Open **Certificates & secrets -> New client secret**.
2. Give it any name and choose the longest expiry (24 months).
3. Copy the **Value** column right away. Microsoft shows it only once. It goes in `MS_CLIENT_SECRET` (not the "Secret ID").
4. Put a reminder in your calendar for about 23 months from now. When the secret expires, Connect fails until you make a new one and update `MS_CLIENT_SECRET`.
(If you would rather not have a secret, register the redirect under the platform **Mobile and desktop applications** instead of **Web** and leave `MS_CLIENT_SECRET` empty. The hub signs in with PKCE either way.)
## 6. Give the hub the values
Add these to your `.env` (or your stack's environment settings):
```
MS_CLIENT_ID=<the Application (client) ID>
MS_CLIENT_SECRET=<the secret Value>
PUBLIC_URL=https://hub.necloud.us
```
Then redeploy the hub:
```
docker compose up -d
```
Leave `MS_CLIENT_ID` empty to switch Mail off.
## 7. Connect
1. Open the hub, go to **Mail**, and press **Connect Outlook.com**.
2. Sign in with your Outlook.com account on Microsoft's page and approve the permissions.
3. You land back on the Mail tab with your inbox showing.
Each person who has the Mail tab connects their own mailbox. The hub keeps only the sign-in token, encrypted, in that person's own folder.
## If something goes wrong
- **"redirect URI doesn't match" (AADSTS50011):** the address in step 2 differs from what the hub sends. Compare `PUBLIC_URL` and the registered URI character by character.
- **"invalid client secret" or `invalid_client`:** you pasted the Secret ID instead of the Value, or the secret has expired. Make a new one.
- **"app not found in directory/consumers":** the account type was not set to personal accounts only. Check it under **Authentication -> Supported account types**.
- **The Mail tab says "Mail is not set up on this server":** `MS_CLIENT_ID` is empty or the container was not restarted after you added it.
- **Connect works, then later says Microsoft signed you out:** the sign-in token was revoked, for example after a password change. Press Connect again.
- **Installed iPhone/iPad app:** Microsoft's page may open in Safari. Sign in there, then reopen the app.
+2 -1
View File
@@ -2,6 +2,7 @@
// The hub asks it for synced lyrics only from sources that return real lyrics (not auto-captions), checks that the answer is the same recording, // The hub asks it for synced lyrics only from sources that return real lyrics (not auto-captions), checks that the answer is the same recording,
// and keeps what it finds on disk so a song is looked up once. // and keeps what it finds on disk so a song is looked up once.
const fs = require('fs'); const fs = require('fs');
const atomicWrite = require('./atomic');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
@@ -76,7 +77,7 @@ async function lookup(artist, title, seconds) {
const d = await r.json().catch(() => null); const d = await r.json().catch(() => null);
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 }); if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
const result = shape(d, seconds); const result = shape(d, seconds);
try { fs.writeFileSync(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ } try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ }
return result; return result;
})().finally(() => inflight.delete(k)); })().finally(() => inflight.delete(k));
inflight.set(k, p); return p; inflight.set(k, p); return p;
+207
View File
@@ -0,0 +1,207 @@
// Mail for a personal Outlook.com account, through Microsoft Graph (the supported way now that Outlook.com has switched off IMAP passwords).
// Each person connects their own account: they sign in on Microsoft's page, the hub keeps only the tokens (encrypted, in their own folder),
// and every mail request runs as that person. Needs an app registration (MS_CLIENT_ID, see README -> Mail).
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const audit = require('./audit');
const vault = require('./vault')('nehub-mail');
const CLIENT_ID = process.env.MS_CLIENT_ID || '', CLIENT_SECRET = process.env.MS_CLIENT_SECRET || '';
const TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only
const LOGIN = (process.env.MS_AUTH_URL || 'https://login.microsoftonline.com').replace(/\/$/, '');
const GRAPH = (process.env.MS_GRAPH_URL || 'https://graph.microsoft.com/v1.0').replace(/\/$/, '');
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
const SCOPES = 'offline_access Mail.ReadWrite Mail.Send User.Read';
const enabled = () => !!CLIENT_ID;
const MAX_BODY = 6 * 1024 * 1024, MAX_ATTACH = 3 * 1024 * 1024;
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > MAX_BODY) { no(new Error('That is too large to send (attachments can total about 3 MB)')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); };
const fail = (status, message) => Object.assign(new Error(message), { status });
// ---- per-person token file (encrypted)
const file = () => path.join(users.dir(), 'mail.json');
const load = () => { try { return vault.unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data) || null; } catch { return null; } };
const save = o => atomicWrite(file(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
const wipe = () => { try { fs.unlinkSync(file()); } catch {} };
// ---- sign-in with Microsoft (authorization code + PKCE)
const states = new Map(); // state -> { uid, verifier, redirect, exp }
const redirectUri = req => (PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`) + '/api/mail/callback';
function startUrl(req) {
for (const [k, v] of states) if (v.exp < Date.now()) states.delete(k);
const state = crypto.randomBytes(24).toString('base64url'), verifier = crypto.randomBytes(48).toString('base64url');
const redirect = redirectUri(req);
states.set(state, { uid: users.current().id, verifier, redirect, exp: Date.now() + 10 * 60e3 });
const q = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: redirect, response_mode: 'query', scope: SCOPES, state,
code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', prompt: 'select_account' });
return `${LOGIN}/${TENANT}/oauth2/v2.0/authorize?${q}`;
}
async function tokenCall(params) {
const body = new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPES, ...params }); if (CLIENT_SECRET) body.set('client_secret', CLIENT_SECRET);
const r = await fetch(`${LOGIN}/${TENANT}/oauth2/v2.0/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15000) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error_description || j.error || 'Microsoft refused the sign-in'), { status: r.status, code: j.error });
return j;
}
const page = (title, msg) => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${title}</title><body style="font:16px system-ui;padding:28px;max-width:520px;margin:auto"><h2>${title}</h2><p>${msg}</p><p><a href="/#/mail">Back to NE Hub</a></p>`;
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** Microsoft sends the person back here. Runs without the hub cookie (an installed app may sign in in another browser), so the one-time state says who it is. */
async function callback(req, res) {
const q = new URL(req.url, 'http://x').searchParams, rec = states.get(q.get('state') || '');
const send = (code, html) => { res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(html); };
if (!rec || rec.exp < Date.now()) return send(400, page('Sign-in expired', 'That sign-in link is no longer valid. Go back and press Connect again.'));
states.delete(q.get('state'));
if (q.get('error')) return send(400, page('Not connected', escHtml(q.get('error_description') || q.get('error'))));
const user = users.get(rec.uid); if (!user || user.disabled) return send(403, page('Not allowed', 'That account is not available.'));
try {
const t = await tokenCall({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: rec.redirect, code_verifier: rec.verifier });
if (!t.refresh_token) throw new Error('Microsoft did not allow ongoing access');
await users.runAs(user, async () => {
const me = await (await fetch(GRAPH + '/me?$select=displayName,mail,userPrincipalName', { headers: { authorization: 'Bearer ' + t.access_token } })).json().catch(() => ({}));
save({ access: t.access_token, refresh: t.refresh_token, exp: Date.now() + (t.expires_in || 3600) * 1000, address: me.mail || me.userPrincipalName || '', name: me.displayName || '' });
audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req);
});
res.writeHead(302, { location: '/#/mail', 'cache-control': 'no-store' }); res.end();
} catch (e) { console.error('[mail] sign-in failed:', e.message); send(400, page('Could not connect', escHtml(e.message))); }
}
const refreshing = new Map(); // uid -> promise: parallel requests share one refresh (refresh tokens can rotate)
/** A working access token for the current person, refreshed when needed. `stale` is the token that just failed. */
async function token(force, stale) {
const st = load(); if (!st) throw fail(409, 'Mail is not connected');
if (!force && st.exp - 60e3 > Date.now()) return st.access;
if (force && st.access !== stale) return st.access; // another request already refreshed it
const uid = users.current().id;
if (refreshing.has(uid)) return refreshing.get(uid);
const job = (async () => {
try {
const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: st.refresh });
save({ ...st, access: t.access_token, refresh: t.refresh_token || st.refresh, exp: Date.now() + (t.expires_in || 3600) * 1000 });
return t.access_token;
} catch (e) {
if (e.code === 'invalid_grant' || e.status === 400 || e.status === 401) { wipe(); throw fail(409, 'Microsoft signed you out of mail. Connect again.'); }
throw fail(502, 'Could not reach Microsoft: ' + e.message);
}
})().finally(() => refreshing.delete(uid));
refreshing.set(uid, job);
return job;
}
async function graph(p, opts = {}, stale) {
const used = await token(stale !== undefined, stale);
const r = await fetch(GRAPH + p, { ...opts, headers: { authorization: 'Bearer ' + used, ...(opts.body && typeof opts.body === 'string' ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) }, signal: AbortSignal.timeout(30000) });
if (r.status === 401 && stale === undefined) return graph(p, opts, used);
if (r.status === 204 || r.status === 202) return null;
if (opts.raw && r.ok) return r;
const j = await r.json().catch(() => ({}));
if (!r.ok) throw fail(r.status === 404 ? 404 : r.status === 429 ? 429 : 502, (j.error && j.error.message) || 'Microsoft Graph error ' + r.status);
return j;
}
// ---- helpers
const ID = /^[A-Za-z0-9_=\-]{1,400}$/;
const WELL = { inbox: 'Inbox', drafts: 'Drafts', sentitems: 'Sent', archive: 'Archive', junkemail: 'Junk', deleteditems: 'Deleted' };
const id = v => { if (!ID.test(String(v || ''))) throw fail(400, 'Bad id'); return encodeURIComponent(v); };
const addr = a => a && a.emailAddress ? { name: a.emailAddress.name || '', address: a.emailAddress.address || '' } : { name: '', address: '' };
const row = m => ({ id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), date: m.receivedDateTime, read: !!m.isRead, attach: !!m.hasAttachments, preview: m.bodyPreview || '', flagged: !!(m.flag && m.flag.flagStatus === 'flagged') });
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
const people = (list, label) => { const out = []; for (const x of Array.isArray(list) ? list : String(list || '').split(/[,;]/)) { const a = String(x).trim(); if (!a) continue; const m = /<([^>]+)>/.exec(a), e = (m ? m[1] : a).trim(); if (!EMAIL.test(e)) throw fail(400, `“${a}” is not an email address (${label})`); out.push({ emailAddress: { address: e } }); } return out; };
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').slice(0, 150);
let folderCache = new Map(); // uid -> { t, v }
async function folders() {
const uid = users.current().id, c = folderCache.get(uid);
if (c && Date.now() - c.t < 15e3) return c.v;
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount';
const wk = await Promise.all(Object.keys(WELL).map(k => graph(`/me/mailFolders/${k}${sel}`).then(f => ({ id: k, realId: f.id, name: WELL[k], unread: f.unreadItemCount || 0, total: f.totalItemCount || 0, well: true })).catch(() => null)));
const known = new Set(wk.filter(Boolean).map(f => f.realId));
const rest = (await graph(`/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
const v = [...wk.filter(Boolean).map(({ realId, ...f }) => f), ...rest.filter(f => !known.has(f.id) && !['Outbox', 'Conversation History', 'Sync Issues', 'RSS Feeds'].includes(f.displayName)).map(f => ({ id: f.id, name: f.displayName, unread: f.unreadItemCount || 0, total: f.totalItemCount || 0 }))];
folderCache.set(uid, { t: Date.now(), v }); return v;
}
const dropFolderCache = () => folderCache.delete(users.current().id);
async function withInline(html, attachments, mid) {
const inl = (attachments || []).filter(a => a.isInline && a.contentId && /^image\//i.test(a.contentType || '') && (a.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
for (const a of inl) {
try {
const full = await graph(`/me/messages/${id(mid)}/attachments/${id(a.id)}?$select=contentBytes,contentType`);
if (full && full.contentBytes) html = html.split('cid:' + String(a.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType};base64,${full.contentBytes}`);
} catch { /* leave that picture out */ }
}
return html;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
try {
if (p === '/api/mail/status') {
const st = load();
if (!st) return json(res, 200, { connected: false });
let unread = null; try { unread = (await graph('/me/mailFolders/inbox?$select=unreadItemCount')).unreadItemCount; } catch (e) { if (e.status === 409) return json(res, 200, { connected: false, expired: true }); }
return json(res, 200, { connected: true, address: st.address, name: st.name, unread });
}
if (p === '/api/mail/connect' && req.method === 'GET') return json(res, 200, { url: startUrl(req), redirect: redirectUri(req) });
if (p === '/api/mail/disconnect' && req.method === 'POST') { wipe(); dropFolderCache(); audit.log('mail disconnected', { who: users.current().name }, req); return json(res, 200, { ok: true }); }
if (p === '/api/mail/folders') return json(res, 200, await folders());
if (p === '/api/mail/messages') {
const folder = id(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0);
const sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag';
const r = await graph(search ? `/me/mailFolders/${folder}/messages?${sel}&$top=50&$search="${encodeURIComponent(search)}"` : `/me/mailFolders/${folder}/messages?${sel}&$top=30&$skip=${skip}&$orderby=receivedDateTime desc`.replace(/ /g, '%20'));
const items = (r.value || []).map(row); if (search) items.sort((a, b) => String(b.date).localeCompare(String(a.date)));
return json(res, 200, { items, more: !search && !!r['@odata.nextLink'], next: skip + items.length });
}
if (p === '/api/mail/message') {
const mid = q.get('id');
const m = await graph(`/me/messages/${id(mid)}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,body,hasAttachments,flag,parentFolderId&$expand=attachments($select=id,name,contentType,size,isInline,contentId)`);
let html = m.body && m.body.contentType === 'html' ? m.body.content || '' : textToHtml((m.body && m.body.content) || '');
html = await withInline(html, m.attachments, mid);
return json(res, 200, { id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), to: (m.toRecipients || []).map(addr), cc: (m.ccRecipients || []).map(addr), date: m.receivedDateTime, read: !!m.isRead, flagged: !!(m.flag && m.flag.flagStatus === 'flagged'),
html, attachments: (m.attachments || []).filter(a => !(a.isInline && a.contentId)).map(a => ({ id: a.id, name: a.name, type: a.contentType, size: a.size })) });
}
if (p === '/api/mail/attachment') {
const mid = q.get('id'), aid = q.get('aid');
const meta = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`);
const r = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true });
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${safeName(meta.name)}"; filename*=UTF-8''${encodeURIComponent(safeName(meta.name))}`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' });
return res.end(Buffer.from(await r.arrayBuffer()));
}
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const b = JSON.parse((await readBody(req)) || '{}');
if (p === '/api/mail/update') {
const patch = {}; if (typeof b.read === 'boolean') patch.isRead = b.read; if (typeof b.flagged === 'boolean') patch.flag = { flagStatus: b.flagged ? 'flagged' : 'notFlagged' };
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change');
await graph(`/me/messages/${id(b.id)}`, { method: 'PATCH', body: JSON.stringify(patch) }); dropFolderCache(); return json(res, 200, { ok: true });
}
if (p === '/api/mail/move') { await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: decodeURIComponent(id(b.to)) }) }); dropFolderCache(); return json(res, 200, { ok: true }); }
if (p === '/api/mail/delete') {
if (b.permanent) await graph(`/me/messages/${id(b.id)}`, { method: 'DELETE' });
else await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) });
dropFolderCache(); return json(res, 200, { ok: true });
}
if (p === '/api/mail/send') {
const to = people(b.to, 'To'), cc = people(b.cc, 'Cc'), bcc = people(b.bcc, 'Bcc');
if (!to.length && !cc.length && !bcc.length) throw fail(400, 'Add at least one recipient');
const subject = String(b.subject || '').slice(0, 255), text = String(b.body || ''); if (text.length > 200000) throw fail(400, 'That message is too long');
let total = 0; const attachments = (Array.isArray(b.attachments) ? b.attachments : []).slice(0, 10).map(a => { const bytes = Buffer.from(String(a.data || ''), 'base64').length; total += bytes; return { '@odata.type': '#microsoft.graph.fileAttachment', name: safeName(a.name), contentType: String(a.type || 'application/octet-stream').slice(0, 100), contentBytes: String(a.data || '') }; });
if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB');
await graph('/me/sendMail', { method: 'POST', body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: textToHtml(text) }, toRecipients: to, ccRecipients: cc, bccRecipients: bcc, attachments }, saveToSentItems: true }) });
audit.log('mail sent', { who: users.current().name, detail: `${to.length + cc.length + bcc.length} recipient(s)` }, req); dropFolderCache(); return json(res, 200, { ok: true });
}
if (p === '/api/mail/reply') {
const mode = { reply: 'reply', replyAll: 'replyAll', forward: 'forward' }[b.mode]; if (!mode) throw fail(400, 'Unknown action');
const body = { comment: String(b.comment || '').slice(0, 200000) };
if (mode === 'forward') { body.toRecipients = people(b.to, 'To'); if (!body.toRecipients.length) throw fail(400, 'Add at least one recipient'); }
await graph(`/me/messages/${id(b.id)}/${mode}`, { method: 'POST', body: JSON.stringify(body) });
audit.log('mail sent', { who: users.current().name, detail: mode }, req); dropFolderCache(); return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
} catch (e) { json(res, e.status || 400, { error: e.message }); }
}
module.exports = { enabled, handle, callback };
+178 -3
View File
@@ -1,15 +1,16 @@
{ {
"name": "project", "name": "ne-hub",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project", "name": "ne-hub",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@simplewebauthn/server": "^14.0.3" "@simplewebauthn/server": "^14.0.3",
"web-push": "^3.6.7"
} }
}, },
"node_modules/@hexagon/base64": { "node_modules/@hexagon/base64": {
@@ -278,6 +279,27 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": { "node_modules/asn1js": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",
@@ -292,6 +314,114 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/pvtsutils": { "node_modules/pvtsutils": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@ -316,6 +446,32 @@
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -339,6 +495,25 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD" "license": "0BSD"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
} }
} }
} }
+6 -3
View File
@@ -4,13 +4,16 @@
"description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA", "description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js",
"test": "for t in smoke tasks push users plex player lyrics signal mail; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@simplewebauthn/server": "^14.0.3" "@simplewebauthn/server": "^14.0.3",
"web-push": "^3.6.7"
} }
} }
+17 -2251
View File
File diff suppressed because it is too large. Load diff
+58
View File
@@ -0,0 +1,58 @@
'use strict';
// ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap,
// because a page can't type into another site's login form.
const abCreds = () => store.get('ab_creds', { u: '', p: '' });
// Podcast Admin (Verse by Verse): the site refuses to be framed (X-Frame-Options), so this is a launcher.
function viewVerse() {
const url = cfg.verse, host = new URL(url).host;
$('#view').innerHTML = `<div class="wrap"><h1>Podcast Admin</h1><p class="sub">${esc(host + new URL(url).pathname)}</p>
<div class="card" style="max-width:none"><p style="margin-top:0">This site doesn't allow being shown inside another app, so it opens in its own tab.</p>
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('book')}Open admin</a></div></div></div>`;
}
// Frames that must keep running (audio) live outside #view: created once, then only shown or hidden.
function showFrame(key, url, title, allow) {
const wrap = $('#audiowrap');
let f = wrap.querySelector(`iframe[data-k="${key}"]`);
if (!f) { f = document.createElement('iframe'); f.dataset.k = key; f.src = url; f.title = title; f.allow = allow; wrap.appendChild(f); }
wrap.querySelectorAll('iframe').forEach(x => x.classList.toggle('act', x === f));
wrap.className = 'on'; document.body.classList.add('onaudio');
}
// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page.
// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
function viewPodcast() {
const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:';
$('#view').innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a></div>
${canFrame ? '<p class="sub" style="margin:8px 0 0">If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.</p>'
: '<div class="card" style="max-width:none;margin-top:12px"><p style="margin:0">This is a plain <b>http</b> address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.</p></div>'}</div>`;
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
}
// The frame is created once and kept alive, so playback carries on while you use other tabs.
// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page).
const ENTER = '/api/audiobooks/enter';
function viewAudio() {
const c = abCreds(), old = $('#audiowrap iframe[data-k="audio"]');
$('#view').innerHTML = `<div class="abbar"><span class="grow"><b>Audiobooks</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
${c.u ? `<button class="ibtn" id="cu" aria-label="Copy username" title="Copy username">${icon('copy')}</button>` : ''}
<button class="ibtn" id="ar" aria-label="Reload" title="Reload">${icon('refresh')}</button>
<button class="ibtn" id="af" aria-label="Full screen" title="Full screen">${icon('full')}</button>
<a class="ibtn" href="${ENTER}" target="_blank" rel="noopener" aria-label="Open in new tab" title="Open in new tab">${icon('ext')}</a></div>`;
const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
const open = fresh => {
setSt('Opening…');
let f = $('#audiowrap iframe[data-k="audio"]'); if (f) f.remove();
showFrame('audio', ENTER + (fresh ? '?t=' + Date.now() : ''), 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media');
f = $('#audiowrap iframe[data-k="audio"]'); f.referrerPolicy = 'no-referrer';
let done = false;
f.addEventListener('load', () => { done = true; setSt(''); });
setTimeout(() => { if (!done) setSt('Still loading… if it stays blank, tap the arrow to open it in its own tab'); }, 10000);
};
if (!old) open(false); else { showFrame('audio', '', 'Audiobooks', ''); setSt(''); }
$('#ar').onclick = () => open(true);
$('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="audio"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
const cu = $('#cu'); if (cu) cu.onclick = async () => { try { await navigator.clipboard.writeText(c.u); toast('Username copied. Password is in Settings.'); } catch { toast('Could not copy'); } };
}
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// ---- Boot
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
const loadedBuild = cfg.build;
// When a new version is deployed, an open page keeps running the old one. Tell the person, and offer a reload.
const checkForUpdate = async () => {
try {
const c = await (await fetch('/api/config', { cache: 'no-store' })).json();
if (c.build && loadedBuild && c.build !== loadedBuild && !document.getElementById('upd')) {
const b = document.createElement('div'); b.id = 'upd';
b.innerHTML = 'A new version of NE Hub is available. <button class="btn" id="updgo">Reload</button>';
document.body.appendChild(b); document.getElementById('updgo').onclick = () => location.reload();
}
} catch {}
};
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkForUpdate(); });
setInterval(checkForUpdate, 10 * 60e3);
await loadMe();
await loadSettings();
sgStart();
addEventListener('hashchange', route);
restorePlayer();
route();
mailPoll(); setInterval(mailPoll, 2 * 60e3);
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
addEventListener('keydown', e => {
const tag = e.target.tagName || '', k = e.key;
if (e.metaKey || e.ctrlKey || e.altKey || !cur() || /^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (k === ' ' && /^(BUTTON|A)$/.test(tag))) return;
if (k === ' ') { e.preventDefault(); toggle(); }
else if (k === 'ArrowRight') pl.a.currentTime += 10; else if (k === 'ArrowLeft') pl.a.currentTime = Math.max(0, pl.a.currentTime - 10);
else if (k === 'n' || k === 'N') next(); else if (k === 'p' || k === 'P') prev();
});
+50
View File
@@ -0,0 +1,50 @@
// Calendar week and day views: a time grid with all-day strip, overlapping events side by side and a "now" line.
// Uses the calendar globals from app.js (calState, loadRange, evStore, ymd, fmtTime, esc, icon, newEvent, viewCalendar).
const HOUR_PX = 48;
/** Lay out one day's timed events in columns so overlapping ones sit side by side. */
function layoutDay(events) {
const evs = events.map(e => ({ e, s: e.sm, en: Math.max(e.em, e.sm + 20) })).sort((a, b) => a.s - b.s || b.en - a.en);
const out = []; let cluster = [], clusterEnd = -1;
const flush = () => { const cols = []; for (const it of cluster) { let c = cols.findIndex(end => end <= it.s); if (c < 0) { c = cols.length; cols.push(0); } cols[c] = it.en; it.col = c; } cluster.forEach(it => { it.cols = cols.length; out.push(it); }); cluster = []; };
for (const it of evs) { if (cluster.length && it.s >= clusterEnd) { flush(); clusterEnd = -1; } cluster.push(it); clusterEnd = Math.max(clusterEnd, it.en); }
if (cluster.length) flush();
return out;
}
async function renderTimeGrid(nDays) {
const sel = new Date(calState.sel + 'T00:00:00'), start = new Date(sel);
if (nDays === 7) start.setDate(start.getDate() - start.getDay());
const { byDay, errors } = await loadRange(start, nDays);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
evStore.length = 0;
const today = ymd(new Date()), days = [...Array(nDays)].map((_, i) => { const d = new Date(start); d.setDate(d.getDate() + i); return d; });
const title = nDays === 1 ? sel.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
: days[0].toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' – ' + days[6].toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
const allDay = days.map(d => (byDay.get(ymd(d)) || []).filter(e => e.allDay));
const cols = days.map(d => {
const k = ymd(d), t0 = new Date(k + 'T00:00:00').getTime(), t1 = t0 + 864e5;
const timed = (byDay.get(k) || []).filter(e => !e.allDay).map(e => ({ ...e, sm: (Math.max(e.start, t0) - t0) / 6e4, em: (Math.min(e.end, t1) - t0) / 6e4, orig: e }));
return layoutDay(timed).map(({ e, s, en, col, cols: n }) => {
const i = evStore.push(e.orig) - 1, h = Math.max(18, (en - s) / 60 * HOUR_PX - 2);
return `<button class="tgev" data-ei="${i}" style="top:${s / 60 * HOUR_PX}px;height:${h}px;left:calc(${col * 100 / n}% + 1px);width:calc(${100 / n}% - 2px);--c:${esc(e.color)}" aria-label="${esc(e.title)}, ${fmtTime(e.start)} to ${fmtTime(e.end)}">
<b>${esc(e.title)}</b>${h > 30 ? `<small>${fmtTime(e.start)}</small>` : ''}</button>`;
}).join('');
});
const hours = [...Array(24)].map((_, h) => `<div class="tgh" style="height:${HOUR_PX}px"><span>${h === 0 ? '' : new Date(2000, 0, 1, h).toLocaleTimeString([], { hour: 'numeric' })}</span></div>`).join('');
const now = new Date(), nowTop = (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_PX;
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="tp" aria-label="Previous ${nDays === 1 ? 'day' : 'week'}">${icon('back')}</button>
<h2 style="margin:0;flex:1;text-align:center;font-size:16px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(title)}</h2>
<button class="ibtn" id="tn" aria-label="Next ${nDays === 1 ? 'day' : 'week'}" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tt" style="min-height:36px">Today</button></div>
<div class="tgwrap"><div class="tg" style="min-width:${nDays === 1 ? 0 : 560}px">
<div class="tgtop"><div class="tghead"><span class="tgg"></span>${days.map(d => `<button class="tgd${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" aria-label="${esc(d.toDateString())}"><small>${d.toLocaleDateString([], { weekday: 'short' })}</small><b>${d.getDate()}</b></button>`).join('')}</div>
${allDay.some(a => a.length) ? `<div class="tgad"><span class="tgg">all day</span>${allDay.map(a => `<div>${a.map(e => { const i = evStore.push(e) - 1; return `<button class="tgchip" data-ei="${i}" style="--c:${esc(e.color)}">${esc(e.title)}</button>`; }).join('')}</div>`).join('')}</div>` : ''}</div>
<div class="tgbody" id="tgbody"><div class="tgg">${hours}</div>${days.map((d, i) => `<div class="tgcol${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" style="height:${24 * HOUR_PX}px">${cols[i]}${ymd(d) === today ? `<i class="tgnow" style="top:${nowTop}px"></i>` : ''}</div>`).join('')}</div>
</div></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
const body = $('#tgbody'); if (body) { const wrap = body.parentElement.parentElement; wrap.scrollTop = Math.max(0, ((days.some(d => ymd(d) === today) ? now.getHours() - 1 : 7) * HOUR_PX)); }
const shift = n => { const d = new Date(sel); d.setDate(d.getDate() + n * (nDays === 1 ? 1 : 7)); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); };
$('#tp').onclick = () => shift(-1); $('#tn').onclick = () => shift(1);
$('#tt').onclick = () => { const d = new Date(); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); };
$('#cbody').querySelectorAll('.tgd').forEach(b => b.onclick = () => { if (nDays === 7) { calState.sel = b.dataset.day; store.set('cal_view', 'day'); viewCalendar(); } });
$('#cbody').querySelectorAll('.tgcol').forEach(c => c.onclick = e => { if (e.target === c) newEvent(c.dataset.day); });
}
+239
View File
@@ -0,0 +1,239 @@
'use strict';
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
async function loadRange(start, days) {
const end = new Date(start); end.setDate(end.getDate() + days);
const last = new Date(end.getTime() - 1);
const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
const { events, errors } = await jfetch('/api/cal/events?' + q);
const byDay = new Map();
const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
for (const e of events) {
if (e.allDay) { // spans start..end-1 (end exclusive)
for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
} else put(ymd(new Date(e.start)), e);
}
for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
return { byDay, errors, start };
}
const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evStore = []; // events currently on screen, so a tap can open the right one
const evRow = e => { const i = evStore.push(e) - 1;
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); });
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
async function renderAgenda(el, mode) { // Home card (today only)
if (!el) return;
try {
const cals = await jfetch('/api/cal/list');
const { byDay } = await loadAgenda(1);
const list = byDay.get(ymd(new Date())) || [];
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
if (!document.body.contains(el)) return;
el.innerHTML = `<h2>Today</h2><div class="list">${list.length ? list.slice(0, 6).map(evRow).join('') : '<div class="row"><small>Nothing on the calendar today</small></div>'}</div>`;
} catch {}
}
const now0 = new Date();
const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
async function viewCalendar() {
const mode = store.get('cal_view', 'month');
const v = $('#view');
v.innerHTML = `<div class="wrap"><h1>Calendar</h1>
<div class="seg">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div>
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
$('#mc').onclick = manageCalendars;
try {
const cals = await jfetch('/api/cal/list');
if (!cals.length) { $('#cbody').innerHTML = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#ac').onclick = addCalendar; return; }
if (mode === 'month') await renderMonth(); else if (mode === 'week') await renderTimeGrid(7); else if (mode === 'day') await renderTimeGrid(1); else await renderAgendaList();
} catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
async function renderAgendaList() {
const range = store.get('cal_range', 7);
const { byDay, errors } = await loadAgenda(range);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
const keys = [...byDay.keys()].sort();
$('#cbody').innerHTML = `<div class="seg">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<a href="#" data-n="${n}" class="${n === range ? 'on' : ''}">${l}</a>`).join('')}</div>`
+ (keys.map(k => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
+ errors.map(e => `<p class="err">${esc(e)}</p>`).join('');
$('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
}
async function renderMonth() {
const { y, m } = calState;
const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st
const { byDay, errors } = await loadRange(gridStart, 42);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
const today = ymd(new Date());
if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first);
const cells = [];
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart); d.setDate(d.getDate() + i);
const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m;
cells.push(`<button class="cday${other ? ' dim' : ''}${k === today ? ' today' : ''}${k === calState.sel ? ' sel' : ''}" data-d="${k}" aria-label="${esc(d.toDateString())}, ${evs.length} events">
<span class="dn">${d.getDate()}</span>
<span class="dots">${evs.slice(0, 4).map(e => `<i style="background:${esc(e.color)}"></i>`).join('')}</span>
<span class="chips">${evs.slice(0, 3).map(e => `<em style="--c:${esc(e.color)}">${esc(e.title)}</em>`).join('')}${evs.length > 3 ? `<small>+${evs.length - 3} more</small>` : ''}</span></button>`);
}
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="pm" aria-label="Previous month">${icon('back')}</button>
<h2 style="margin:0;flex:1;text-align:center;font-size:17px;text-transform:none;letter-spacing:0;color:var(--text)">${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}</h2>
<button class="ibtn" id="nm" aria-label="Next month" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tm" style="min-height:36px">Today</button></div>
<div class="cgrid wk">${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `<span>${d}</span>`).join('')}</div>
<div class="cgrid">${cells.join('')}</div>
<div id="dayev"></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
const showDay = () => {
const k = calState.sel, evs = byDay.get(k) || [];
$('#dayev').innerHTML = `<h2>${esc(dayLabel(k))}</h2><div class="list">${evs.length ? evs.map(evRow).join('') : '<div class="row"><small>No events</small></div>'}</div>`;
$('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k));
};
showDay();
$('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); });
const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
$('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1);
$('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
}
const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const pad2 = n => String(n).padStart(2, '0');
const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
function openEvent(e) {
if (!e) return;
if (e.editable) return editEvent(e);
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
}
async function editEvent(e) {
try {
const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
const r = await jfetch('/api/cal/event?' + q);
eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
} catch (err) { toast(err.message); }
}
async function newEvent(day) {
try {
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
const d = new Date(day + 'T00:00:00'), now = new Date();
if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
const end = new Date(d.getTime() + 36e5);
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
} catch (err) { toast(err.message); }
}
function eventForm(o) {
const f = { ...o.fields }, editing = o.mode === 'edit';
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`;
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
<div id="fdt"></div>
<label for="fr">Repeat</label><select id="fr">${opts.map(([v, l]) => `<option value="${v}" ${v === f.repeat ? 'selected' : ''}>${l}</option>`).join('')}</select>
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
${calPick}
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
<div class="err" id="fe"></div></form>`);
el.querySelector('.sheet').classList.add('wide');
const draw = () => { // date-only inputs for all-day, date+time otherwise
const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
el.querySelector('#fdt').innerHTML = `<label for="fs">Starts</label><input type="${t}" id="fs" value="${cut(f.start)}" required><label for="fe2">Ends</label><input type="${t}" id="fe2" value="${cut(f.end)}" required>
${!f.allDay && f.tz !== deviceTz() ? `<p class="sub" style="margin:6px 0 0">Times are in ${esc(f.tz)}.</p>` : ''}`;
const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
s.onchange = () => { // moving the start keeps the length
const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
};
en.onchange = () => { f.end = en.value; };
};
draw();
el.querySelector('#fa').onchange = ev => {
f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
f.allDay = ev.target.checked;
if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
draw();
};
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#ef').onsubmit = async ev => {
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
const cal = editing ? o.calId : el.querySelector('#fc').value;
try {
await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
if (!editing) store.set('cal_default', cal);
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
};
const del = el.querySelector('#fd');
if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
catch (err) { toast(err.message); }
});
}
function addCalendar() {
const el = openSheet('Add a calendar link', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
<label>Private calendar link (ICS)</label><input type="url" id="cu2" placeholder="https://… or webcal://…" autocomplete="off" autocapitalize="off" required>
<p class="sub" style="margin:10px 0 0">Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.</p>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Add</button></div><div class="err" id="ce"></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); }
catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
};
}
async function manageCalendars() {
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
const links = cals.filter(c => !c.local && !c.icloud);
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div>
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')}
</div>
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div>
<h2 style="margin-top:14px">iCloud calendars</h2>
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div>
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
: `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p>
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Connect iCloud</button></div><div class="err" id="ice"></div></form>`}
<h2 style="margin-top:14px">See NE Hub events in other apps</h2>
<p class="sub">Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).</p>
<input type="text" readonly id="fu" value="${esc(feed.url)}" onfocus="this.select()">
<div class="bar"><button class="btn ghost" id="fc2" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" id="fr2" style="flex:1;justify-content:center">Make a new link</button></div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide');
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
const icf = el.querySelector('#icf');
if (icf) icf.onsubmit = async e => {
e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
};
el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
}
+73
View File
@@ -0,0 +1,73 @@
'use strict';
// ---- File actions
const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac' };
const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
const encP = p => p.split('/').map(encodeURIComponent).join('/');
const fileUrl = async (repo, path) => { const u = new URL(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), cfg.seafile); return '/sf' + u.pathname; };
// Recently opened files, shown on Home (kept in this browser only).
function addRecent(repo, path, name) {
const r = store.get('recents', []).filter(x => !(x.repo === repo && x.path === path));
r.unshift({ repo, path, name, t: Date.now() }); store.set('recents', r.slice(0, 12));
}
// Default tap on a file: edit documents, preview media, otherwise show the actions.
function openFile(repo, path, name, dir) {
if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); }
else if (previewKind(name)) previewFile(repo, path, name);
else fileSheet(repo, path, name, dir);
}
async function previewFile(repo, path, name) {
addRecent(repo, path, name);
try {
const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
const media = kind === 'img' ? `<img src="${url}" alt="${esc(name)}" style="max-width:100%;max-height:70dvh;border-radius:12px;display:block;margin:8px auto">`
: kind === 'pdf' ? `<iframe src="${url}" title="${esc(name)}" style="width:100%;height:70dvh;border:0;border-radius:12px;background:#fff"></iframe>`
: kind === 'video' ? `<video src="${url}" controls playsinline style="width:100%;max-height:70dvh;border-radius:12px;background:#000"></video>`
: `<audio src="${url}" controls style="width:100%;margin:12px 0"></audio>`;
const el = openSheet(name, '', `${media}<div class="bar"><a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Open</a><button class="btn ghost" id="sx">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
} catch (e) { toast(e.message); }
}
async function shareLink(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path;
try {
let link;
try { link = (await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORM, body: new URLSearchParams({ repo_id: repo, path: p }) })).link; }
catch { link = (await sf.req(`/api/v2.1/share-links/?repo_id=${encodeURIComponent(repo)}&path=${encodeURIComponent(p)}`))[0]?.link; }
if (!link) throw new Error('Could not create a share link');
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || '', `<input type="text" readonly id="sl" value="${esc(link)}" onfocus="this.select()">
<div class="bar"><button class="btn" id="cp1" style="flex:1;justify-content:center">${icon('copy')}Copy</button>${navigator.share ? '<button class="btn ghost" id="sh1" style="flex:1;justify-content:center">Share…</button>' : ''}</div>`);
el.querySelector('#cp1').onclick = async () => { try { await navigator.clipboard.writeText(link); toast('Link copied'); closeSheet(); } catch { el.querySelector('#sl').select(); toast('Press copy on the selected link'); } };
const sh = el.querySelector('#sh1'); if (sh) sh.onclick = () => navigator.share({ url: link }).catch(() => {});
} catch (e) { toast(e.message); }
}
function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' };
if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
if (cfg.editor && EDIT_EXT.has(ext(name)) && !ro) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); } });
if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') });
acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) });
acts.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, path) });
acts.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, path, name, ro) });
if (!ro) acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
if (!ro) acts.push({ label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) });
acts.push({ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) });
if (!ro) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) });
actionSheet(name, '', acts);
}
function dirSheet(repo, path, name, dir) {
const ro = libPerm[repo] === 'r', it = { name, type: 'dir' };
actionSheet(name, 'Folder', [
{ label: 'Open', icon: 'files', run: () => { location.hash = '#/files/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } },
{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
...(ro ? [] : [{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) }, { label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }]),
{ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) },
...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]),
]);
}
+35
View File
@@ -0,0 +1,35 @@
// Version history for a Seafile file: see earlier versions, download one, or restore it.
// Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js.
async function fileHistory(repo, path, name, readOnly) {
const base = `/api2/repos/${encodeURIComponent(repo)}/file`;
const el = openSheet('Version history', name, '<p class="empty">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
let commits = [], next = '';
for (let page = 0; page < 3; page++) { // up to three pages of history
const r = await sf.req(`${base}/history/?p=${encodeURIComponent(path)}${next ? '&commit_id=' + encodeURIComponent(next) : ''}`);
commits = commits.concat(r.commits || []);
next = r.next_start_commit; if (!next) break;
}
if (!document.body.contains(el)) return;
if (!commits.length) { el.querySelector('.empty').textContent = 'No earlier versions are kept for this file.'; return; }
const body = el.querySelector('.empty');
body.outerHTML = `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${commits.map((c, i) => `
<div class="row"><span class="grow"><b>${esc(new Date((c.ctime || 0) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${i === 0 ? ' · current' : ''}</b>
<small style="white-space:normal">${esc(fmtSize(c.rev_file_size || 0))}${c.creator_name ? ' · ' + esc(c.creator_name) : ''}</small></span>
${i === 0 ? '' : `<button class="btn ghost" data-dl="${i}" style="min-height:36px">Download</button>${readOnly ? '' : `<button class="btn ghost" data-rs="${i}" style="min-height:36px">Restore</button>`}`}</div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`;
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-dl]').forEach(b => b.onclick = async () => {
const c = commits[+b.dataset.dl];
try { window.open(await sf.req(`${base}/revision/?p=${encodeURIComponent(path)}&commit_id=${encodeURIComponent(c.id)}`), '_blank', 'noopener'); } catch (e) { toast(e.message); }
});
el.querySelectorAll('[data-rs]').forEach(b => b.onclick = () => {
const c = commits[+b.dataset.rs], when = new Date((c.ctime || 0) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
confirmSheet('Restore this version?', name + ' goes back to how it was on ' + when + '. The current version stays in the history.', 'Restore', async () => {
try { await sf.req(`${base}/revert/`, { method: 'POST', headers: FORM, body: new URLSearchParams({ p: path, commit_id: c.id }) }); toast('Restored'); if (typeof route === 'function') route(); }
catch (e) { toast(e.message); }
});
});
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
}
+398
View File
@@ -0,0 +1,398 @@
'use strict';
// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
async function viewEdit([repo, ...rawParts], opts = {}) {
const parts = rawParts.filter(Boolean);
const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
const v = $('#view');
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
if (!sf.token()) return (location.hash = '#/files');
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
try {
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
const conf = await r.json();
if (!r.ok) return fail(conf.error || 'Could not open document');
if (!window.DocsAPI) await new Promise((ok, no) => {
const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js';
s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s);
});
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
$('#oomsg').remove();
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
editor = new DocsAPI.DocEditor('oo', conf);
} catch (e) { fail(e.message); }
}
// ---- Files
function viewFiles([repo, ...parts]) {
if (!sf.token()) return filesLogin();
const dir = '/' + parts.join('/');
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
libsP = loadLibs(repo);
if (repo) loadDir(repo, dir);
}
function filesLogin(msg = '', needOtp = false) {
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><p class="sub">Sign in to ${esc(new URL(cfg.seafile).host)}</p>
<form class="card" id="lf"><label>Email / username</label><input type="text" name="u" autocomplete="username" autocapitalize="off" required>
<label>Password</label><input type="password" name="p" autocomplete="current-password" required>
${needOtp ? '<label>2FA code</label><input type="text" name="o" inputmode="numeric" autocomplete="one-time-code">' : ''}
<div class="bar"><button class="btn" type="submit">Sign in</button></div><div class="err">${esc(msg)}</div></form></div>`;
$('#lf').onsubmit = async e => {
e.preventDefault();
const f = new FormData(e.target);
try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); }
catch (err) { filesLogin(err.message, !!err.otp || needOtp); }
};
}
async function loadLibs(active) {
try {
const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name));
libNames = Object.fromEntries(repos.map(r => [r.id, r.name])); libPerm = Object.fromEntries(repos.map(r => [r.id, r.permission]));
$('#libs').innerHTML = `<div class="bar" style="margin-top:0"><button class="btn ghost" id="nl">${icon('newdir')}New library</button></div><h2 style="margin-top:8px">Libraries</h2><div class="list">${repos.map(r => `
<a class="row" href="#/files/${encodeURIComponent(r.id)}" ${r.id === active ? 'style="background:var(--panel2)"' : ''}>
<span class="ic" style="background:#2f9e6e">${icon('files')}</span>
<span class="grow"><b>${esc(r.name)}</b><small>${r.encrypted ? 'Encrypted · ' : ''}${esc(r.type === 'repo' ? 'Mine' : r.owner_name || r.owner || 'Shared')}</small></span>${icon('chev', 'chev')}</a>`).join('') || '<p class="empty">No libraries</p>'}</div>`;
$('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => {
try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); }
catch (e) { toast(e.message); }
});
if (!active) return;
const r = repos.find(x => x.id === active);
if (r && r.encrypted) $('#fmain').innerHTML = '<p class="empty">This library is encrypted. Unlock it in the Seafile web UI first.</p>';
} catch (e) { $('#libs').innerHTML = `<p class="err">${esc(e.message)}</p>`; if (!sf.token()) setTimeout(route, 800); }
}
// ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download)
let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {};
const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']];
const nameCmp = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
function sortItems(items, s) {
const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp;
return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top
: (a.type === 'dir' && s.k !== 'name' && s.k !== 'mtime') ? nameCmp(a, b) : s.d * by(a, b) || nameCmp(a, b));
}
const badName = (n, items, self) => !n || n === '.' || n === '..' ? 'Enter a name' : /[\/\\]/.test(n) ? 'A name cannot contain / or \\' : n.length > 255 ? 'That name is too long'
: items.some(x => x.name.toLowerCase() === n.toLowerCase() && x.name !== self) ? 'Something with that name is already here' : '';
const sfJson = (path, body) => sf.req(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const hereItems = (repo, dir) => (fv && fv.repo === repo && fv.dir === dir ? fv.items : []);
// After something changed in a folder, redraw it if it is the one on screen.
function afterChange(repo, dir) { if (fv && document.contains(fv.root) && fv.repo === repo && fv.dir === dir) { fv.sel.clear(); fv.selecting = false; fv.reload(); } }
async function removeItems(repo, dir, items) {
const enc = encodeURIComponent;
try { await sfJson('/api/v2.1/repos/batch-delete-item/', { repo_id: repo, parent_dir: dir, dirents: items.map(x => x.name) }); }
catch (e) { // older servers have no batch call: one at a time
const failed = [];
for (const it of items) { try { await sf.req(`/api2/repos/${enc(repo)}/${it.type === 'dir' ? 'dir' : 'file'}/?p=${enc(joinP(dir, it.name))}`, { method: 'DELETE' }); } catch { failed.push(it.name); } }
if (failed.length) throw new Error('Could not delete: ' + failed.join(', '));
}
}
function deleteItems(repo, dir, items) {
const one = items.length === 1;
confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`,
one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => {
try { await removeItems(repo, dir, items); toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); }
afterChange(repo, dir);
});
}
async function transferItems(mode, repo, dir, names, dst) {
await sfJson(`/api/v2.1/repos/sync-batch-${mode}-item/`, { src_repo_id: repo, src_parent_dir: dir, dst_repo_id: dst.repo, dst_parent_dir: dst.dir, src_dirents: names });
}
function moveSheet(mode, repo, dir, items) {
const names = items.map(x => x.name), folders = items.filter(x => x.type === 'dir').map(x => joinP(dir, x.name)), verb = mode === 'move' ? 'Move' : 'Copy';
chooseFolder(verb + (items.length === 1 ? ' “' + names[0] + '”' : ' ' + items.length + ' items') + ' to…', { repo, repoName: libNames[repo] || '', dir },
{ okLabel: verb + ' here', hide: (r, d) => (r === repo && d === dir ? names : []), newFolder: true,
blocked: (r, d) => r === repo && ((mode === 'move' && d === dir) || folders.some(f => d === f || d.startsWith(f + '/'))) }).then(async dst => {
if (!dst) return;
try { await transferItems(mode, repo, dir, names, dst); toast((mode === 'move' ? 'Moved' : 'Copied') + (items.length > 1 ? ' ' + items.length + ' items' : '')); }
catch (e) { toast(e.message); }
afterChange(repo, dir);
});
}
function renameItem(repo, path, name, dir, isDir) {
textSheet('Rename', 'New name', 'Rename', async n => {
if (n === name) return;
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${isDir ? 'dir' : 'file'}/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'rename', newname: n }) }); toast('Renamed'); }
catch (e) { toast(e.message); }
afterChange(repo, dir);
}, name, n => badName(n, hereItems(repo, dir), name));
}
async function downloadItem(repo, path) {
try { window.open(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), '_blank', 'noopener'); } catch (e) { toast(e.message); }
}
// ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel
const uq = { items: [], running: 0 };
const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up');
function enqueueUploads(repo, dir, entries) {
if (!entries.length) return;
for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', pct: 0, state: 'wait', tries: 0 });
drawUpBar(); pumpUploads();
}
function pumpUploads() {
while (uq.running < 2) {
const it = uq.items.find(x => x.state === 'wait'); if (!it) break;
uq.running++; it.state = 'up';
sendUpload(it).then(() => { it.state = 'done'; }, err => { it.state = it.cancelled ? 'cancel' : 'fail'; it.err = err.message; })
.finally(() => { uq.running--; drawUpBar(); pumpUploads(); if (!upActive()) uploadsFinished(); });
}
}
async function sendUpload(it) {
const enc = encodeURIComponent;
for (;;) {
it.tries++; it.pct = 0;
try {
const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry
await new Promise((ok, no) => {
const x = it.xhr = new XMLHttpRequest(), fd = new FormData();
fd.append('parent_dir', it.dir); fd.append('replace', '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name);
x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1');
x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } };
x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 }));
x.onerror = () => no(new Error('Connection lost'));
x.onabort = () => no(Object.assign(new Error('Cancelled'), { fatal: true }));
x.send(fd);
});
return;
} catch (e) {
if (it.cancelled) throw e;
if (e.fatal || !sf.token() || it.tries >= 3) throw new Error(it.file.name + ': ' + e.message);
await sleep(1500 * it.tries);
}
}
}
function uploadsFinished() {
const done = uq.items.filter(x => x.state === 'done').length, bad = uq.items.filter(x => x.state === 'fail').length;
if (done) toast(done + (done === 1 ? ' file' : ' files') + ' uploaded' + (bad ? ', ' + bad + ' failed' : ''));
uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar();
if (fv && document.contains(fv.root)) fv.reload(true);
}
function drawUpBar() {
let b = $('#upbar');
const live = uq.items.filter(x => x.state === 'wait' || x.state === 'up'), bad = uq.items.filter(x => x.state === 'fail');
if (!live.length && !bad.length) { if (b) b.remove(); return; }
if (!b) { b = document.createElement('div'); b.id = 'upbar'; document.body.appendChild(b); }
if (live.length) {
const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0];
const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100);
b.innerHTML = `<div class="upm"><b>Uploading ${esc(live.length === 1 ? cur.file.name : live.length + ' files')}</b><small>${pct}%</small><i style="width:${pct}%"></i></div><button class="btn ghost" id="upx">Cancel</button>`;
$('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); };
} else {
b.innerHTML = `<div class="upm"><b>${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed</b><small>${esc(bad[0].err || '')}</small></div><button class="btn" id="upr">Retry</button><button class="btn ghost" id="upd">Dismiss</button>`;
$('#upr').onclick = () => { bad.forEach(x => { x.state = 'wait'; x.tries = 0; x.cancelled = false; }); drawUpBar(); pumpUploads(); };
$('#upd').onclick = () => { uq.items = uq.items.filter(x => x.state !== 'fail'); drawUpBar(); };
}
}
addEventListener('beforeunload', e => { if (upActive()) { e.preventDefault(); e.returnValue = ''; } });
// Everything dropped from the computer, including whole folders (their structure is kept).
async function droppedFiles(dt) {
const ents = [...(dt.items || [])].map(i => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null)).filter(Boolean), out = [];
if (!ents.length) return [...dt.files].map(file => ({ file, rel: '' }));
const walk = async (en, rel) => {
if (en.isFile) await new Promise(ok => en.file(f => { out.push({ file: f, rel }); ok(); }, ok));
else if (en.isDirectory) {
const here = rel ? rel + '/' + en.name : en.name, rd = en.createReader(); let batch;
do { batch = await new Promise(ok => rd.readEntries(ok, () => ok([]))); for (const c of batch) await walk(c, here); } while (batch.length);
}
};
for (const en of ents) await walk(en, '');
return out;
}
const dlUrls = new Map(); // direct download addresses, fetched ahead so a file can be dragged out to the desktop
async function prefetchDl(repo, path) {
const k = repo + path, c = dlUrls.get(k); if (c && Date.now() - c.t < 45 * 60e3) return;
dlUrls.set(k, { t: Date.now() });
try { const u = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`); dlUrls.set(k, { t: Date.now(), url: new URL(u, cfg.seafile).href }); } catch { dlUrls.delete(k); }
}
async function loadDir(repo, dir) {
const main = $('#fmain'); if (!main) return;
main.innerHTML = '<p class="empty">Loading…</p>';
await libsP.catch(() => {});
if (!document.contains(main)) return;
const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
const segs = dir.split('/').filter(Boolean);
const crumbs = `<button data-go="/">${esc(libNames[repo] || 'Library')}</button>` + segs.map((s, i) => `<span>/</span><button data-go="/${esc(segs.slice(0, i + 1).join('/'))}">${esc(s)}</button>`).join('');
const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null };
const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 });
const byName = n => v.items.find(x => x.name === n);
const selItems = () => v.items.filter(x => v.sel.has(x.name));
const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
main.innerHTML = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
const go = p => { location.hash = hash(p); };
const drawTop = () => {
const n = v.sel.size, one = n === 1 ? selItems()[0] : null;
$('#ftop').innerHTML = v.selecting
? `<div class="selbar"><div class="bar"><b class="selcount">${n ? n + ' selected' : 'Select items'}</b>
<button class="btn ghost" data-a="all">${n && n === visible().length ? 'None' : 'All'}</button><button class="btn" data-a="done">Done</button></div>
<div class="bar">${ro ? '' : `<button class="btn ghost" data-a="move" ${n ? '' : 'disabled'}>${icon('files')}Move</button>`}
<button class="btn ghost" data-a="copy" ${n ? '' : 'disabled'}>${icon('copy')}Copy</button>
${ro ? '' : `<button class="btn ghost" data-a="del" ${n ? '' : 'disabled'} style="color:var(--danger)">${icon('trash')}Delete</button>`}
<button class="btn ghost" data-a="more" aria-label="More" ${one ? '' : 'disabled'}>${icon('more')}</button></div></div>`
: `<div class="bar"><a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
${ro ? '' : `<button class="btn ghost" data-a="up">${icon('up')}Upload</button><button class="btn ghost" data-a="new">${icon('newdir')}New…</button>`}
<button class="btn ghost" data-a="sel">${icon('check')}Select</button>
<button class="btn ghost" data-a="sort" aria-label="Sort">${icon('sort')}</button>
<button class="btn ghost" data-a="refresh" aria-label="Refresh">${icon('refresh')}</button>
<input id="upf" type="file" multiple hidden><input id="upd2" type="file" webkitdirectory hidden></div>`;
};
const drawItems = () => {
const list = visible(), box = $('#items'), ff = $('#ff');
ff.hidden = v.items.length < 8;
const row = it => { const d = it.type === 'dir', on = v.sel.has(it.name);
return `<div class="row it fr${on ? ' sel' : ''}" data-n="${esc(it.name)}" ${ro ? '' : 'draggable="true"'}>
${v.selecting ? `<span class="ck${on ? ' on' : ''}" aria-hidden="true">${on ? icon('check') : ''}</span>` : ''}
<button class="rmain" aria-label="${d ? 'Open folder ' : 'Open '}${esc(it.name)}"><span class="ic" style="background:${d ? '#e0a82b' : OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon(d ? 'files' : 'file')}</span>
<span class="grow"><b>${esc(it.name)}</b><small>${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}</small></span></button>
${v.selecting ? '' : `<button class="ibtn" data-more aria-label="Actions for ${esc(it.name)}">${icon('more')}</button>`}</div>`; };
box.innerHTML = list.length ? `<div class="list">${list.map(row).join('')}</div>`
: `<p class="empty">${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}</p>`;
const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0);
$('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : '';
};
const redraw = () => { drawTop(); drawItems(); };
const toggle = (it, range) => {
const vis = visible().map(x => x.name);
if (range && v.last && vis.includes(v.last) && vis.includes(it.name)) { const a = vis.indexOf(v.last), b = vis.indexOf(it.name); vis.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(n => v.sel.add(n)); }
else { v.sel.has(it.name) ? v.sel.delete(it.name) : v.sel.add(it.name); v.last = it.name; }
v.selecting = true; redraw();
};
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
const openIt = it => it.type === 'dir' ? go(joinP(dir, it.name)) : openFile(repo, joinP(dir, it.name), it.name, dir);
const oneActions = it => {
const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }];
if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) });
if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) });
if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) });
return a;
};
const create = async (path, op, extra = {}, kind = 'file') => {
try { await sf.req(`/api2/repos/${enc(repo)}/${kind}/?p=${enc(path)}`, { method: 'POST', body: new URLSearchParams({ operation: op, ...extra }), headers: FORM }); v.reload(true); }
catch (e) { toast(e.message); }
};
const actions = {
all: () => { const vis = visible(); if (v.sel.size === vis.length) v.sel.clear(); else vis.forEach(x => v.sel.add(x.name)); redraw(); },
move: () => v.sel.size && moveSheet('move', repo, dir, selItems()),
copy: () => v.sel.size && moveSheet('copy', repo, dir, selItems()),
del: () => v.sel.size && deleteItems(repo, dir, selItems()),
more: () => { const it = selItems()[0]; if (it) actionSheet(it.name, '', oneActions(it)); },
done: () => { v.selecting = false; v.sel.clear(); redraw(); },
sel: () => { v.selecting = true; redraw(); },
refresh: () => v.reload(),
sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort',
run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); },
up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]),
new: () => actionSheet('Create new', '', [
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir'), '', n => badName(n, v.items)) },
...[['Document', 'docx'], ['Spreadsheet', 'xlsx'], ['Presentation', 'pptx'], ['Text file', 'txt']].map(([label, x]) => ({ label, icon: 'doc',
run: () => textSheet('New ' + label.toLowerCase(), 'File name', 'Create', async n => {
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
try { const name = await createFromTemplate(repo, dir, want, x); v.reload(true); if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir); }
catch (e) { toast(e.message); }
}, '', n => badName(n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x, v.items)) })),
]),
};
v.actions = actions; v.selItems = selItems; v.openIt = openIt; v.visible = visible; v.one = oneActions;
$('#ftop').onclick = e => { const b = e.target.closest('[data-a]'); if (b && !b.disabled && actions[b.dataset.a]) actions[b.dataset.a](); };
$('#ftop').onchange = e => {
const pick = e.target.id === 'upf' || e.target.id === 'upd2' ? [...e.target.files] : null; if (!pick) return;
enqueueUploads(repo, dir, pick.map(f => ({ file: f, rel: f.webkitRelativePath ? f.webkitRelativePath.split('/').slice(0, -1).join('/') : '' }))); e.target.value = '';
};
$('#ff').oninput = e => { v.filter = e.target.value; drawItems(); };
main.querySelector('.crumbs').onclick = e => { const b = e.target.closest('[data-go]'); if (b) go(b.dataset.go); };
// tap / click / ctrl-click / shift-click, right-click, and long-press on a touch screen
const box = $('#items'); let suppress = false, lastTouch = 0, lp = null;
box.onclick = e => {
if (suppress) { suppress = false; return; }
const r = e.target.closest('.fr'); if (!r) return; const it = byName(r.dataset.n); if (!it) return;
if (e.target.closest('[data-more]')) return itemSheet(it);
if (v.selecting || e.metaKey || e.ctrlKey || e.shiftKey) return toggle(it, e.shiftKey);
openIt(it);
};
box.oncontextmenu = e => {
const r = e.target.closest('.fr'); if (!r) return; e.preventDefault(); if (Date.now() - lastTouch < 1200) return;
const it = byName(r.dataset.n); if (!it) return;
if (v.selecting && v.sel.size > 1 && v.sel.has(it.name)) return actionSheet(v.sel.size + ' selected', '', [ ...(ro ? [] : [{ label: 'Move', icon: 'files', run: actions.move }]), { label: 'Copy', icon: 'copy', run: actions.copy }, ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: actions.del }]) ]);
itemSheet(it);
};
box.onpointerdown = e => {
if (e.pointerType !== 'touch') return; lastTouch = Date.now();
const r = e.target.closest('.fr'); if (!r || v.selecting) return; const x0 = e.clientX, y0 = e.clientY;
const stop = () => { clearTimeout(lp); box.removeEventListener('pointermove', mv); };
const mv = m => { if (Math.abs(m.clientX - x0) + Math.abs(m.clientY - y0) > 10) stop(); };
box.addEventListener('pointermove', mv); box.addEventListener('pointerup', stop, { once: true }); box.addEventListener('pointercancel', stop, { once: true });
lp = setTimeout(() => { stop(); const it = byName(r.dataset.n); if (!it) return; suppress = true; v.sel.add(it.name); v.last = it.name; v.selecting = true; redraw(); try { navigator.vibrate && navigator.vibrate(12); } catch {} }, 450);
};
box.onmouseover = e => { const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (it && it.type !== 'dir' && !ro) prefetchDl(repo, joinP(dir, it.name)); };
// Drag: out to the desktop (one file, Chrome/Edge), onto a folder or breadcrumb to move (Alt/Option copies), in from the computer to upload.
box.ondragstart = e => {
const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (!it) return;
const names = v.sel.has(it.name) && v.sel.size ? [...v.sel] : [it.name];
e.dataTransfer.setData('application/x-hub-items', JSON.stringify({ repo, dir, names })); e.dataTransfer.effectAllowed = 'copyMove';
const c = it.type !== 'dir' && names.length === 1 && dlUrls.get(repo + joinP(dir, it.name));
if (c && c.url) e.dataTransfer.setData('DownloadURL', `application/octet-stream:${it.name.replace(/:/g, '-')}:${c.url}`);
else if (it.type !== 'dir') prefetchDl(repo, joinP(dir, it.name));
};
const target = e => { const t = e.target.closest('.fr, [data-go]'); if (!t) return null;
if (t.dataset.go) return { el: t, path: t.dataset.go }; const it = byName(t.dataset.n); return it && it.type === 'dir' ? { el: t, path: joinP(dir, it.name) } : null; };
const clearDrop = () => main.querySelectorAll('.dropt').forEach(x => x.classList.remove('dropt')), isFiles = e => [...(e.dataTransfer.types || [])].includes('Files');
main.ondragover = e => {
const mine = [...(e.dataTransfer.types || [])].includes('application/x-hub-items'); if (!mine && !isFiles(e) || ro) return;
e.preventDefault(); clearDrop(); const t = target(e); if (t) t.el.classList.add('dropt'); else if (isFiles(e)) main.classList.add('dropt'); e.dataTransfer.dropEffect = mine && e.altKey ? 'copy' : mine ? 'move' : 'copy';
};
main.ondragleave = e => { if (!main.contains(e.relatedTarget)) { clearDrop(); main.classList.remove('dropt'); } };
main.ondrop = async e => {
if (ro) return; e.preventDefault(); clearDrop(); main.classList.remove('dropt');
const t = target(e), mine = e.dataTransfer.getData('application/x-hub-items');
if (mine) {
const { names } = JSON.parse(mine); if (!t || t.path === dir || names.some(n => joinP(dir, n) === t.path)) return;
try { await transferItems(e.altKey ? 'copy' : 'move', repo, dir, names, { repo, dir: t.path }); toast((e.altKey ? 'Copied' : 'Moved') + (names.length > 1 ? ' ' + names.length + ' items' : '')); } catch (err) { toast(err.message); }
return afterChange(repo, dir);
}
if (isFiles(e)) { const files = await droppedFiles(e.dataTransfer); enqueueUploads(repo, t ? t.path : dir, files); }
};
v.reload = async quiet => {
const tok = v.tok = Symbol(); if (!quiet) $('#items').innerHTML = '<p class="empty">Loading…</p>';
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
if (v.tok !== tok || !document.contains(main)) return;
v.items = items; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
redraw();
} catch (e) {
if (v.tok !== tok || !document.contains(main)) return;
if (!sf.token()) return route();
$('#items').innerHTML = `<p class="err">${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}</p><div class="bar"><button class="btn ghost" id="fretry">Try again</button></div>`;
$('#fretry').onclick = () => v.reload();
}
};
drawTop(); v.reload();
}
const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
// Keep the folder fresh when you come back to the tab, and add Finder-style keys.
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && fv && document.contains(fv.root) && !upActive() && !document.getElementById('sheet')) fv.reload(true); });
addEventListener('keydown', e => {
if (!fv || !document.contains(fv.root) || document.getElementById('sheet') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) return;
const mod = e.metaKey || e.ctrlKey, n = fv.sel.size, one = n === 1 ? fv.selItems()[0] : null, a = fv.actions;
if (e.key === 'Escape' && (n || fv.selecting)) { a.done(); }
else if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); fv.selecting = true; fv.visible().forEach(x => fv.sel.add(x.name)); a.sel(); }
else if ((e.key === 'Delete' || e.key === 'Backspace') && n) { e.preventDefault(); a.del(); }
else if (e.key === 'F2' && one) { e.preventDefault(); const p = joinP(fv.dir, one.name); renameItem(fv.repo, p, one.name, fv.dir, one.type === 'dir'); }
else if (e.key === 'Enter' && one) fv.openIt(one);
});
+21
View File
@@ -53,6 +53,27 @@
</div> </div>
<div id="toast" role="status"></div> <div id="toast" role="status"></div>
<script src="/webauthn.js"></script> <script src="/webauthn.js"></script>
<script src="/links.js"></script>
<script src="/palette.js"></script>
<script src="/reminders.js"></script>
<script src="/file-history.js"></script>
<script src="/music-extras.js"></script>
<script src="/calendar-views.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
<script src="/search-view.js"></script>
<script src="/calendar.js"></script>
<script src="/tasks.js"></script>
<script src="/audiobooks.js"></script>
<script src="/music.js"></script>
<script src="/player.js"></script>
<script src="/lyrics-view.js"></script>
<script src="/settings.js"></script>
<script src="/files.js"></script>
<script src="/sheets.js"></script>
<script src="/file-actions.js"></script>
<script src="/signal-view.js"></script>
<script src="/mail.js"></script>
<script src="/boot.js"></script>
<script src="/shell.js"></script>
</body> </body>
</html> </html>
+43
View File
@@ -0,0 +1,43 @@
// Links tab: your own bookmarks, grouped. Saved with the other per-person settings (see app.js: store).
// ---- Links: your own bookmarks, grouped, saved with your other settings (private to you, every device)
const safeUrl = s => { let t = String(s || '').trim(); if (!t) return ''; if (!/^[a-z][a-z0-9+.-]*:/i.test(t)) t = 'https://' + t; try { const u = new URL(t); return /^https?:$/.test(u.protocol) ? u.href : ''; } catch { return ''; } };
const linkList = () => { const l = store.get('links', []); return Array.isArray(l) ? l.filter(x => x && safeUrl(x.url)) : []; };
const LINK_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f9d9a', '#c2417a'];
const linkColor = l => LINK_COLORS[[...String(l.title || l.url)].reduce((n, c) => n + c.charCodeAt(0), 0) % LINK_COLORS.length];
let linkQuery = '';
function viewLinks() {
const all = linkList(), q = linkQuery.trim().toLowerCase();
const shown = q ? all.filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(q)) : all;
const groups = [...new Set(shown.map(l => l.group || ''))].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b));
$('#view').innerHTML = `<div class="wrap"><h1>Links</h1>
<div class="bar"><input type="search" id="lq" placeholder="Filter links" value="${esc(linkQuery)}" aria-label="Filter links" style="flex:1"><button class="btn" id="ladd">${icon('plus')}Add</button></div>
${all.length ? '' : '<p class="empty">No links yet. Add the sites you use most; they are saved on your server and show up on every device.</p>'}
${all.length && !shown.length ? '<p class="empty">Nothing matches.</p>' : ''}
${groups.map(g => `${g ? `<h2>${esc(g)}</h2>` : (groups.length > 1 ? '<h2>Other</h2>' : '')}<div class="list">${shown.filter(l => (l.group || '') === g).map(l => `
<div class="row"><a class="grow" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer" style="display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;min-width:0">
<span class="ic" style="background:${linkColor(l)};color:#fff;font-weight:700">${esc([...(l.title || l.url)][0].toUpperCase())}</span>
<span class="grow" style="min-width:0"><b>${esc(l.title || l.url)}</b><small style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(new URL(safeUrl(l.url)).host)}</small></span></a>
<button class="btn ghost" data-edit="${esc(l.id)}" aria-label="Edit ${esc(l.title)}" style="min-height:36px">Edit</button></div>`).join('')}</div>`).join('')}</div>`;
$('#lq').oninput = e => { linkQuery = e.target.value; const p = e.target.selectionStart; viewLinks(); const n = $('#lq'); n.focus(); n.setSelectionRange(p, p); };
$('#ladd').onclick = () => linkSheet();
document.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => linkSheet(all.find(l => l.id === b.dataset.edit)));
}
function linkSheet(link) {
const groups = [...new Set(linkList().map(l => l.group).filter(Boolean))];
const el = openSheet(link ? 'Edit link' : 'Add a link', '', `<form id="lf"><label for="lt">Name</label><input type="text" id="lt" required autocomplete="off" value="${esc(link ? link.title : '')}" placeholder="Router">
<label for="lu">Address</label><input type="text" id="lu" required autocomplete="off" autocapitalize="off" inputmode="url" value="${esc(link ? link.url : '')}" placeholder="https://192.168.1.1">
<label for="lg">Group (optional)</label><input type="text" id="lg" list="lgl" autocomplete="off" value="${esc(link ? link.group || '' : '')}" placeholder="Home network"><datalist id="lgl">${groups.map(g => `<option value="${esc(g)}">`).join('')}</datalist>
<div class="bar">${link ? '<button class="btn danger" type="button" id="ld" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="le"></div></form>`);
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
if (link) el.querySelector('#ld').onclick = () => confirmSheet('Delete link?', link.title, 'Delete', () => { store.set('links', linkList().filter(l => l.id !== link.id)); viewLinks(); });
el.querySelector('#lf').onsubmit = e => {
e.preventDefault();
const url = safeUrl(el.querySelector('#lu').value);
if (!url) { el.querySelector('#le').textContent = 'That is not a web address (it must be http or https).'; return; }
const next = { id: link ? link.id : 'l' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), title: el.querySelector('#lt').value.trim().slice(0, 60), url, group: el.querySelector('#lg').value.trim().slice(0, 40) };
const rest = linkList();
store.set('links', link ? rest.map(l => l.id === link.id ? next : l) : [...rest, next]);
closeSheet(); viewLinks();
};
}
+3 -3
View File
@@ -14,11 +14,11 @@ async function init() {
$('#sub').textContent = 'Sign in with your passkey'; $('#sub').textContent = 'Sign in with your passkey';
$('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>'; $('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>';
$('#go').onclick = signIn; $('#go').onclick = signIn;
$('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device? Use an invite code</a></p>'); $('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device or lost your devices? Use an invite or recovery code</a></p>');
$('#inv').onclick = e => { $('#inv').onclick = e => {
e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device'; e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device';
$('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>.</p> $('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>. Lost every device? Use one of the <b>recovery codes</b> you made in Settings.</p>
<label for="ic">Invite code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required> <label for="ic">Invite or recovery code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required>
<label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off"> <label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off">
<div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`; <div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`;
$('#f').onsubmit = async ev => { $('#f').onsubmit = async ev => {
+126
View File
@@ -0,0 +1,126 @@
'use strict';
// ---- Lyrics and karaoke: lyrics from Plex when the song has them, otherwise from the Lyrica service on the server; synced ones follow the song.
function lyrParseLrc(text) {
const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000);
const lines = [];
for (const raw of String(text || '').split(/\r?\n/)) {
const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)];
if (!tags.length) continue;
let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = [];
if (/<\d+:\d{1,2}/.test(body)) {
const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)];
words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim());
words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; });
body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim();
}
for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) });
}
lines.sort((a, b) => a.t - b.t);
lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } });
return lines;
}
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
const lyrOffset = k => { try { return +localStorage.getItem('lyr_off:' + k) || 0; } catch { return 0; } };
const lyrSetOffset = (k, v) => { try { localStorage.setItem('lyr_off:' + k, String(v)); } catch {} };
const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + t.key)) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } };
const lyrCleanTitle = s => String(s || '').replace(/\s*[\(\[](feat\.?|ft\.?|with|remaster(ed)?|live|deluxe|version|mono|stereo)[^\)\]]*[\)\]]/gi, '').replace(/\s+-\s+(remaster(ed)?|live|mono|stereo|single version).*$/i, '').trim();
async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc file next to it, or downloaded by Plex)
try {
const mc = await px.get('/library/metadata/' + t.key), st = ((mc.Metadata || [])[0]?.Media?.[0]?.Part?.[0]?.Stream || []).find(x => x.streamType === 4 && x.key);
if (!st) return null;
const r = await fetch('/plex' + st.key + (st.key.includes('?') ? '&' : '?') + 'format=' + (st.format || 'txt'), { headers: { Accept: 'text/plain, */*' } }); if (!r.ok) return null;
let text = await r.text(); if (/^\s*[{<]/.test(text) && /MediaContainer|"Lyrics"/.test(text)) return null; // (not lyrics)
const timed = lyrParseLrc(text);
if (timed.length >= 2) return { found: true, source: 'Plex', sync: timed.some(l => l.words) ? 'word' : 'line', lines: timed.filter(l => l.text || l.words) };
const lines = text.split(/\r?\n/).map(x => ({ text: x.trim() })); return lines.some(l => l.text) ? { found: true, source: 'Plex', sync: 'none', lines } : null;
} catch { return null; }
}
async function lyrLoad(t, force) {
if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key);
let res = force ? null : await lyrFromPlex(t);
if (!res && cfg.lyrics) {
const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' });
try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; }
}
res = res || { found: false };
if (!res.error) lyr.cache.set(t.key, res);
return res;
}
function lyrOpen() {
const t = cur(); if (!t || lyr.open) return;
lyr.open = true; document.body.classList.add('lyropen');
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
document.body.appendChild(el); lyrShell(); lyrReload();
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
}
function lyrClose() {
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
}
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
function lyrShell() {
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
lyr.el.className = pref.karaoke ? 'kar' : '';
lyr.el.style.setProperty('--lf', pref.size + 'px');
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><span class="grow"></span>
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
const $l = q => lyr.el.querySelector(q);
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
$l('#lyrs').onclick = lyrSearchAgain;
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
$l('#lyrm').onclick = () => nudge(-500); $l('#lyrp').onclick = () => nudge(500);
syncUI();
}
async function lyrReload(force) {
const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2;
const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '<p class="empty">Looking for the lyrics…</p>';
const res = await lyrLoad(t, force); if (!lyr.el || lyr.key !== t.key) return;
lyr.data = res; lyrDraw();
}
function lyrDraw() {
const t = cur(), d = lyr.data, body = lyr.el && lyr.el.querySelector('#lyrbody'); if (!body || !t) return;
const src = lyr.el.querySelector('#lyrsrc'), nudge = lyr.el.querySelector('#lyrnudge');
nudge.hidden = !(d && d.found && d.sync !== 'none'); lyr.el.querySelector('#lyroff').textContent = (lyrOffset(t.key) / 1000).toFixed(1) + ' s';
if (!d || !d.found) { src.textContent = ''; body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return; }
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced');
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none') return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
body.scrollTop = 0; lyr.idx = -2;
}
function lyrSearchAgain() {
const t = cur(); if (!t) return; const q = lyrQuery(t);
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
<div class="bar"><button class="btn ghost" type="button" id="lyrc" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Search</button></div></form>`);
el.querySelector('#lyrc').onclick = closeSheet;
el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + t.key, JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(t.key); lyrReload(true); };
}
function lyrTick() { // follows the song: which line is current, and how far into it we are
lyr.raf = requestAnimationFrame(lyrTick);
const d = lyr.data, t = cur(); if (!lyr.el || !d || !d.found || d.sync === 'none' || !t) return;
const pos = pl.a.currentTime * 1000 + lyrOffset(t.key), L = d.lines;
let lo = 0, hi = L.length - 1, idx = -1; while (lo <= hi) { const m = (lo + hi) >> 1; if (L[m].t <= pos) { idx = m; lo = m + 1; } else hi = m - 1; }
const body = lyr.el.querySelector('#lyrbody'), nodes = body.querySelectorAll('.ll');
if (idx !== lyr.idx) {
lyr.idx = idx; let n = -1;
nodes.forEach(x => { const i = +x.dataset.i; x.classList.toggle('on', i === idx); x.classList.toggle('past', i < idx); x.classList.toggle('near', Math.abs(i - idx) === 1); x.classList.toggle('upnext', i === idx + 1); if (i === idx) n = i; });
const cur1 = body.querySelector('.ll.on'); if (cur1) cur1.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
const on = body.querySelector('.ll.on'); if (!on) return; const l = L[idx];
if (l.words) on.querySelectorAll('.w').forEach((w, j) => { const x = l.words[j]; if (!x) return; const p = pos >= x.e ? 1 : pos <= x.t ? 0 : (pos - x.t) / Math.max(1, x.e - x.t); w.classList.toggle('sung', p >= 1); w.style.setProperty('--p', p); });
else { const e = l.e && l.e > l.t ? l.e : l.t + 4000, p = Math.max(0, Math.min(1, (pos - l.t) / Math.max(400, Math.min(e - l.t, 7000)))); on.style.setProperty('--p', p); } // (no word timing: the colour sweeps across the line)
}
+200
View File
@@ -0,0 +1,200 @@
'use strict';
// Mail tab: your Outlook.com mailbox (through the server, see mail.js there). Folders, message list, reader, compose, reply/forward,
// search, unread badge. Message bodies are shown in a sandboxed frame with remote pictures blocked until you ask for them.
let mailUnread = 0;
const mail = { st: null, folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false };
const mailFrom = f => (f && (f.name || f.address)) || '';
const mailWhen = iso => {
const d = new Date(iso), now = new Date(); if (isNaN(d)) return '';
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' });
};
const mailFolderName = id => (mail.folders.find(f => f.id === id) || {}).name || (id === 'inbox' ? 'Inbox' : 'Folder');
/** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */
function mailDoc(html, images) {
const csp = `default-src 'none'; img-src data: ${images ? 'https: http:' : ''}; style-src 'unsafe-inline'; font-src data:`;
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><base target="_blank"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{margin:14px;font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;color:#111;background:#fff;overflow-wrap:anywhere}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1a5fd1}blockquote{margin:0 0 0 6px;padding-left:10px;border-left:3px solid #ccc;color:#555}</style>${html}`;
}
const mailHasRemote = html => /<img[^>]+src=["']?https?:|url\(\s*["']?https?:/i.test(html || '');
// ---- unread badge and Home card
async function mailPoll() {
if (!(allowed('mail') && cfg.mail)) return;
try { const s = await jfetch('/api/mail/status'); mail.st = s; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } } catch {}
}
async function renderMailCard(el) {
if (!el) return;
try {
const s = mail.st || await jfetch('/api/mail/status'); mail.st = s;
if (!s.connected || !document.body.contains(el)) return;
const r = await jfetch('/api/mail/messages?folder=inbox'); if (!document.body.contains(el)) return;
const top = r.items.filter(m => !m.read).slice(0, 3);
el.innerHTML = `<h2>Mail${s.unread ? ' · ' + s.unread + ' unread' : ''}</h2><div class="list">${top.length ? top.map(m => `<a class="row" href="#/mail/m/${encodeURIComponent(m.id)}"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>${esc(mailFrom(m.from))}</b><small>${esc(m.subject)}</small></span></a>`).join('') : `<a class="row" href="#/mail"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>Inbox is clear</b><small>${esc(s.address || '')}</small></span></a>`}</div>`;
} catch {}
}
function mailSettings() {
const box = $('#mailst'); if (!box) return;
jfetch('/api/mail/status').then(s => {
if (!$('#mailst')) return;
box.innerHTML = s.connected
? `<div class="row"><span class="grow"><b>Outlook.com</b><small>${esc(s.address || 'Connected')}</small></span><button class="btn danger" id="mdis" style="min-height:36px">Disconnect</button></div>`
: `<div class="row"><span class="grow"><b>Outlook.com</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Not connected</small></span><a class="btn ghost" href="#/mail" style="min-height:36px">Connect</a></div>`;
const d = $('#mdis'); if (d) d.onclick = () => confirmSheet('Disconnect mail?', 'NE Hub forgets your Outlook.com sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: '{}' }); mail.st = null; mailUnread = 0; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
}
// ---- the tab
async function viewMail(args = []) {
const v = $('#view');
if (!cfg.mail) { v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Mail is not set up on this server. An admin can switch it on (see the README, Mail).</p></div>'; return; }
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/mail')) return;
mail.st = st;
if (!st.connected) return mailConnectScreen(st);
if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); }
const [kind, key] = args;
if (kind === 'f' && key) mail.folder = key;
if (!mail.folders.length) { try { mail.folders = await jfetch('/api/mail/folders'); } catch {} }
mailShell(kind === 'm' && key ? 'read' : 'list');
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true);
else mailDrawList();
if (kind === 'm' && key) mailOpen(key); else { mail.msg = null; mailDrawReader(); }
}
function mailConnectScreen(st) {
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
<p class="sub">Read and send mail from your Outlook.com account right here. You sign in on Microsoft's own page; NE Hub never sees your password and only keeps a sign-in token on your server (encrypted).</p>
${st.expired ? '<p class="err">Microsoft signed you out of mail. Connect again to continue.</p>' : ''}
<div class="bar"><button class="btn" id="mcon">${icon('mail')}Connect Outlook.com</button></div><div class="err" id="mce"></div>
<p class="sub">Personal Outlook.com, Hotmail and Live accounts only.</p></div>`;
$('#mcon').onclick = async () => {
try { const r = await jfetch('/api/mail/connect'); location.href = r.url; }
catch (e) { $('#mce').textContent = e.message; }
};
}
function mailShell(mode) {
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
$('#view').innerHTML = `<div class="mailapp" data-mode="${mode}">
<aside class="mfold" aria-label="Folders">${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
<section class="mlist" aria-label="Messages">
<div class="mtool"><select id="mfsel" aria-label="Folder">${fl.map(f => `<option value="${esc(f.id)}" ${f.id === mail.folder ? 'selected' : ''}>${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''}</option>`).join('')}</select>
<input type="search" id="mq" placeholder="Search mail" value="${esc(mail.q)}" autocomplete="off" aria-label="Search mail">
<button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div>
<div id="mrows" class="list"></div><div id="mmore"></div>
</section>
<section class="mread" id="mread" aria-label="Message"></section></div>`;
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
$('#mnew').onclick = () => mailCompose();
$('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders').then(f => { mail.folders = f; mailPoll(); }).catch(() => {}); };
let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); };
}
async function mailLoadList(reset) {
if (reset) { mail.items = []; mail.next = 0; mail.more = false; }
const rows = $('#mrows'); if (rows && reset) rows.innerHTML = '<p class="empty">Loading…</p>';
try {
const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(mail.folder)}&skip=${mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`);
if (!$('#mrows')) return;
mail.items = mail.items.concat(r.items); mail.more = r.more; mail.next = r.next;
mailDrawList();
} catch (e) { const el = $('#mrows'); if (el) el.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; if (/Connect again/.test(e.message)) { mail.st = null; setTimeout(route, 1200); } }
}
function mailDrawList() {
const rows = $('#mrows'); if (!rows) return;
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
<span class="mmeta"><small>${esc(mailWhen(m.date))}</small>${m.flagged ? icon('check') : ''}${m.attach ? icon('clip') : ''}</span></a>`).join('') : `<p class="empty" style="padding:12px">${mail.q ? 'Nothing matches.' : 'No messages here.'}</p>`;
const more = $('#mmore'); more.innerHTML = mail.more ? '<div class="bar" style="justify-content:center"><button class="btn ghost" id="mmb">Load more</button></div>' : '';
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
}
async function mailOpen(id) {
const rd = $('#mread'); if (rd) rd.innerHTML = '<p class="empty" style="padding:16px">Opening…</p>';
mail.images = false;
try {
const m = await jfetch('/api/mail/message?id=' + encodeURIComponent(id));
if (!(location.hash.startsWith('#/mail/m/'))) return;
mail.msg = m; mailDrawReader(); mailDrawList();
if (!m.read) { // opening a message marks it read
jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => {
const it = mail.items.find(x => x.id === id); if (it) it.read = true; m.read = true; mailDrawList();
mailUnread = Math.max(0, mailUnread - 1); renderNav(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {});
}).catch(() => {});
}
} catch (e) { if ($('#mread')) $('#mread').innerHTML = `<p class="err" style="padding:16px">${esc(e.message)}</p>`; }
}
function mailDrawReader() {
const rd = $('#mread'); if (!rd) return;
const m = mail.msg;
if (!m) { rd.innerHTML = '<p class="empty" style="padding:24px">Choose a message to read it.</p>'; return; }
const addr = l => l.map(a => esc(a.name ? `${a.name} <${a.address}>` : a.address)).join(', ');
const remote = mailHasRemote(m.html) && !mail.images;
rd.innerHTML = `<div class="mrhead"><a class="btn ghost mback" href="#/mail/f/${encodeURIComponent(mail.folder)}">${icon('back')}${esc(mailFolderName(mail.folder))}</a>
<h2 class="msubj">${esc(m.subject)}</h2>
<p class="mfrom"><b>${esc(mailFrom(m.from))}</b> <small>${esc(m.from.address)}</small></p>
<p class="mto"><small>To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></p>
<div class="mact">
<button class="btn" data-a="reply">Reply</button><button class="btn ghost" data-a="replyAll">Reply all</button><button class="btn ghost" data-a="forward">Forward</button>
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
<iframe class="mbody" title="Message" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
}
async function mailAction(a) {
const m = mail.msg; if (!m) return;
const post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) });
const back = () => { mail.items = mail.items.filter(x => x.id !== m.id); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {}); };
try {
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); }
if (a === 'unread') { await post('update', { read: false }); const it = mail.items.find(x => x.id === m.id); if (it) it.read = false; toast('Marked unread'); mailUnread++; renderNav(); return back(); }
if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); }
if (a === 'delete') {
if (mail.folder === 'deleteditems') return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } });
await post('delete', {}); toast('Moved to Deleted'); return back();
}
} catch (e) { toast(e.message); }
}
// ---- compose, reply, forward
const MAIL_MAX = 3 * 1024 * 1024;
function mailCompose(o = {}) {
const reply = o.mode, m = o.msg, files = [];
const title = reply === 'reply' ? 'Reply' : reply === 'replyAll' ? 'Reply all' : reply === 'forward' ? 'Forward' : 'New message';
const el = openSheet(title, reply ? m.subject : '', `<form id="mf">
${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`}
${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div>
<label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`}
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
${reply ? '' : `<div class="bar" style="margin:8px 0 0;justify-content:flex-start"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('clip')}Attach<input type="file" id="mfile" multiple hidden></label><small id="mfl" style="color:var(--muted)"></small></div>`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="msend" style="flex:1;justify-content:center">Send</button></div><div class="err" id="mer"></div></form>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; };
const fi = el.querySelector('#mfile');
const total = () => files.reduce((n, f) => n + f.size, 0);
const drawFiles = () => { el.querySelector('#mfl').textContent = files.length ? files.map(f => f.name).join(', ') + ' (' + fmtSize(total()) + ')' : ''; };
if (fi) fi.onchange = () => { for (const f of fi.files) files.push(f); fi.value = ''; if (total() > MAIL_MAX) { files.pop(); el.querySelector('#mer').textContent = 'Attachments can total about 3 MB.'; } drawFiles(); };
const b64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); });
el.querySelector('#mf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('#msend'); btn.disabled = true; el.querySelector('#mer').textContent = '';
try {
const body = el.querySelector('#mbd').value;
if (reply) await jfetch('/api/mail/reply', { method: 'POST', body: JSON.stringify({ id: m.id, mode: reply, comment: body, to: reply === 'forward' ? el.querySelector('#mto').value : undefined }) });
else await jfetch('/api/mail/send', { method: 'POST', body: JSON.stringify({ to: el.querySelector('#mto').value, cc: el.querySelector('#mcc2').value, bcc: el.querySelector('#mbcc').value, subject: el.querySelector('#msb').value, body,
attachments: await Promise.all(files.map(async f => ({ name: f.name, type: f.type, data: await b64(f) }))) }) });
closeSheet(); toast('Sent');
} catch (err) { btn.disabled = false; el.querySelector('#mer').textContent = err.message; }
};
setTimeout(() => { const f = el.querySelector('#mto') || el.querySelector('#mbd'); if (f) f.focus(); }, 60);
}
+64
View File
@@ -0,0 +1,64 @@
// Music extras: Plex playlists you can change (create, add, remove, rename, delete, save the queue) and a sleep timer.
// Uses the player globals from app.js (px, pl, jfetch, actionSheet, textSheet, confirmSheet, toast, syncUI, saveState).
// ---- Plex playlists. The hub only lets /plex/playlists... writes through, so nothing else on the server can be changed from here.
const plx = {
_m: null,
async mid() { return plx._m || (plx._m = (await px.get('/identity')).machineIdentifier); },
async uri(keys) { return `server://${await plx.mid()}/com.plexapp.plugins.library/library/metadata/${keys.join(',')}`; },
async call(method, path) {
const r = await fetch('/plex' + path, { method, headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error('Plex said no (' + r.status + '). Your Plex account may not be allowed to change playlists.');
return r.json().catch(() => ({}));
},
async list() { return ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(p => !p.smart); },
async create(title, keys) { return plx.call('POST', `/playlists?type=audio&smart=0&title=${encodeURIComponent(title)}&uri=${encodeURIComponent(await plx.uri(keys))}`); },
async add(id, keys) { return plx.call('PUT', `/playlists/${encodeURIComponent(id)}/items?uri=${encodeURIComponent(await plx.uri(keys))}`); },
remove: (id, pid) => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}/items/${encodeURIComponent(pid)}`),
rename: (id, title) => plx.call('PUT', `/playlists/${encodeURIComponent(id)}?title=${encodeURIComponent(title)}`),
del: id => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}`),
};
/** Pick a playlist (or make a new one) and put these tracks in it. */
async function addToPlaylist(tracks, suggest = '') {
const keys = tracks.map(t => t.key).filter(Boolean); if (!keys.length) return;
let lists = [];
try { lists = await plx.list(); } catch (e) { return toast(e.message); }
const newOne = () => textSheet('New playlist', 'Playlist name', 'Create', async name => {
try { await plx.create(name, keys); toast('Playlist “' + name + '” created'); if (location.hash.startsWith('#/music/playlists')) route(); } catch (e) { toast(e.message); }
}, suggest);
actionSheet('Add to playlist', keys.length + (keys.length === 1 ? ' track' : ' tracks'), [
{ label: 'New playlist…', icon: 'plus', run: newOne },
...lists.map(p => ({ label: p.title, icon: 'music', run: async () => { try { await plx.add(p.ratingKey, keys); toast('Added to ' + p.title); if (location.hash === '#/music/playlist/' + p.ratingKey) route(); } catch (e) { toast(e.message); } } })),
]);
}
// ---- Sleep timer: fades the music out and pauses after a set time, or when the current song ends.
const sleepT = { end: 0, eot: false, timer: null, fade: null };
function sleepClear() { clearTimeout(sleepT.timer); clearInterval(sleepT.fade); sleepT.end = 0; sleepT.eot = false; pl.a.volume = 1; }
function sleepLabel() {
if (sleepT.eot) return 'Sleep · end of song';
if (sleepT.end) return 'Sleep · ' + Math.max(1, Math.round((sleepT.end - Date.now()) / 6e4)) + ' min';
return 'Sleep';
}
function sleepFade() {
clearInterval(sleepT.fade);
const t0 = Date.now(), v0 = pl.a.volume || 1;
sleepT.fade = setInterval(() => {
const f = Math.min(1, (Date.now() - t0) / 8000);
pl.a.volume = Math.max(0, v0 * (1 - f));
if (f >= 1) { clearInterval(sleepT.fade); pl.a.pause(); pl.a.volume = 1; sleepT.end = 0; const b = document.getElementById('cnz'); if (b) b.textContent = 'Sleep'; toast('Sleep timer: music stopped'); }
}, 200);
}
function sleepSet(minutes) {
sleepClear();
if (minutes === 'eot') sleepT.eot = true;
else if (minutes > 0) { sleepT.end = Date.now() + minutes * 6e4; sleepT.timer = setTimeout(sleepFade, minutes * 6e4); }
const b = document.getElementById('cnz'); if (b) b.textContent = sleepLabel();
toast(minutes ? (minutes === 'eot' ? 'Music will stop when this song ends' : 'Music will fade out in ' + minutes + ' minutes') : 'Sleep timer off');
}
function sleepSheet() {
const opts = [[15, '15 minutes'], [30, '30 minutes'], [45, '45 minutes'], [60, '1 hour'], [90, '1.5 hours'], ['eot', 'When this song ends']].map(([v, label]) => ({ label, icon: 'pause', run: () => sleepSet(v) }));
if (sleepT.end || sleepT.eot) opts.push({ label: 'Turn off the timer', icon: 'x', run: () => sleepSet(0) });
actionSheet('Sleep timer', sleepLabel() === 'Sleep' ? 'Fades the music out and pauses' : sleepLabel(), opts);
}
+162
View File
@@ -0,0 +1,162 @@
'use strict';
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
const jfetch = async (path, opts) => {
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
return j;
};
const px = {
secs: null,
async get(path) {
const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } });
if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true });
if (!r.ok) throw new Error('Plex error ' + r.status);
return (await r.json()).MediaContainer || {};
},
art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
// All music libraries on the server; the chosen one is remembered per server.
async sections() {
if (px.secs) return px.secs;
const d = (await px.get('/library/sections')).Directory || [];
px.secs = d.filter(x => x.type === 'artist').map(x => ({ key: x.key, title: x.title }));
if (!px.secs.length) { px.secs = null; throw new Error('No music library found on this Plex server'); }
return px.secs;
},
async section() {
const secs = await px.sections(), want = store.get('plex_lib', {})[px.serverKey];
return (secs.find(x => x.key === want) || secs[0]).key;
},
serverKey: '',
};
const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
const mapTrack = (t, mc) => {
const part = t.Media?.[0]?.Part?.[0];
return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '',
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 };
};
async function viewMusic(args) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
try {
const st = await jfetch('/api/plex/status');
if (!location.hash.startsWith('#/music')) return;
if (!st.signedIn) return plexSignIn();
if (!st.server) return plexPickServer();
await musicRoute(args, st);
renderNpCard();
} catch (e) {
if (e.auth) return plexSignIn();
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rs">Change server</button></div></div>`;
$('#rs').onclick = plexPickServer;
}
}
function plexSignIn(msg = '') {
$('#view').innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Play your Plex music library here.</p>
<div class="card"><p style="margin-top:0">You'll sign in on plex.tv in a new tab. This app never sees your Plex password.</p>
<div class="bar"><button class="btn" id="pl">Sign in with Plex</button></div><div class="err" id="pe">${esc(msg)}</div></div></div>`;
$('#pl').onclick = async () => {
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
try {
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `<a href="${esc(pin.authUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">Tap to open Plex sign-in</a>`;
for (let i = 0; i < 150; i++) {
await new Promise(r => setTimeout(r, 2000));
if (!location.hash.startsWith('#/music')) return;
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
if (r.ok) { try { w?.close(); } catch {} return route(); }
}
$('#pe').textContent = 'Timed out waiting for Plex. Try again.';
} catch (e) { $('#pe').textContent = e.message; }
};
}
async function plexPickServer() {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Finding your Plex servers…</p></div>';
try {
const servers = await jfetch('/api/plex/servers');
if (!servers.length) { v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="err">No Plex servers found on this account.</p></div>'; return; }
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Choose a server</p><div class="list">${servers.map(s => `
<button class="row" data-id="${esc(s.id)}"><span class="ic" style="background:#e5a00d">${icon('music')}</span>
<span class="grow"><b>${esc(s.name)}</b><small>${s.owned ? 'Yours' : 'Shared with you'}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="pe"></div><div id="pm"></div></div>`;
v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
b.disabled = true; $('#pe').textContent = '';
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.secs = null; route(); }
catch (e) {
b.disabled = false; $('#pe').textContent = e.message;
// Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails.
$('#pm').innerHTML = `<p class="sub">Know the server's address? Enter it (for example <b>http://192.168.1.10:32400</b>).</p>
<form id="pf2"><input type="url" id="pu" placeholder="http://192.168.1.10:32400" autocomplete="off" autocapitalize="off" required>
<div class="bar"><button class="btn">Try this address</button></div></form>`;
$('#pf2').onsubmit = async ev => {
ev.preventDefault(); $('#pe').textContent = '';
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.secs = null; route(); }
catch (e2) { $('#pe').textContent = e2.message; }
};
}
});
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
}
async function musicRoute([kind = 'albums', key], st) {
px.serverKey = st.server;
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
const tabs = ['albums', 'artists', 'playlists', 'recent'];
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
const listing = (items, html) => {
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}</p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
<div class="agrid" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
const pl2 = $('#plib');
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
$('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
};
const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); }
if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio');
return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); }
if (kind === 'artist') {
const mc = await px.get(`/library/metadata/${key}/children`);
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
return;
}
if (kind === 'album' || kind === 'playlist') {
const album = kind === 'album';
const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`),
px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]);
const info = (meta.Metadata || [])[0] || {};
const tracks = (mc.Metadata || []).map(t => { const m = mapTrack(t, mc); if (m && !album) m.pid = t.playlistItemID; return m; }).filter(Boolean);
const mine = !album && !info.smart;
const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks';
const thumb = info.thumb || info.composite || mc.thumb;
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
if (mine) {
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
}
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
markPlaying();
return;
}
location.hash = '#/music/albums';
}
+114
View File
@@ -0,0 +1,114 @@
// Command palette (Ctrl/Cmd-K) and quick capture. Uses the app's globals (allowed, TABS, icon, jfetch, openSheet, store, toast ...).
/** "buy milk tomorrow" -> { title: 'buy milk', due: '2026-10-23' }. Understands today, tomorrow, a weekday name, "in N days" and YYYY-MM-DD at the end. */
function parseQuickTask(text) {
let t = String(text || '').trim(), due = '';
const d0 = new Date(); d0.setHours(12, 0, 0, 0);
const fmt = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
let m;
if ((m = /\s+(\d{4}-\d{2}-\d{2})$/.exec(t))) { due = m[1]; t = t.slice(0, m.index); }
else if ((m = /\s+in (\d{1,3}) days?$/i.exec(t))) { const d = new Date(d0); d.setDate(d.getDate() + +m[1]); due = fmt(d); t = t.slice(0, m.index); }
else if ((m = /\s+(today|tomorrow|tonight)$/i.exec(t))) { const d = new Date(d0); if (/tomorrow/i.test(m[1])) d.setDate(d.getDate() + 1); due = fmt(d); t = t.slice(0, m.index); }
else if ((m = /\s+(?:on |next )?(sun|mon|tue|wed|thu|fri|sat)[a-z]*$/i.exec(t))) {
const d = new Date(d0), want = days.indexOf(m[1].toLowerCase()); let add = (want - d.getDay() + 7) % 7; if (add === 0) add = 7; d.setDate(d.getDate() + add); due = fmt(d); t = t.slice(0, m.index);
}
return { title: t.trim() || String(text || '').trim(), due };
}
async function quickTask(text) {
const { title, due } = parseQuickTask(text);
if (!title) return;
await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: 'local:inbox', fields: { title, notes: '', due, done: false } }) });
toast('Task added' + (due ? ' (due ' + due + ')' : ''));
if (location.hash.startsWith('#/tasks') && typeof viewTasks === 'function') viewTasks();
}
/** Small sheet to capture something fast from any screen. */
function quickAdd() {
const can = t => allowed(t);
const el = openSheet('Quick add', '', `${can('tasks') ? `<form id="qf"><input type="text" id="qt" placeholder="Task, e.g. Call mom tomorrow" autocomplete="off" aria-label="New task" style="margin-top:6px">
<p class="sub" style="margin:6px 0 0">Understands today, tomorrow, a weekday, "in 3 days" or a date (2026-12-25) at the end.</p>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Add task</button></div></form>` : ''}
<div class="bar">${can('calendar') ? '<button class="btn ghost" id="qe" style="flex:1;justify-content:center">New event</button>' : ''}${can('links') ? '<button class="btn ghost" id="ql" style="flex:1;justify-content:center">New link</button>' : ''}</div><div class="err" id="qerr"></div>`);
const qt = el.querySelector('#qt'); if (qt) qt.focus();
if (el.querySelector('#qf')) el.querySelector('#qf').onsubmit = async e => { e.preventDefault(); try { await quickTask(qt.value); closeSheet(); } catch (err) { el.querySelector('#qerr').textContent = err.message; } };
if (el.querySelector('#qe')) el.querySelector('#qe').onclick = () => { closeSheet(); newEvent(ymd(new Date())); };
if (el.querySelector('#ql')) el.querySelector('#ql').onclick = () => { closeSheet(); linkSheet(); };
}
// ---- Command palette
let palEl = null;
function openPalette() {
if (palEl) return closePalette();
const lastFocus = document.activeElement;
palEl = document.createElement('div'); palEl.id = 'pal';
palEl.innerHTML = `<div class="scrim"></div><div class="palbox" role="dialog" aria-modal="true" aria-label="Command palette">
<input type="text" id="palq" role="combobox" aria-expanded="true" aria-controls="pall" aria-autocomplete="list" placeholder="Type a command, a page, a task or a link" autocomplete="off" autocapitalize="off" spellcheck="false">
<div id="pall" role="listbox" aria-label="Results"></div></div>`;
document.body.appendChild(palEl);
const input = palEl.querySelector('#palq'), list = palEl.querySelector('#pall');
let items = [], sel = 0, seq = 0, timer, live = [];
const go = h => () => { location.hash = h; };
const base = () => {
const out = TABS.filter(t => allowed(t[0])).map(([id, label]) => ({ label: 'Go to ' + label, hint: 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' });
if (allowed('links')) out.push({ label: 'New link', hint: 'Create', ic: 'plus', run: () => linkSheet(), kw: 'add bookmark' });
out.push({ label: 'Hide or show the menu', hint: 'View', ic: 'menu', run: () => setNav({ hidden: !navPref().hidden }), kw: 'sidebar' });
out.push({ label: 'Refresh the app', hint: 'App', ic: 'refresh', run: () => location.reload(), kw: 'reload update' });
return out;
};
const build = () => {
const q = input.value.trim(), ql = q.toLowerCase();
let all = base();
if (typeof linkList === 'function' && allowed('links')) all = all.concat(linkList().map(l => ({ label: l.title || l.url, hint: 'Link · ' + new URL(l.url).host, ic: 'link', run: () => window.open(l.url, '_blank', 'noopener'), kw: (l.group || '') + ' ' + l.url })));
let res = ql ? all.filter(i => (i.label + ' ' + i.kw).toLowerCase().includes(ql)) : all.slice(0, 12);
if (ql) {
res = res.sort((a, b) => (b.label.toLowerCase().startsWith(ql)) - (a.label.toLowerCase().startsWith(ql)));
res = res.concat(live);
if (allowed('tasks')) res.push({ label: 'Add task: ' + q, hint: 'Create', ic: 'plus', run: () => quickTask(q).catch(e => toast(e.message)) });
if (allowed('search')) res.push({ label: 'Search everything for “' + q + '”', hint: 'Search', ic: 'search', run: () => { window.pendingSearch = q; if (location.hash === '#/search') route(); else location.hash = '#/search'; } });
}
items = res; sel = Math.min(sel, Math.max(0, items.length - 1));
list.innerHTML = items.length ? items.map((i, n) => `<div class="palrow${n === sel ? ' on' : ''}" role="option" id="po${n}" aria-selected="${n === sel}" data-n="${n}">${icon(i.ic)}<span class="grow">${esc(i.label)}</span><small>${esc(i.hint)}</small></div>`).join('') : '<p class="empty">Nothing matches.</p>';
input.setAttribute('aria-activedescendant', items.length ? 'po' + sel : '');
const on = list.querySelector('.on'); if (on) on.scrollIntoView({ block: 'nearest' });
};
const fetchLive = q => { // tasks and events from the server, shown under the commands
clearTimeout(timer); live = [];
if (q.length < 2 || !(allowed('tasks') || allowed('calendar'))) return;
const mine = ++seq;
timer = setTimeout(async () => {
try {
const r = await jfetch('/api/search?q=' + encodeURIComponent(q));
if (mine !== seq || !palEl) return;
live = (r.tasks || []).slice(0, 4).map(t => ({ label: t.title, hint: 'Task' + (t.due ? ' · due ' + t.due : '') + (t.done ? ' · done' : ''), ic: 'check', run: go('#/tasks') }))
.concat((r.events || []).slice(0, 4).map(e => ({ label: e.title, hint: 'Event · ' + (e.allDay ? e.start : new Date(e.start).toLocaleDateString([], { month: 'short', day: 'numeric' })), ic: 'cal', run: go('#/calendar') })));
build();
} catch {}
}, 250);
};
const run = n => { const it = items[n]; if (!it) return; closePalette(); setTimeout(it.run, 0); };
input.oninput = () => { sel = 0; fetchLive(input.value.trim()); build(); };
input.onkeydown = e => {
if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % Math.max(1, items.length); build(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % Math.max(1, items.length); build(); }
else if (e.key === 'Enter') { e.preventDefault(); run(sel); }
else if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
else if (e.key === 'Tab') e.preventDefault(); // keep focus in the box
};
list.onclick = e => { const r = e.target.closest('.palrow'); if (r) run(+r.dataset.n); };
palEl.querySelector('.scrim').onclick = closePalette;
palEl._restore = lastFocus;
build(); input.focus();
requestAnimationFrame(() => palEl && palEl.classList.add('on'));
}
function closePalette() { if (!palEl) return; const r = palEl._restore; palEl.remove(); palEl = null; try { r && r.focus && r.focus(); } catch {} }
addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
if (document.body.classList.contains('editing') || !document.getElementById('nav')) return; // not on the sign-in page or inside the document editor
e.preventDefault(); openPalette();
}
});
+281
View File
@@ -0,0 +1,281 @@
'use strict';
// ---- Player: one <audio> element that lives outside the views, so music keeps playing as you move around.
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() };
const cur = () => pl.q[pl.i];
function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
// Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order.
function setShuffle(on) {
if (on === pl.shuffle || !pl.q.length) return;
const t = cur();
if (on) { pl.orig = [...pl.q]; pl.q = [...pl.q.slice(0, pl.i + 1), ...shuffled(pl.q.slice(pl.i + 1))]; }
else if (pl.orig) { const have = new Set(pl.q.map(x => x.key)), keep = pl.orig.filter(x => have.has(x.key)), known = new Set(keep.map(x => x.key));
pl.q = [...keep, ...pl.q.filter(x => !known.has(x.key))]; pl.i = Math.max(0, pl.q.findIndex(x => x.key === t.key)); pl.orig = null; }
pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow(); modeUI();
}
function npQueueCount() { const q = $('#cnq'); if (q) q.textContent = 'Queue · ' + Math.max(0, pl.q.length - pl.i - 1) + ' up next'; }
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); modeUI(); }
const srcOf = t => (pl.alt.has(t.key) ? altSrc(t) : t.src);
// Some files a browser cannot decode directly; Plex can convert those to MP3 on the fly.
const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSearchParams({ path: '/library/metadata/' + t.key, mediaIndex: 0, partIndex: 0, protocol: 'http', directPlay: 0, directStream: 0, audioCodec: 'mp3', maxAudioBitrate: 320 });
// Track cache: the current track and the next two are downloaded whole (one at a time, after the current one has buffered),
// so changing songs is instant and a slow or lost connection does not stop the music.
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const tc = { m: new Map(), ctl: null, busy: false };
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src);
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
}
async function tcFetch(src, signal) {
for (let n = 0; n < 4 && !signal.aborted; n++) {
try {
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
const b = await r.blob(); // throws if the connection drops part way
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
return;
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
}
}
async function tcPump() {
if (tc.busy) return; tc.busy = true;
try {
for (;;) {
tcTrim();
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
tc.ctl = new AbortController(); tc.cur = src;
await tcFetch(src, tc.ctl.signal);
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
}
} finally { tc.busy = false; tc.ctl = null; }
}
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
let loadTok = 0, loadTimer = null, stallTimer = null;
pl.retries = 0; pl.buf = false;
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
function loadCur(delay = 220) {
const t = cur(); if (!t) return;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
const tok = ++loadTok; pl.buf = true;
if (delay) pl.a.pause();
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
if (lyr.open) { lyrShell(); lyrReload(); }
const go = () => {
if (tok !== loadTok) return;
const hit = tc.m.get(t.src);
pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } });
tcKick();
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
};
if (delay) loadTimer = setTimeout(go, delay); else go();
}
function setMeta(t) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
}
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
function recover(now) {
clearTimeout(stallTimer); const t = cur(), a = pl.a;
if (!t || a.paused) return;
stallTimer = setTimeout(() => {
if (a.paused || (a.readyState >= 3 && !a.error)) return;
const hit = tc.m.get(t.src), pos = a.currentTime;
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
if (bad && !hit) {
if (!pl.alt.has(t.key)) { pl.alt.add(t.key); pl.retries = 0; }
else { toast('Cannot play "' + t.title + '". Skipping.'); return advance(true); }
}
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
if (!hit && !navigator.onLine) return; // 'online' will call us again
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
if (!hit || a.src !== hit.url) pl.retries++;
a.src = want;
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
recover();
}, now ? 300 : 4000);
}
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); };
const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); npQueueCount(); toast('Added to queue'); };
function trackSheet(t, extra) {
actionSheet(t.title, t.artist, [
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
{ label: 'Add to queue', icon: 'files', run: () => queueTrack(t) },
{ label: 'Play now', icon: 'play', run: () => playQueue([t], 0) },
{ label: 'Add to playlist…', icon: 'plus', run: () => addToPlaylist([t]) },
...(extra || []),
]);
}
// Restore the last queue and position on launch (paused, so nothing starts by itself).
function restorePlayer() {
const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return;
const m = store.get('plex_mode', {}); pl.shuffle = !!m.shuffle; pl.repeat = m.repeat || 'off';
pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
renderMini(); syncUI();
}
function openQueue() {
const upcoming = pl.q.map((t, i) => ({ t, i }));
const el = openSheet('Up next', pl.q.length + ' tracks', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="qsp" style="flex:1;justify-content:center">Save as playlist</button></div>
<div class="bar" style="margin-top:0"><button class="btn ghost" id="qsl" style="flex:1;justify-content:center">${esc(sleepLabel())} timer</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#qsp').onclick = () => { const q = pl.q.slice(); closeSheet(); addToPlaylist(q, 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' })); };
el.querySelector('#qsl').onclick = () => { closeSheet(); sleepSheet(); };
el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); };
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
}
function advance(auto) {
if (auto && sleepT.eot) { sleepClear(); pl.a.pause(); syncUI(); toast('Sleep timer: music stopped'); return; }
if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; }
if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; }
loadCur(auto ? 0 : 220);
}
const next = () => advance(false);
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
pl.a.preload = 'auto';
// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
const tlSend = (state, key, pos, dur) => fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
function tlReport(state) {
const t = cur(); if (!t) return;
if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
tlKey = t.key; tlAt = Date.now(); tlPos = (pl.a.currentTime || 0) * 1000; tlDur = isFinite(pl.a.duration) ? pl.a.duration * 1000 : 0;
tlSend(state, tlKey, tlPos, tlDur);
}
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
pl.a.addEventListener('ended', () => advance(true));
let lastSave = 0, lastPos = 0;
pl.a.addEventListener('timeupdate', () => {
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
const t = cur(), d = pl.a.duration;
if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} }
});
addEventListener('pagehide', saveState);
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('canplaythrough', () => tcKick());
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
}
function markPlaying() {
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
}
function renderMini() {
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
el.hidden = false;
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
// tap or drag along the thin bar to seek
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
// swipe the track area: left for next, right for previous
const mini = el.querySelector('.mini'); let x0 = null, y0 = 0;
mini.onpointerdown = e => { if (e.target.closest('button:not(.meta), #mseek')) return; x0 = e.clientX; y0 = e.clientY; miniSwiped = false; };
mini.onpointerup = e => { if (x0 === null) return; const dx = e.clientX - x0, dy = e.clientY - y0; x0 = null;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 2) { miniSwiped = true; setTimeout(() => { miniSwiped = false; }, 300); dx < 0 ? next() : prev(); } };
mini.onpointercancel = () => { x0 = null; };
modeUI(); syncUI();
}
let miniSwiped = false;
function stopPlayer() {
tlStop();
pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; }
tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose();
}
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
function modeUI() {
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
}
function syncUI() {
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
// the Now Playing card on the Music tab
const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
}
// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
function renderNpCard() {
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
<button class="btn ghost npc-l" id="cnl">Lyrics</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
const sk = el.querySelector('#cnk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen;
el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
modeUI(); syncUI();
}
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
function updateNow() {
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
modeUI();
}
function openNow() {
const t = cur(); if (!t) return;
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">Lyrics</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
const sk = el.querySelector('#npk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); };
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
updateNow(); syncUI();
}
+44
View File
@@ -0,0 +1,44 @@
// Settings -> Reminders: turn on push notifications for tasks due today and events about to start (Web Push).
const remSupported = () => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); };
const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
function remindersHtml() {
return `<div id="sec-rem"><h2>Reminders</h2><p class="sub">Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.</p>
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
}
async function remindersInit() {
const box = document.getElementById('remlist'); if (!box) return;
if (!(allowed('tasks') || allowed('calendar'))) return document.getElementById('sec-rem').remove();
if (!remSupported()) { box.innerHTML = `<div class="row"><span class="grow"><b>Not available here</b><small style="white-space:normal">${/iPhone|iPad/.test(navigator.userAgent) ? 'On iPhone and iPad, add NE Hub to the Home Screen first (Share → Add to Home Screen), then open it from there.' : 'This browser does not support push notifications.'}</small></span></div>`; return; }
let info; try { info = await jfetch('/api/push/key'); } catch (e) { box.innerHTML = `<div class="row"><span class="grow"><small>${esc(e.message)}</small></span></div>`; return; }
const reg = await navigator.serviceWorker.ready, sub = await reg.pushManager.getSubscription();
const on = !!sub && Notification.permission === 'granted';
const post = (p, body) => jfetch('/api/push/' + p, { method: 'POST', body: JSON.stringify({ tz: myTz(), ...body }) });
box.innerHTML = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label>
${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto"></div>` : ''}
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>`;
const on1 = document.getElementById('remon');
on1.onchange = async () => {
try {
if (on1.checked) {
const perm = Notification.permission === 'default' ? await Notification.requestPermission() : Notification.permission;
if (perm !== 'granted') { on1.checked = false; return toast('Notifications were not allowed'); }
const s = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(info.publicKey) });
await post('subscribe', { subscription: s.toJSON() }); toast('Reminders are on for this device');
} else {
const s = await reg.pushManager.getSubscription();
if (s) { await post('unsubscribe', { endpoint: s.endpoint }); await s.unsubscribe(); }
toast('Reminders are off for this device');
}
remindersInit();
} catch (e) { on1.checked = !on1.checked; toast(e.message); }
};
const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); };
pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
document.getElementById('remtest').onclick = () => post('test', {}).then(() => toast('Test sent')).catch(e => toast(e.message));
}
+108
View File
@@ -0,0 +1,108 @@
'use strict';
// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
// Seafile: use the search index when the server has one; if it is missing OR comes back empty, scan file names library by library.
async function searchFiles(term, libs) {
const enc = encodeURIComponent, lower = term.toLowerCase();
let results = [], via = 'index';
try { results = (await sf.req(`/api2/search/?q=${enc(term)}&per_page=20`)).results || []; } catch {}
if (!results.length) { results = await walkSearch(libs, lower); via = 'scan'; }
return { results, via, scanned: libs.filter(l => !l.encrypted).length };
}
// Plex: the server-wide search first; if that finds nothing, filter the artists and albums of the chosen music library directly.
async function searchMusic(term) {
const enc = encodeURIComponent, lower = term.toLowerCase(), items = [];
try {
const mc = await px.get(`/hubs/search?query=${enc(term)}&limit=10`);
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['artist', 'album', 'track'].includes(m.type)) items.push(m);
} catch {}
if (!items.length) {
const sec = await px.section(), big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
const [artists, albums] = await Promise.all([px.get(`/library/sections/${sec}/all?type=8${big}`), px.get(`/library/sections/${sec}/all?type=9${big}`)]);
for (const a of artists.Metadata || []) if ((a.title || '').toLowerCase().includes(lower)) items.push({ ...a, type: 'artist' });
for (const a of albums.Metadata || []) if (((a.title || '') + ' ' + (a.parentTitle || '')).toLowerCase().includes(lower)) items.push({ ...a, type: 'album' });
}
return items.slice(0, 16);
}
function viewSearch() {
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music…" autocomplete="off" autocapitalize="off">
<p class="sub" id="src" style="margin:8px 0 0"></p>
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
<div id="res"></div></div>`;
const q = $('#q'); q.focus();
let timer, seq = 0, plexSt = null;
if (window.pendingSearch) { q.value = window.pendingSearch; window.pendingSearch = ''; setTimeout(() => q.oninput && q.oninput(), 0); }
// One line that says what Search covers and what each source returned, with a link to sign in where a source needs it.
const showSources = (r = {}) => {
const el = $('#src'); if (!el) return;
const link = (href, t) => `<a href="${href}" style="color:var(--accent)">${t}</a>`, bad = t => `<span style="color:var(--danger)">${esc(t)}</span>`;
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`;
};
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
showSources();
// Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab.
const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); };
q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
async function runSearch(term, mySeq) {
const res = $('#res');
if (term.length < 2) { res.innerHTML = ''; showSources(); return; }
res.innerHTML = '<p class="empty">Searching…</p>';
const enc = encodeURIComponent, lower = term.toLowerCase();
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
const [libs, files, music, ddg, hub] = await Promise.allSettled([
libsP,
libsP.then(l => searchFiles(term, l)),
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
jfetch('/api/ddg?q=' + enc(term)),
jfetch('/api/search?q=' + enc(term)),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
`<a class="row" href="#/files/${enc(r.id)}"><span class="ic" style="background:#2f9e6e">${icon('files')}</span><span class="grow"><b>${esc(r.name)}</b><small>Library</small></span></a>`).join('') : '';
const found = files.status === 'fulfilled' ? files.value.results : [];
const fileHtml = found.map(f => {
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
return f.is_dir ? `<a class="row" href="#/files/${enc(f.repo_id)}${f.fullpath.split('/').filter(Boolean).map(x => '/' + enc(x)).join('')}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(f.fullpath)}</small></span></a>`
: `<button class="row" data-f="${esc(JSON.stringify([f.repo_id, f.fullpath, f.name, parent]))}"><span class="ic" style="background:#6a7280">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(parent)}</small></span></button>`;
}).join('');
let musicHtml = '';
for (const m of music.status === 'fulfilled' ? music.value : []) {
if (m.type === 'artist') musicHtml += `<a class="row" href="#/music/artist/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:50%" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Artist</small></span></a>`;
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
}
const d = ddg.status === 'fulfilled' ? ddg.value : {};
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
<div style="min-width:0"><small style="color:var(--muted)">From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}</small>
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
evStore.length = 0;
const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
return `<button class="row" data-tl="${esc(t.list)}"><span class="ic" style="background:#4f7cff">${icon('check')}</span><span class="grow"><b${t.done ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(t.title)}</b><small>${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''}</small></span></button>`; }).join('');
const eventHtml = (h.events || []).map(e => { const i = evStore.push(e) - 1, dt = e.allDay ? new Date(e.start + 'T00:00:00') : new Date(e.start);
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b><small>${esc(dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }))}${e.allDay ? ' · all day' : ' · ' + esc(fmtTime(e.start))} · ${esc(e.cal)}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; }).join('');
// what each source did, so an empty screen always has an explanation
showSources({
tasks: hub.status === 'fulfilled' ? { n: (h.tasks || []).length } : { err: hub.reason && hub.reason.message },
events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message },
files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
});
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
const linkHtml = allowed('links') ? linkList().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes;
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
}
}
+175
View File
@@ -0,0 +1,175 @@
'use strict';
// ---- People (admin): who can sign in, which tabs each may use, one-time invite codes
let peopleTabs = [];
async function loadPeople() {
const box = $('#people'); if (!box) return;
try {
const r = await jfetch('/api/users'); peopleTabs = r.tabs;
if (!$('#people')) return;
$('#people').innerHTML = r.users.map(u => `<button class="row" data-p="${esc(u.id)}"><span class="ic" style="background:${u.role === 'admin' ? '#4f7cff' : '#6a7280'}">${esc(u.name.slice(0, 1).toUpperCase())}</span>
<span class="grow"><b>${esc(u.name)}${u.id === ME.id ? ' (you)' : ''}${u.role === 'admin' ? ' · Admin' : ''}${u.disabled ? ' · Off' : ''}</b>
<small style="white-space:normal">${u.passkeys ? u.passkeys + ' passkey' + (u.passkeys === 1 ? '' : 's') : 'No passkey yet (waiting for the invite code)'} · ${u.role === 'admin' ? 'Everything' : u.tabsChosen.map(t => (peopleTabs.find(x => x.id === t) || {}).label || t).join(', ') || 'Home and Settings only'}</small></span>${icon('chev', 'chev')}</button>`).join('');
$('#people').querySelectorAll('button[data-p]').forEach(b => b.onclick = () => personSheet(r.users.find(u => u.id === b.dataset.p)));
} catch (e) { box.innerHTML = `<div class="row"><small class="err">${esc(e.message)}</small></div>`; }
}
const tabBoxes = (chosen, disabled) => `<div class="list" style="background:var(--panel2);border:0">${peopleTabs.map(t => `<label class="row" style="cursor:pointer"><span class="grow"><b>${esc(t.label)}</b></span>
<input type="checkbox" data-tab="${esc(t.id)}" ${chosen.includes(t.id) ? 'checked' : ''} ${disabled ? 'disabled' : ''}></label>`).join('')}</div>`;
const pickedTabs = el => [...el.querySelectorAll('input[data-tab]:checked')].map(i => i.dataset.tab);
function codeSheet(name, code, minutes) {
const el = openSheet('Invite code for ' + name, '', `<p class="sub">On ${esc(name)}'s device, open <b>${esc(location.origin)}</b>, tap <b>New device? Use an invite code</b> on the sign-in page, and enter:</p>
<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:14px 0">${esc(code)}</p>
<p class="sub" style="text-align:center">It works once and expires in ${minutes} minutes. They will then create their own passkey.</p>
<div class="bar"><button class="btn ghost" id="cc" style="flex:1;justify-content:center">${icon('copy')}Copy code</button><button class="btn" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = () => { closeSheet(); loadPeople(); };
el.querySelector('#cc').onclick = async () => { try { await navigator.clipboard.writeText(code); toast('Code copied'); } catch { toast('Could not copy'); } };
}
async function addPersonSheet() {
if (!peopleTabs.length) { try { peopleTabs = (await jfetch('/api/users')).tabs; } catch (e) { return toast(e.message); } }
const el = openSheet('Add a person', '', `<form id="pf"><label for="pn">Name</label><input type="text" id="pn" autocomplete="off" required placeholder="Sam">
<label>What can they use?</label>${tabBoxes(['tasks', 'calendar', 'search'])}
<p class="sub" style="margin:8px 0 0">They always get Home and Settings, and their own private tasks, events and settings. You can change this later.</p>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="pe"></div></form>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#pf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try { const r = await jfetch('/api/users', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#pn').value, tabs: pickedTabs(el) }) }); codeSheet(r.user.name, r.code, r.minutes); }
catch (err) { btn.disabled = false; el.querySelector('#pe').textContent = err.message; }
};
}
function personSheet(u) {
const self = u.id === ME.id, admin = u.role === 'admin';
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required>
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ue"></div></form>
<div class="list" style="background:var(--panel2);border:0;margin-top:6px">
<button class="row" id="u-inv">${icon('copy')}<span class="grow"><b>Invite code for a new device</b><small>Also the way back in if they lose their device</small></span></button>
<button class="row" id="u-out">${icon('back')}<span class="grow"><b>Sign out everywhere</b></span></button>
${self ? '' : `<button class="row" id="u-off">${icon('more')}<span class="grow"><b>${u.disabled ? 'Turn back on' : 'Turn off'}</b><small>${u.disabled ? 'Let them sign in again' : 'Signs them out and blocks sign-in; their data is kept'}</small></span></button>`}
<button class="row" id="u-role">${icon('check')}<span class="grow"><b>${admin ? 'Remove admin' : 'Make admin'}</b></span></button>
${self ? '' : `<button class="row dng" id="u-del">${icon('trash')}<span class="grow"><b>Delete person and their data</b></span></button>`}</div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } };
const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) });
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); };
el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
el.querySelector('#u-role').onclick = () => act(() => put({ role: admin ? 'user' : 'admin' }), admin ? 'No longer an admin' : 'Now an admin');
const del = el.querySelector('#u-del'); if (del) del.onclick = () => confirmSheet('Delete ' + u.name + '?', 'Their tasks, events, settings and sign-ins are permanently deleted.', 'Delete', () => act(() => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE' }), 'Deleted'));
}
function viewSettings() {
const signed = !!sf.token();
const isAdmin = ME && ME.role === 'admin';
const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
<div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
</div><div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
remindersInit();
// only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!(allowed('mail') && cfg.mail), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
if ($('#sga-s')) {
const a = sgAlerts(), ns = $('#sga-ns'); $('#sga-s').checked = a.sound; $('#sga-n').checked = a.notify && 'Notification' in window && Notification.permission === 'granted';
if (!('Notification' in window)) { $('#sga-n').disabled = true; ns.textContent = 'This browser does not support notifications'; }
else if (Notification.permission === 'denied') { $('#sga-n').disabled = true; ns.textContent = 'Blocked in the browser settings for this site; allow notifications there first'; }
$('#sga-s').onchange = e => { sgSetAlerts({ sound: e.target.checked }); if (e.target.checked) sgChime(); };
$('#sga-n').onchange = async e => {
if (!e.target.checked) return sgSetAlerts({ notify: false });
let p = Notification.permission; if (p === 'default') p = await Notification.requestPermission();
if (p === 'granted') { sgSetAlerts({ notify: true }); try { new Notification('Signal alerts are on', { body: 'You will see new messages here when this tab is in the background.', icon: '/icons/icon-192.png' }); } catch {} }
else { e.target.checked = false; sgSetAlerts({ notify: false }); toast('Notifications are blocked. Allow them for this site in the browser settings.'); }
};
$('#sga-t').onclick = () => { sgUnlockAudio(); sgChime(); if (!sgAC || sgAC.state !== 'running') toast('Click anywhere on the page once, then try again (the browser allows sound only after that)'); };
}
if ($('#mailst')) mailSettings();
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
if (signed) sf.req('/api2/account/info/').then(a => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · Signed in' + (a && (a.email || a.name) ? ' as ' + (a.email || a.name) : ''); })
.catch(e => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · ' + (sf.token() ? 'Signed in (could not check: ' + e.message + ')' : 'Signed out'); });
if (allowed('music')) jfetch('/api/plex/status').then(st => {
const el = $('#plexst'); if (!el) return;
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
if (isAdmin) jfetch('/api/system').then(i => {
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
const where = !i.source ? '' : i.source.kind === 'folder' ? ' · host folder ' + esc(i.source.name) : i.source.kind === 'named' ? ' · volume "' + esc(i.source.name) + '"' : '';
$('#stor').innerHTML =
row(ok ? 'Data is saved on a volume' : 'Data is NOT being kept safely', anon ? 'This is a temporary Docker volume (' + esc(i.source.name) + '…), NOT your host folder. It is deleted when the container is recreated, and your folder stays empty. Check that your stack mounts the host folder at ' + esc(i.dir) + '.'
: i.mounted === false ? 'The data folder is not a mounted volume, so passkeys, calendars and sign-ins are lost when the container is recreated. Mount a volume or a host folder at ' + esc(i.dir) + '.'
: !i.writable ? 'The data folder is not writable by the app.' : esc(i.dir) + ' (mounted' + where + ')', !ok)
+ row('What is stored', `${i.passkeys} passkey${i.passkeys === 1 ? '' : 's'} · ${i.events} NE Hub event${i.events === 1 ? '' : 's'} · ${i.calendarLinks} calendar link${i.calendarLinks === 1 ? '' : 's'} · ${i.settings || 0} saved setting${i.settings === 1 ? '' : 's'}${i.icloud ? ' · iCloud connected' : ''}`)
+ row('Session secret', i.secretFromEnv ? 'Set in the stack (sign-ins survive restarts)' : 'Generated and kept in the data folder', false)
+ row('Running since', new Date(i.startedAt).toLocaleString(), false);
}).catch(() => { const e = $('#stor'); if (e) e.innerHTML = '<div class="row"><small>Could not read storage status</small></div>'; });
const loadPks = async () => {
try {
const pks = await (await fetch('/api/auth/passkeys')).json();
$('#pks').innerHTML = pks.map(k => `<div class="row"><span class="grow"><b>${esc(k.name)}</b><small>Added ${new Date(k.created).toLocaleDateString()}${k.lastUsed ? ' · used ' + new Date(k.lastUsed).toLocaleDateString() : ''}</small></span>
<button class="btn danger" data-id="${esc(k.id)}" style="min-height:36px">Remove</button></div>`).join('') || '<div class="row"><small>No passkeys</small></div>';
$('#pks').querySelectorAll('button[data-id]').forEach(b => b.onclick = () => confirmSheet('Remove passkey?', pks.length === 1 ? 'This is your only passkey. You would need the server setup code to enrol a new one.' : 'It will no longer sign in to NE Hub.', 'Remove', async () => {
await authPost('/api/auth/passkeys', { id: b.dataset.id }, 'DELETE'); loadPks();
}));
} catch (e) { $('#pks').innerHTML = `<div class="row"><small class="err">${esc(e.message)}</small></div>`; }
};
loadPks();
$('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => {
try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); }
});
jfetch('/api/auth/recovery').then(r => { $('#rcn').textContent = r.remaining ? r.remaining + ' recovery code' + (r.remaining === 1 ? '' : 's') + ' left' : 'No recovery codes yet'; }).catch(() => { $('#rcn').textContent = 'Recovery codes'; });
$('#rcgen').onclick = () => confirmSheet('Make new recovery codes?', 'Any codes you made before stop working.', 'Make codes', async () => {
try {
const { codes } = await jfetch('/api/auth/recovery', { method: 'POST', body: '{}' });
const el = openSheet('Your recovery codes', 'Each works once. They are shown only now: save them before closing.', `<div class="list" style="background:var(--panel2);border:0">${codes.map(c => `<div class="row"><span class="grow"><b style="font-family:ui-monospace,Menlo,monospace;letter-spacing:.08em">${esc(c)}</b></span></div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="rcc" style="flex:1;justify-content:center">Copy</button><button class="btn" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = () => { closeSheet(); viewSettings(); };
el.querySelector('#rcc').onclick = async () => { try { await navigator.clipboard.writeText(codes.join('\n')); toast('Copied'); } catch { toast('Could not copy; write them down'); } };
} catch (e) { toast(e.message); }
});
if ($('#audit')) jfetch('/api/audit').then(({ events }) => {
const el = $('#audit'); if (!el) return;
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
$('#invpk').onclick = async () => {
try {
const { code, minutes } = await authPost('/api/auth/invite');
const el = openSheet('Invite a device', 'On the new device, open this app, tap "New device? Use an invite code", and enter:', `<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:16px 0">${esc(code)}</p>
<p class="sub" style="text-align:center">Works once, and expires in ${minutes} minutes.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
} catch (e) { toast(e.message); }
};
$('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
}
+44
View File
@@ -0,0 +1,44 @@
'use strict';
// ---- Bottom sheet (dialog on tablet/desktop). Body content is built by the caller from trusted/escaped HTML.
function openSheet(title, sub, bodyHtml) {
closeSheet();
const el = document.createElement('div');
el.id = 'sheet';
el.innerHTML = `<div class="scrim"></div><div class="sheet" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
document.body.appendChild(el);
el.querySelector('.scrim').onclick = closeSheet;
requestAnimationFrame(() => el.classList.add('on'));
return el;
}
function closeSheet() { document.getElementById('sheet')?.remove(); }
addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
function actionSheet(title, sub, actions) {
const el = openSheet(title, sub, `<div class="list">${actions.map((a, i) => `
<button class="row ${a.danger ? 'dng' : ''}" data-i="${i}">${icon(a.icon)}<span class="grow"><b>${esc(a.label)}</b></span></button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); actions[b.dataset.i].run(); });
}
function textSheet(title, label, okLabel, onOk, initial = '', validate) {
const el = openSheet(title, '', `<form><label>${esc(label)}</label><input type="text" id="tv" autocapitalize="off" autocomplete="off" required value="${esc(initial)}">
<div class="err" id="te" style="margin-top:6px"></div>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>
<button class="btn" style="flex:1;justify-content:center">${esc(okLabel)}</button></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = e => {
e.preventDefault(); const v = el.querySelector('#tv').value.trim(); if (!v) return;
const bad = validate && validate(v); if (bad) { el.querySelector('#te').textContent = bad; return; }
closeSheet(); onOk(v);
};
setTimeout(() => { const t = el.querySelector('#tv'); t.focus(); const d = initial.lastIndexOf('.'); t.setSelectionRange(0, d > 0 ? d : initial.length); }, 50);
}
function confirmSheet(title, sub, okLabel, onOk) {
const el = openSheet(title, sub, `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>
<button class="btn" id="so" style="flex:1;justify-content:center;background:var(--danger);color:#fff">${esc(okLabel)}</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#so').onclick = () => { closeSheet(); onOk(); };
}
+56
View File
@@ -0,0 +1,56 @@
// App-shell niceties that do not belong to any one tab: offline notice and an accessibility layer
// (names for icon-only buttons, a skip link, keyboard handling and focus for sheets).
(function () {
// ---- Offline notice
const bar = document.createElement('div');
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
bar.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.';
document.body.appendChild(bar);
const sync = () => { bar.hidden = navigator.onLine; };
addEventListener('online', sync); addEventListener('offline', sync); sync();
// ---- Skip link and landmarks
const skip = document.createElement('a');
skip.href = '#view'; skip.className = 'skip'; skip.textContent = 'Skip to content';
skip.addEventListener('click', e => { e.preventDefault(); const v = document.getElementById('view'); if (v) { v.setAttribute('tabindex', '-1'); v.focus(); } });
document.body.insertBefore(skip, document.body.firstChild);
const nav = document.getElementById('nav'); if (nav && !nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Main');
// ---- Give every button or link that has only an icon a name (from its title, tooltip, id or class)
const NAMES = { more: 'More', navcol: 'Collapse the menu', sx: 'Cancel', x: 'Close', close: 'Close', back: 'Back', fwd: 'Forward', menu: 'Menu', play: 'Play', pause: 'Pause', next: 'Next', prev: 'Previous', shuffle: 'Shuffle', repeat: 'Repeat', send: 'Send', clip: 'Attach a file', lyrics: 'Lyrics', full: 'Full screen', refresh: 'Refresh', ext: 'Open in a new tab' };
const named = el => (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.textContent.trim() || el.getAttribute('title') || '');
function label(root) {
root.querySelectorAll('button, a[href], [role="button"]').forEach(el => {
if (named(el)) { if (!el.getAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', el.getAttribute('title')); return; }
const key = (el.id || '').replace(/^(btn|b)-?/, '') || [...el.classList].find(c => NAMES[c]) || '';
const l = NAMES[key] || NAMES[el.id] || (el.querySelector('img[alt]') || {}).alt;
if (l) el.setAttribute('aria-label', l);
});
root.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]), select, textarea').forEach(el => {
if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || (el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`)) || el.closest('label')) return;
const l = el.getAttribute('placeholder') || el.getAttribute('title'); if (l) el.setAttribute('aria-label', l);
});
}
let pending = false;
new MutationObserver(() => { if (pending) return; pending = true; requestAnimationFrame(() => { pending = false; label(document.body); }); })
.observe(document.body, { childList: true, subtree: true });
label(document.body);
// ---- Sheets (bottom sheets and dialogs): Escape closes, focus moves in and returns, Tab stays inside
let lastFocus = null;
new MutationObserver(() => {
const s = document.getElementById('sheet');
if (s && !s._a11y) { s._a11y = true; lastFocus = document.activeElement; setTimeout(() => { if (!s.contains(document.activeElement)) { const f = s.querySelector('input:not([type=hidden]), textarea, select, button'); if (f) f.focus(); } }, 50); }
if (!s && lastFocus) { try { lastFocus.focus(); } catch {} lastFocus = null; }
}).observe(document.body, { childList: true });
addEventListener('keydown', e => {
const s = document.getElementById('sheet'); if (!s) return;
if (e.key === 'Escape' && typeof closeSheet === 'function') { e.preventDefault(); closeSheet(); return; }
if (e.key !== 'Tab') return;
const f = [...s.querySelectorAll('a[href], button:not([disabled]), input:not([type=hidden]):not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])')].filter(x => x.offsetParent !== null);
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
});
})();
+242
View File
@@ -0,0 +1,242 @@
'use strict';
// ---- Signal (through signal-cli-rest-api on the server; this hub is one more "linked device" of your Signal account)
const sgq = q => (sg.root || document).querySelector(q);
const sgApi = (p, o) => jfetch('/api/signal' + p, o);
const sgPost = (p, body) => sgApi(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateString() === n.toDateString() ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : (n - d < 6 * 864e5 ? d.toLocaleDateString([], { weekday: 'short' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' })); };
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
const sgOnView = () => location.hash.startsWith('#/signal');
const sgMounted = () => !!sg.root && document.contains(sg.root);
async function sgStart() { // keep the unread count and live updates running whatever screen is open
if (!allowed('signal') || sg.es) return;
try { const st = await sgApi('/status'); sg.st = st; sg.unread = st.unread || 0; renderNav(activeTab); } catch {}
sg.es = new EventSource('/api/signal/events');
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); };
}
function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
// ---- Getting your attention: a chime, a desktop notification when the app is in the background, the count in the tab title and on the app icon.
const sgAlerts = () => { try { return { sound: true, notify: false, ...JSON.parse(localStorage.getItem('sg_alerts')) }; } catch { return { sound: true, notify: false }; } };
const sgSetAlerts = p => { try { localStorage.setItem('sg_alerts', JSON.stringify({ ...sgAlerts(), ...p })); } catch {} };
let sgAC = null;
function sgUnlockAudio() { try { sgAC = sgAC || new (window.AudioContext || window.webkitAudioContext)(); if (sgAC.state === 'suspended') sgAC.resume(); } catch {} } // a browser only allows sound after you have used the page once
['pointerdown', 'keydown', 'touchstart'].forEach(e => addEventListener(e, sgUnlockAudio, { passive: true }));
function sgChime() {
sgUnlockAudio(); if (!sgAC || sgAC.state !== 'running') return;
const t = sgAC.currentTime;
[[880, 0], [1318, 0.14]].forEach(([f, d]) => { const o = sgAC.createOscillator(), g = sgAC.createGain(); o.type = 'sine'; o.frequency.value = f;
g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
}
function sgBadgeEverywhere() {
document.title = (sg.unread ? '(' + (sg.unread > 99 ? '99+' : sg.unread) + ') ' : '') + 'NE Hub';
try { if (navigator.setAppBadge) { sg.unread ? navigator.setAppBadge(sg.unread) : navigator.clearAppBadge(); } } catch {}
}
function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
const a = sgAlerts();
if (a.sound) sgChime();
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
}
}
let sgEvT = null;
function sgEvent(ev) {
if (typeof ev.unread === 'number' && ev.unread !== sg.unread) { sg.unread = ev.unread; renderNav(activeTab); }
if (ev.t === 'msg' && !ev.out) {
const looking = sgMounted() && sg.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
}
if (ev.t === 'gone') sgGone(ev.conv, true);
if (!sgMounted()) return;
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
}
// The Signal tab (full page, desktop) ...
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
// ... and the same screens in a panel that floats over every other screen.
async function sgMount(root, dock) {
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : '<h1>Signal</h1>';
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
if (sg.root !== root) return; sg.st = st;
if (!st.reachable) { root.innerHTML = `<div class="${cls}">${title}<div class="card" style="max-width:none"><p style="margin-top:0">The Signal service is not answering. Check that its container (<b>signal</b>) is running, then reload.</p></div></div>`; return; }
if (!st.linked) return sgLinkScreen(root, dock);
root.innerHTML = `<div class="${cls}"><div class="sg ${sg.cur ? 'open' : ''}"><div class="sgl"><div class="sghead">${dock ? '<b class="sgdt">Chats</b>' : '<h1>Signal</h1>'}<button class="btn ghost" id="sgnew">${icon('edit')}New</button></div>
<input type="search" id="sgq" placeholder="Search chats" autocomplete="off"><div class="list sglist" id="sglist"></div><p class="sub" style="margin-top:8px">${esc(st.number)}${st.connected ? '' : ' · reconnecting…'}</p>${st.gap ? `<p class="sub">The link to Signal was down at ${new Date(st.gap.from).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} for ${Math.max(1, Math.round((st.gap.to - st.gap.from) / 1000))} s. A message that arrived then may be missing here.</p>` : ''}</div>
<div class="sgm" id="sgm"></div></div></div>`;
sgq('#sgnew').onclick = sgNewChat; sgq('#sgq').oninput = sgDrawList;
await sgRefresh(true);
}
async function sgRefresh(first) {
try {
const r = await sgApi('/convs'); sg.convs = r.convs; sg.unread = r.unread; renderNav(activeTab);
if (!sgq('#sglist')) return; sgDrawList();
if (sg.cur) await sgDrawThread(first); else sgq('#sgm').innerHTML = '<p class="empty">Choose a conversation, or start a new one.</p>';
} catch (e) { const l = sgq('#sglist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
}
function sgOpen(id) { // in the floating panel a chat opens in place (the page behind does not change)
sg.cur = id; sgSaveDock(); const box = sgq('.sg'); if (box) box.classList.toggle('open', !!id);
sgq('#sgm').innerHTML = ''; sgDrawList(); if (id) sgDrawThread(true);
}
function sgDrawList() {
const q = (sgq('#sgq') || {}).value ? sgq('#sgq').value.toLowerCase() : '', box = sgq('#sglist'); if (!box) return;
const rows = sg.convs.filter(c => !q || (c.name || c.id).toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q));
box.innerHTML = rows.map(c => `<a class="row sgrow ${c.id === sg.cur ? 'on' : ''}" data-id="${esc(c.id)}" href="${sg.dock ? '#' : '#/signal/' + encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span>
<span class="grow"><b>${esc(c.name || c.id)}</b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`).join('') || `<p class="empty">${q ? 'Nothing matches' : 'No conversations yet. Tap New, or wait for a message.'}</p>`;
if (sg.dock) box.onclick = e => { const a = e.target.closest('[data-id]'); if (a) { e.preventDefault(); sgOpen(a.dataset.id); } };
box.oncontextmenu = e => { const a = e.target.closest('[data-id]'); if (a) { e.preventDefault(); sgChatMenu(a.dataset.id); } };
}
const sgLink = t => t.replace(/(https?:\/\/[^\s<]+[^\s<.,;:!?)\]'"])/g, u => `<a href="${u}" target="_blank" rel="noopener">${u}</a>`);
const sgSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
function sgBubble(m, c, prev) {
const nm = a => (sg.names[a] || (a === sg.st.number ? 'You' : a));
const att = (m.att || []).map(a => { const u = '/api/signal/attachment/' + encodeURIComponent(a.id);
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
: /^audio\//.test(a.type || '') ? `<audio controls preload="none" src="${u}"></audio>`
: /^video\//.test(a.type || '') ? `<video controls preload="metadata" playsinline src="${u}"></video>`
: `<a class="sgfile" href="${u}" target="_blank" rel="noopener">${icon('clip')}<span>${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''}</span></a>`; }).join('');
const reacts = {}; Object.values(m.react || {}).forEach(e => { reacts[e] = (reacts[e] || 0) + 1; });
const day = !prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString() ? `<div class="sgday">${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</div>` : '';
const tick = m.out ? (m.status === 'read' ? '✓✓' : m.status === 'delivered' ? '✓✓' : '✓') : '';
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-ts="${m.ts}">${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name || nm(m.from))}</small>` : ''}
${m.quote ? `<div class="sgq"><b>${esc(nm(m.quote.author))}</b><span>${esc(m.quote.text || '')}</span></div>` : ''}${att}
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${sgLink(esc(m.text)).replace(/\n/g, '<br>')}</div>` : ''}
<span class="sgmeta">${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ` <span class="${m.status === 'read' ? 'rd' : ''}">${tick}</span>` : ''}</span>
${Object.keys(reacts).length ? `<span class="sgre">${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}</span>` : ''}</div>`;
}
async function sgDrawThread(first) {
const box = sgq('#sgm'); if (!box) return;
const id = sg.cur; let r;
try { r = await sgApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
if (!box.querySelector('.sgbar')) {
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="#/signal" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
sgWireComposer(); const bk = sgq('#sgbk'); if (bk) bk.onclick = () => sgOpen(null); sgq('#sgmore').onclick = () => sgChatMenu(sg.cur);
}
sgq('#sgtitle').textContent = c.name || c.id; sgq('#sgsub').textContent = c.type === 'group' ? 'Group' : (c.name && c.name !== c.id ? c.id : '');
const list = r.msgs, el = sgq('#sgmsgs');
el.innerHTML = (r.more ? '<p class="sub" style="text-align:center">Older messages are not shown here</p>' : '') + list.map((m, i) => sgBubble(m, c, list[i - 1])).join('') || '<p class="empty">No messages yet. Say hello.</p>';
if (first || stick) el.scrollTop = el.scrollHeight;
el.onclick = e => { const b = e.target.closest('.sgb'); if (!b || e.target.closest('a, audio, video')) return; sgMessageMenu(list.find(m => String(m.ts) === b.dataset.ts), c); };
if (c.unread || (sg.convs.find(x => x.id === id) || {}).unread) sgPost('/read', { id }).then(() => { const x = sg.convs.find(q => q.id === id); if (x) x.unread = 0; sgDrawList(); }).catch(() => {});
}
function sgMessageMenu(m, c) {
if (!m || m.deleted) return;
const el = openSheet('', '', `<div class="sgemo">${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `<button class="ibtn" data-e="${e}" aria-label="React ${e}">${e}</button>`).join('')}</div>
<div class="list" style="background:var(--panel2);border:0;margin-top:8px"><button class="row" id="sgr">${icon('back')}<span class="grow"><b>Reply</b></span></button>${m.text ? `<button class="row" id="sgcp">${icon('copy')}<span class="grow"><b>Copy text</b></span></button>` : ''}</div>`);
el.querySelectorAll('[data-e]').forEach(b => b.onclick = async () => { closeSheet(); try { await sgPost('/react', { to: c.id, ts: m.ts, emoji: (m.react && m.react[sg.st.number]) === b.dataset.e ? '' : b.dataset.e }); } catch (e) { toast(e.message); } });
el.querySelector('#sgr').onclick = () => { closeSheet(); sg.reply = m; sgDrawReply(); sgq('#sgtext').focus(); };
const cp = el.querySelector('#sgcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
}
function sgDrawReply() {
const b = sgq('#sgrep'); if (!b) return;
if (!sg.reply) { b.hidden = true; return; }
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(sg.reply.out ? 'You' : (sg.reply.name || sg.reply.from))}</b><small>${esc(sg.reply.text || 'Attachment')}</small></span><button class="ibtn" id="sgrx" aria-label="Cancel reply">${icon('x')}</button>`;
sgq('#sgrx').onclick = () => { sg.reply = null; sgDrawReply(); };
}
function sgDrawTray() {
const t = sgq('#sgtray'); if (!t) return; t.hidden = !sg.tray.length;
t.innerHTML = sg.tray.map((f, i) => `<span class="sgchip">${/^image\//.test(f.type) ? `<img alt="" src="${URL.createObjectURL(f)}">` : icon('clip')}<small>${esc(f.name)}</small><button type="button" class="ibtn" data-x="${i}" aria-label="Remove">${icon('x')}</button></span>`).join('');
t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { sg.tray.splice(+b.dataset.x, 1); sgDrawTray(); });
}
const sgB64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); });
function sgWireComposer() {
const ta = sgq('#sgtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
ta.oninput = grow;
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); sgq('#sgform').requestSubmit(); } };
sgq('#sgat').onclick = () => sgq('#sgfile').click();
sgq('#sgfile').onchange = e => { sg.tray.push(...e.target.files); e.target.value = ''; sgDrawTray(); };
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { sg.tray.push(...fs); sgDrawTray(); } });
sgq('#sgform').onsubmit = async e => {
e.preventDefault(); const text = ta.value, files = [...sg.tray]; if (!text.trim() && !files.length) return;
const btn = sgq('#sgsend'); btn.disabled = true;
try {
const attachments = []; for (const f of files) attachments.push({ name: f.name, type: f.type || 'application/octet-stream', data: await sgB64(f) });
await sgPost('/send', { to: sg.cur, text, attachments, quote: sg.reply ? sg.reply.ts : undefined });
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); await sgRefresh(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) { toast(err.message); }
btn.disabled = false; ta.focus();
};
}
// ---- editing contacts and removing chats
function sgChatMenu(id) {
const c = sg.convs.find(x => x.id === id); if (!c) return;
const acts = [];
if (c.type === 'direct') acts.push({ label: 'Edit contact', icon: 'edit', run: () => sgEditContact(c) });
acts.push({ label: 'Clear messages', icon: 'trash', run: () => confirmSheet('Clear all messages?', (c.name || c.id) + ': removed from this app only. Your phone keeps them.', 'Clear', async () => { try { await sgPost('/conv/clear', { id }); await sgRefresh(); } catch (e) { toast(e.message); } }) });
acts.push({ label: 'Remove chat', icon: 'trash', danger: true, run: () => confirmSheet('Remove this chat?', 'It is removed from this app only. Your phone keeps it, and a new message from them starts it again here.', 'Remove', async () => { try { await sgPost('/conv/delete', { id }); sgGone(id); } catch (e) { toast(e.message); } }) });
actionSheet(c.name || c.id, c.type === 'group' ? 'Group' : c.id, acts);
}
function sgGone(id, fromServer) {
sg.convs = sg.convs.filter(x => x.id !== id);
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sgOnView()) location.hash = '#/signal'; }
if (!fromServer || sgMounted()) sgRefresh();
}
function sgEditContact(c, after) {
const el = openSheet('Edit contact', c.id || c.number, `<form id="sgcf"><label for="sgcn">Name</label><input type="text" id="sgcn" value="${esc(!c.name || c.name === (c.id || c.number) ? '' : c.name)}" placeholder="Name" autocomplete="off" maxlength="80">
<p class="sub" style="margin:8px 0 0">Shown in this app, and sent to Signal as the contact's name. Leave it empty to use the name Signal has.</p><div class="err" id="sgce"></div>
<div class="bar"><button class="btn ghost" type="button" id="sgcx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#sgcx').onclick = closeSheet; setTimeout(() => el.querySelector('#sgcn').focus(), 60);
el.querySelector('#sgcf').onsubmit = async e => { e.preventDefault();
try { await sgPost('/contact', { number: c.id || c.number, name: el.querySelector('#sgcn').value }); closeSheet(); toast('Saved'); if (after) after(); await sgRefresh(); }
catch (err) { el.querySelector('#sgce').textContent = err.message; } };
}
async function sgNewChat() {
let contacts = []; try { contacts = (await sgApi('/contacts')).contacts; } catch {}
const el = openSheet('New chat', '', `<form id="sgnf"><label for="sgnn">Phone number (with country code) or a contact</label><input type="text" id="sgnn" inputmode="tel" autocomplete="off" placeholder="+1 555 123 4567 or a name">
<input type="text" id="sgnm" autocomplete="off" placeholder="Name for a new number (optional)" style="margin-top:8px" maxlength="80"><div class="err" id="sgne"></div><div class="bar"><button class="btn" style="width:100%;justify-content:center">Start chat</button></div></form><div class="list" id="sgcl" style="background:var(--panel2);border:0;margin-top:10px;max-height:40dvh;overflow:auto"></div>`);
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
}
function sgLinkScreen(root, dock) {
const v = root, cls = dock ? 'sgdockin' : 'wrap';
const draw = () => { const img = sgq('#sgqr'); if (img) img.src = '/api/signal/qr?t=' + Date.now(); };
v.innerHTML = `<div class="${cls}">${dock ? '' : '<h1>Signal</h1>'}<p class="sub">Link this app to your Signal account. It becomes one more linked device, like Signal Desktop.</p>
<div class="card" style="max-width:none"><ol class="sgsteps"><li>On your phone open <b>Signal</b> → <b>Settings</b> → <b>Linked devices</b> → <b>Link new device</b>.</li><li>Scan this code.</li></ol>
<div style="text-align:center;margin:14px 0"><img id="sgqr" alt="Signal link code" width="256" height="256" style="background:#fff;border-radius:12px;padding:8px;max-width:100%"></div>
<div class="bar" style="justify-content:center"><button class="btn ghost" id="sgrq">${icon('refresh')}New code</button></div><p class="sub" id="sgls" style="text-align:center">Waiting for you to scan…</p></div>
<p class="sub" style="margin-top:12px">Messages from before you link are not copied over, only new ones. Conversations are kept on your server.</p></div>`;
draw(); sgq('#sgrq').onclick = draw;
sg.timers.push(setInterval(draw, 55000));
sg.timers.push(setInterval(async () => { try { const st = await sgApi('/status'); if (st.linked && sgMounted()) { toast('Signal linked'); sgMount(sg.root, sg.dock); } } catch {} }, 3000));
}
// ---- The floating chat: a button on every screen (desktop) that opens Signal over whatever you are doing.
const sgDockState = () => { try { return JSON.parse(localStorage.getItem('sg_dock')) || {}; } catch { return {}; } };
function sgSaveDock() { try { localStorage.setItem('sg_dock', JSON.stringify({ open: !!sg.dockOpen, cur: sg.cur })); } catch {} }
function sgDockInit() {
if ($('#sgfab') || !allowed('signal')) return;
const st = sgDockState(); sg.dockOpen = !!st.open; if (st.cur) sg.cur = st.cur;
const fab = document.createElement('button'); fab.id = 'sgfab'; fab.setAttribute('aria-label', 'Signal chat'); fab.title = 'Signal chat'; fab.hidden = true;
const dock = document.createElement('div'); dock.id = 'sgdock'; dock.hidden = true; dock.setAttribute('role', 'dialog'); dock.setAttribute('aria-label', 'Signal chat');
dock.innerHTML = `<div class="sgdtop"><b>Signal</b><span class="grow"></span><button class="ibtn" id="sgdfull" aria-label="Open the full Signal page" title="Open the full page">${icon('full')}</button><button class="ibtn" id="sgdx" aria-label="Close chat" title="Close">${icon('x')}</button></div><div class="sgdbody" id="sgdbody"></div>`;
document.body.append(dock, fab);
fab.onclick = () => (sg.dockOpen ? sgDockClose() : sgDockOpen());
dock.querySelector('#sgdx').onclick = sgDockClose;
dock.querySelector('#sgdfull').onclick = () => { const id = sg.cur; sgDockClose(); location.hash = '#/signal' + (id ? '/' + encodeURIComponent(id) : ''); };
const ro = new ResizeObserver(() => document.documentElement.style.setProperty('--ph', ($('#player') && !$('#player').hidden ? $('#player').offsetHeight : 0) + 'px')); ro.observe($('#player'));
}
function sgDockBadge() { const f = $('#sgfab'); if (f) f.innerHTML = icon('chat') + (sg.unread ? `<i class="nbadge">${sg.unread > 99 ? '99+' : sg.unread}</i>` : ''); }
function sgDockOpen() { const d = $('#sgdock'); if (!d) return; sg.dockOpen = true; d.hidden = false; sgSaveDock(); const body = $('#sgdbody'); if (sg.root !== body) sgMount(body, true); }
function sgDockClose() { const d = $('#sgdock'); if (d) d.hidden = true; sg.dockOpen = false; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; sgSaveDock(); }
function sgDockHide() { const d = $('#sgdock'), f = $('#sgfab'); if (d) d.hidden = true; if (f) f.hidden = true; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; }
function sgDockSync(name) { // after every screen change
if (!allowed('signal')) { sgDockHide(); return; }
sgDockInit(); const f = $('#sgfab'), d = $('#sgdock'); if (!f) return;
if (name === 'signal' || name === 'edit') { sgDockHide(); return; }
f.hidden = false; sgDockBadge();
if (sg.dockOpen) { d.hidden = false; if (sg.root !== $('#sgdbody')) sgMount($('#sgdbody'), true); } else d.hidden = true;
}
sgMQ.addEventListener('change', () => { // a window made narrower than a desktop (or back)
renderNav(activeTab);
if (!sgMQ.matches) { sgStop(); sgDockHide(); if (sgOnView()) location.hash = '#/home'; } else { sgStart(); sgDockSync(activeTab); }
});
+86 -15
View File
@@ -303,21 +303,6 @@ body:not(.editing) #view { padding-top: 8px; }
@media (min-width: 768px) { #navbar { padding-left: 24px; } body:not(.editing) #view { padding-top: 12px; } } @media (min-width: 768px) { #navbar { padding-left: 24px; } body:not(.editing) #view { padding-top: 12px; } }
/* Mail */ /* Mail */
.mailgrid { display: grid; gap: 14px; grid-template-columns: 1fr; margin-top: 16px; }
@media (min-width: 768px) { .mailgrid { grid-template-columns: repeat(2, 1fr); } }
.mcard { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 16px; position: relative; overflow: hidden; display: flex; flex-direction: column; gap: 16px; }
.mcard::before { content: ''; position: absolute; left: 0; top: 0; right: 0; height: 4px; background: var(--mc); }
.mhd { display: flex; align-items: center; gap: 12px; }
.mhd .grow { flex: 1; min-width: 0; }
.mhd b, .mhd small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mhd b { font-size: 17px; }
.mhd small { color: var(--muted); }
.mbadge { width: 48px; height: 48px; border-radius: 14px; background: var(--mc); color: #fff; display: grid; place-items: center; flex: none; }
.mbadge svg { width: 26px; height: 26px; }
.mact { display: flex; gap: 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) */ /* Collapsible menu (tablet / computer) */
.navtoggle { display: none; } .navtoggle { display: none; }
@@ -482,3 +467,89 @@ body.lyropen { overflow: hidden; }
.npc-l { min-height: 38px; font-size: 14px; } .npc-l { min-height: 38px; font-size: 14px; }
@media (max-width: 560px) { .lyrtop .lyrmode { display: none; } .lyrfoot #lyrs { padding: 0 10px; font-size: 13px; } .npc-l { display: inline-flex; } .npc-ctl { gap: 0; } .npc-q { display: none; } } @media (max-width: 560px) { .lyrtop .lyrmode { display: none; } .lyrfoot #lyrs { padding: 0 10px; font-size: 13px; } .npc-l { display: inline-flex; } .npc-ctl { gap: 0; } .npc-q { display: none; } }
.lyrnudge small, .lyrfoot #lyrs { white-space: nowrap; } .lyrnudge small, .lyrfoot #lyrs { white-space: nowrap; }
/* Command palette */
#pal { position: fixed; inset: 0; z-index: 60; display: flex; justify-content: center; align-items: flex-start; padding: calc(var(--sat) + 12vh) 12px 12px; }
#pal .scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, .45); }
.palbox { position: relative; width: min(560px, 100%); background: var(--panel); border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, .35); overflow: hidden; }
.palbox input { width: 100%; border: 0; border-bottom: 1px solid var(--line); background: transparent; padding: 16px 18px; font-size: 17px; outline: none; }
#pall { max-height: min(50vh, 420px); overflow-y: auto; padding: 6px; }
.palrow { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer; }
.palrow .grow { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palrow small { color: var(--muted); flex: none; }
.palrow.on { background: var(--panel2); }
.palrow svg { width: 20px; height: 20px; color: var(--muted); }
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
/* Offline notice, skip link, keyboard focus */
#offline { position: fixed; left: 0; right: 0; top: 0; z-index: 70; padding: calc(var(--sat) + 6px) 16px 6px; text-align: center; font-size: 13px; background: #b26a00; color: #fff; }
.skip { position: fixed; left: 8px; top: -60px; z-index: 80; padding: 10px 14px; border-radius: 10px; background: var(--accent); color: var(--accent-t); }
.skip:focus { top: calc(var(--sat) + 8px); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.row.subtask { padding-left: 34px; }
.row.subtask .tick { transform: scale(.88); }
/* Calendar week and day views */
.tgwrap { overflow: auto; max-height: 68vh; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); }
.tg { position: relative; }
.tgg { width: 44px; flex: none; }
.tghead, .tgad, .tgbody { display: flex; }
.tgtop { position: sticky; top: 0; z-index: 3; background: var(--panel); border-bottom: 1px solid var(--line); }
.tgd { flex: 1; border: 0; background: none; padding: 6px 0; display: flex; flex-direction: column; align-items: center; gap: 0; color: var(--muted); min-width: 0; }
.tgd b { font-size: 17px; color: var(--text); width: 30px; height: 30px; line-height: 30px; border-radius: 50%; }
.tgd.today b { background: var(--accent); color: var(--accent-t); }
.tgad { border-bottom: 1px solid var(--line); }
.tgad .tgg { font-size: 10px; color: var(--muted); padding: 6px 4px 0; }
.tgad > div { flex: 1; min-width: 0; padding: 3px 2px; display: flex; flex-direction: column; gap: 2px; border-left: 1px solid var(--line); }
.tgchip { border: 0; text-align: left; font-size: 12px; padding: 2px 6px; border-radius: 6px; background: color-mix(in srgb, var(--c) 28%, transparent); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tgh { position: relative; }
.tgh span { position: absolute; top: -8px; right: 6px; font-size: 10px; color: var(--muted); }
.tgcol { flex: 1; position: relative; min-width: 0; border-left: 1px solid var(--line); cursor: cell; background-image: linear-gradient(var(--line) 1px, transparent 1px); background-size: 100% 48px; }
.tgcol.today { background-color: color-mix(in srgb, var(--accent) 5%, transparent); }
.tgev { position: absolute; border: 0; border-left: 3px solid var(--c); border-radius: 6px; padding: 2px 5px; text-align: left; overflow: hidden; font-size: 12px; line-height: 1.25; background: color-mix(in srgb, var(--c) 24%, var(--panel)); color: var(--text); cursor: pointer; }
.tgev b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tgev small { color: var(--muted); font-size: 11px; }
.tgnow { position: absolute; left: 0; right: 0; height: 2px; background: var(--danger); z-index: 2; pointer-events: none; }
/* Mail */
.mailapp { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; min-height: 70dvh; }
.mlist, .mread { min-width: 0; }
.mailapp .mfold { display: none; }
.mailapp[data-mode="list"] .mread, .mailapp[data-mode="read"] .mlist { display: none; }
.mtool { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
.mtool select { width: auto; max-width: 42%; }
.mtool input[type=search] { flex: 1; min-width: 120px; margin: 0; }
.mrow { align-items: flex-start; }
.mrow .grow { min-width: 0; display: flex; flex-direction: column; }
.mrow b, .mrow .msub, .mrow small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.mrow .msub { font-size: 14px; }
.mrow:not(.unread) b { font-weight: 500; }
.mrow:not(.unread) .msub { color: var(--muted); }
.mrow.unread b::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-right: 6px; }
.mrow.sel { background: var(--panel2); }
.mmeta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: none; color: var(--muted); }
.mmeta svg { width: 14px; height: 14px; }
.mread { min-width: 0; display: flex; flex-direction: column; }
.mrhead { padding: 4px 0 8px; }
.msubj { margin: 8px 0 4px; font-size: 20px; text-transform: none; letter-spacing: 0; color: var(--text); }
.mfrom, .mto { margin: 0 0 2px; }
.mact, .matt { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.mact .btn, .matt .btn { min-height: 38px; }
.mimg { background: var(--panel2); border-radius: 10px; padding: 8px 12px; font-size: 14px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.mbody { width: 100%; flex: 1; min-height: 55dvh; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
@media (min-width: 1024px) {
.mailapp { grid-template-columns: 340px minmax(0, 1fr); height: calc(100dvh - 40px); min-height: 0; }
.mailapp[data-mode] .mlist, .mailapp[data-mode] .mread { display: block; }
.mailapp[data-mode] .mread { display: flex; }
.mlist, .mread { overflow-y: auto; min-height: 0; }
.mback { display: none; }
.mbody { min-height: 0; }
}
@media (min-width: 1500px) {
.mailapp { grid-template-columns: 190px 340px minmax(0, 1fr); }
.mailapp .mfold { display: flex; flex-direction: column; gap: 2px; }
.mfold a { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 10px; color: var(--muted); }
.mfold a.on { background: var(--panel2); color: var(--text); font-weight: 600; }
.mtool select { display: none; }
}
+32 -6
View File
@@ -1,20 +1,46 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v6'; const CACHE = 'hub-v8';
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
self.addEventListener('install', e => { // Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL))); async function precache() {
self.skipWaiting(); const c = await caches.open(CACHE);
}); await c.addAll(SHELL);
try {
const r = await fetch('/', { credentials: 'same-origin', redirect: 'manual' });
if (r.ok) {
const html = await r.clone().text();
await c.put('/', r);
const urls = [...html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css)(?:\?[^"]*)?)"/g)].map(m => m[1]);
await Promise.all(urls.map(u => c.add(u).catch(() => {})));
}
} catch { /* signed out or offline: it is cached the first time it is used */ }
}
self.addEventListener('install', e => { e.waitUntil(precache()); self.skipWaiting(); });
self.addEventListener('activate', e => { self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
self.clients.claim(); self.clients.claim();
}); });
self.addEventListener('fetch', e => { self.addEventListener('fetch', e => {
const u = new URL(e.request.url); 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('/webmail') || 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('/api/')) return;
e.respondWith(fetch(e.request).then(r => { e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); } if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
return r; return r;
}).catch(() => caches.match(e.request))); }).catch(() => caches.match(e.request)));
}); });
// Push reminders (see push.js on the server)
self.addEventListener('push', e => {
let d = {}; try { d = e.data.json(); } catch {}
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
});
self.addEventListener('notificationclick', e => {
e.notification.close();
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(cs => {
const c = cs.find(x => new URL(x.url).origin === self.location.origin);
if (c) { c.focus(); return c.navigate(url); }
return self.clients.openWindow(url);
}));
});
+257
View File
@@ -0,0 +1,257 @@
'use strict';
// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
const taskStore = []; // tasks on screen, so a tap can open the right one
async function viewTasks() {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>';
try {
const { lists, icloud } = await jfetch('/api/tasks/lists');
if (!location.hash.startsWith('#/tasks')) return;
let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
const list = lists.find(l => l.id === cur);
v.innerHTML = `<div class="wrap"><h1>Tasks</h1>
<div class="seg">${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div id="tl"><p class="empty">Loading…</p></div>
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
: icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
v.querySelectorAll('.seg a[data-l]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('task_list', a.dataset.l); viewTasks(); });
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
$('#qa').onsubmit = async e => {
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
catch (err) { toast(err.message); }
};
loadTaskList(cur, list);
} catch (e) { v.innerHTML = `<div class="wrap"><h1>Tasks</h1><p class="err">${esc(e.message)}</p></div>`; }
}
async function loadTaskList(cur, list) {
const box = $('#tl'); if (!box) return;
try {
const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
if (!$('#tl')) return;
taskStore.length = 0;
const ids = new Set(tasks.map(t => t.id)), byParent = {};
tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); });
const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title);
const open = tasks.filter(t => top(t) && !t.done).sort(byDue);
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
const extra = [rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
const showDone = store.get('task_show_done', false);
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : '<p class="empty">Nothing to do. Add a task above.</p>')
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : ''}` : '');
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
const t = taskStore[+b.dataset.t]; b.disabled = true;
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
catch (err) { b.disabled = false; toast(err.message); }
});
box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list));
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
function taskSheet(t, cur, list) {
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
${list.local ? `<label for="trep">Repeat</label><select id="trep">${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `<option value="${v}" ${(t.repeat || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
<p class="sub" style="margin:4px 0 0">A repeating task moves to its next date when you complete it.</p>` : ''}
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
${list.local && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
<div style="display:flex;gap:8px;margin-top:6px"><input type="text" id="tsub" placeholder="Add a subtask" autocomplete="off" style="flex:1"><button type="button" class="btn ghost" id="tsubadd">Add</button></div>` : ''}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div>
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
el.querySelector('#tf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); loadTaskList(cur, list); }
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
};
if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
const inp = el.querySelector('#tsub'), title = inp.value.trim(); if (!title) return;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title, notes: '', due: '', done: false, parent: t.id } }) });
el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `<div class="row"><span class="grow"><b>${esc(title)}</b></span></div>`); inp.value = ''; inp.focus(); loadTaskList(cur, list); }
catch (err) { el.querySelector('#te').textContent = err.message; }
};
if (el.querySelector('#tsub')) el.querySelector('#tsub').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); el.querySelector('#tsubadd').click(); } };
el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title + (taskStore.some(k => k.parent === t.id) ? ' and its subtasks' : ''), 'Delete', async () => {
try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); }
catch (err) { toast(err.message); }
});
}
function newListSheet(lists, list) {
const mine = lists.filter(l => l.local && l.id !== 'local:inbox');
const el = openSheet('Lists', '', `${mine.length ? `<div class="list" style="background:var(--panel2);border:0">${mine.map(l => `<div class="row"><span class="grow"><b>${esc(l.name)}</b></span><button class="btn danger" data-rm="${esc(l.id)}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
<form id="lf"><label for="ln">New list</label><input type="text" id="ln" placeholder="Groceries" autocomplete="off" required>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Add list</button></div><div class="err" id="le"></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#lf').onsubmit = async e => { e.preventDefault(); try { const r = await jfetch('/api/tasks/lists', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#ln').value }) }); store.set('task_list', r.id); closeSheet(); viewTasks(); } catch (err) { el.querySelector('#le').textContent = err.message; } };
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
}
// Home: a card only when something is overdue or due today
async function renderTasksCard(el) {
if (!el) return;
try {
const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today;
if (!n || !document.body.contains(el)) return;
el.innerHTML = `<h2>Tasks</h2><a class="row card2" href="#/tasks">${icon('check')}<span class="grow"><b>${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention</b><small>${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}</small></span>${icon('chev', 'chev')}</a>`;
} catch {}
}
const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
function viewOffice(args = []) {
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
$('#view').innerHTML = `<div class="wrap"><h1>Office</h1><p class="sub">Create, open and edit documents without leaving this tab. Everything is saved in Seafile.</p>
<div class="grid">
<button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button>
<button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button>
<button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button>
</div>
<p class="sub" id="olib" style="margin-top:12px">${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
<div id="odocs"></div>
<div class="bar"><a class="btn ghost" href="#/files">Browse all files</a>${sf.token() ? '' : '<a class="btn" href="#/files">Sign in</a>'}</div></div>`;
$('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l));
if (!sf.token()) return;
(async () => {
try {
const loc = await officeLocation(false), el = $('#olib'); if (!el) return;
if (!loc) {
el.textContent = 'Choose where your documents are saved in Seafile.';
$('#odocs').innerHTML = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
return;
}
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
$('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); };
const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60);
if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
$('#odocs').innerHTML = docs.length ? `<h2>Documents</h2><div class="list">${docs.map((f, i) => {
const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b';
return `<button class="row" data-i="${i}"><span class="ic" style="background:${color}">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)}</small></span></button>`; }).join('')}</div>`
: '<p class="empty">No documents here yet. Create one above.</p>';
$('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => {
const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
addRecent(loc.repo, path, f.name);
if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
});
} catch (e) { const el = $('#olib'); if (el) el.innerHTML = `<span class="err">${esc(e.message)}</span>`; }
})();
}
const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / '));
// The saved Office location, if it still exists (the library may have been deleted).
async function officeLocation(ask) {
const loc = store.get('office_loc');
if (loc) {
let repos;
try { repos = await sf.req('/api2/repos/'); } catch { return loc; } // a network hiccup must not forget the choice
const r = repos.find(x => x.id === loc.repo);
if (r) {
try { await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}`); return { ...loc, repoName: r.name }; }
catch (e) { if (!/not found|404|exist/i.test(e.message)) return { ...loc, repoName: r.name }; }
}
store.del('office_loc'); // library or folder is really gone
}
if (!ask) return null;
const l = await chooseFolder('Save documents in…'); if (l) store.set('office_loc', l); return l;
}
// Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled.
function chooseFolder(title, start, opts = {}) {
return new Promise(resolve => {
let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false;
const el = openSheet(title, '', '<div id="cf"></div>');
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const finish = v => { done = true; closeSheet(); resolve(v); };
const draw = async () => {
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty">Loading…</p>';
try {
if (!repo) {
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `<p class="sub">Choose a library</p><div class="list" style="background:var(--panel2);border:0;max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No libraries. Create one in Files.</p>'}</div>
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button></div>`;
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); });
} else {
const hidden = opts.hide ? opts.hide(repo, dir) : [], blocked = opts.blocked ? opts.blocked(repo, dir) : false;
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir' && !hidden.includes(x.name)).sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(locLabel({ repoName, dir }))}</p><div class="list" style="background:var(--panel2);border:0;max-height:45dvh;overflow:auto">
<button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button>${opts.newFolder ? `<button class="btn ghost" id="cn" aria-label="New folder">${icon('newdir')}</button>` : ''}<button class="btn" id="cs" style="flex:1;justify-content:center" ${blocked ? 'disabled' : ''}>${esc(opts.okLabel || 'Save here')}</button></div>`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); });
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir });
const cn = box.querySelector('#cn'); if (cn) cn.onclick = () => {
cn.disabled = true; const f = document.createElement('form'); f.style.marginTop = '10px';
f.innerHTML = '<input type="text" id="cmn" placeholder="Folder name" autocomplete="off" autocapitalize="off" required><div class="err" id="cme" style="margin-top:6px"></div>';
box.insertBefore(f, box.querySelector('.bar')); f.querySelector('input').focus();
f.onsubmit = async ev => { ev.preventDefault(); const n = f.querySelector('input').value.trim(), bad = badName(n, subs); if (bad) { f.querySelector('#cme').textContent = bad; return; }
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(joinP(dir, n))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'mkdir' }) }); dir = joinP(dir, n); draw(); }
catch (e) { f.querySelector('#cme').textContent = e.message; } };
};
}
box.querySelector('#cx').onclick = () => finish(null);
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
draw();
});
}
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
async function createFromTemplate(repo, dir, name, x) {
const enc = encodeURIComponent;
if (!['docx', 'xlsx', 'pptx'].includes(x)) {
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
return name;
}
const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing');
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name);
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Could not create ' + name);
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
}
async function newOfficeDoc(x, label) {
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
let loc;
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
if (!loc) return;
const el = openSheet('New ' + label, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required>
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
showLoc();
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#nl').onclick = async () => {
// Sheets don't stack, so close this one, choose, then reopen with the new location.
const picked = await chooseFolder('Save in…', loc);
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label);
};
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
el.querySelector('form').onsubmit = async e => {
e.preventDefault();
const n = el.querySelector('#nn').value.trim(); if (!n) return;
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try {
const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name);
addRecent(loc.repo, path, name); closeSheet();
if (cfg.editor) location.hash = officeHash(loc.repo, path);
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
} catch (err) { btn.disabled = false; el.querySelector('#ne').textContent = err.message; }
};
}
+119
View File
@@ -0,0 +1,119 @@
// Web Push reminders: a notification on a person's devices for tasks due today and events about to start,
// even when the app is closed. Keys are made once and kept in /data/vapid.json; each person's devices and preferences
// live in their own folder (push.json). iPhone and iPad only deliver these to the installed home-screen app.
const fs = require('fs');
const path = require('path');
const webpush = require('web-push');
const atomicWrite = require('./atomic');
const users = require('./users');
const tasks = require('./tasks');
const cal = require('./cal');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
let vapid = null;
function keys() {
if (vapid) return vapid;
const f = path.join(DATA_DIR, 'vapid.json');
try { vapid = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { vapid = webpush.generateVAPIDKeys(); fs.mkdirSync(DATA_DIR, { recursive: true }); atomicWrite(f, JSON.stringify(vapid)); }
webpush.setVapidDetails(process.env.VAPID_SUBJECT || (process.env.PUBLIC_URL || 'mailto:admin@localhost.invalid'), vapid.publicKey, vapid.privateKey);
return vapid;
}
const DEFAULTS = { tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
const file = dir => path.join(dir, 'push.json');
const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } };
const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o));
const validTz = tz => { try { new Intl.DateTimeFormat('en', { timeZone: tz }); return true; } catch { return false; } };
/** The person's own wall clock: { day: 'YYYY-MM-DD', hm: 'HH:MM' } */
function localNow(tz, at = new Date()) {
const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(at).map(x => [x.type, x.value]));
return { day: `${p.year}-${p.month}-${p.day}`, hm: `${p.hour}:${p.minute}` };
}
async function send(dir, st, payload) {
keys();
let changed = false;
await Promise.all(st.subs.map(async s => {
try { await webpush.sendNotification(s.sub, JSON.stringify(payload), { TTL: 3600 }); }
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); }
}));
if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); }
}
let lastEvents = 0;
/** One pass over everyone: runs every minute. */
async function tick(now = new Date(), force = false) {
const checkEvents = force || now.getTime() - lastEvents >= 4.5 * 60e3;
if (checkEvents) lastEvents = now.getTime();
for (const u of users.all()) {
if (u.disabled) continue;
const dir = users.dirOf(u.id), st = load(dir);
if (!st.subs.length) continue;
await users.runAs(u, async () => {
const lt = localNow(st.tz, now); let dirty = false;
if (st.tasks && users.can(u, 'tasks') && st.tasksDay !== lt.day && lt.hm >= st.tasksAt) {
st.tasksDay = lt.day; dirty = true;
try {
const due = (await tasks.allTasks()).filter(t => !t.done && t.due && t.due <= lt.day);
if (due.length) {
const over = due.filter(t => t.due < lt.day).length;
await send(dir, st, { title: due.length === 1 ? due[0].title : due.length + ' tasks need attention', body: due.length === 1 ? (over ? 'Overdue' : 'Due today') : due.slice(0, 3).map(t => t.title).join(', ') + (due.length > 3 ? '…' : '') + (over ? ` (${over} overdue)` : ''), url: '#/tasks', tag: 'tasks-' + lt.day });
}
} catch (e) { console.warn('[push] tasks check failed:', e.message); }
}
if (checkEvents && st.events && users.can(u, 'calendar')) {
try {
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
const fd = new Date(from - 864e5).toISOString().slice(0, 10), td = new Date(to + 864e5).toISOString().slice(0, 10);
const r = await cal.collectEvents({ from, to, fd, td });
for (const e of r.events) {
if (e.allDay || e.start < from || e.start > to) continue;
const key = (e.uid || e.title) + '@' + e.start;
if (st.sent.includes(key)) continue;
st.sent.push(key); dirty = true;
const mins = Math.max(1, Math.round((e.start - from) / 6e4));
await send(dir, st, { title: e.title, body: `Starts in ${mins} min${e.location ? ' · ' + e.location : ''}`, url: '#/calendar', tag: key });
}
if (st.sent.length > 300) { st.sent = st.sent.slice(-200); dirty = true; }
} catch (e) { console.warn('[push] events check failed:', e.message); }
}
if (dirty) save(dir, st);
});
}
}
let timer = null;
function start() { if (!timer) { timer = setInterval(() => tick().catch(e => console.warn('[push]', e.message)), 60e3); timer.unref(); } }
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
const pub = () => ({ tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() });
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const b = JSON.parse((await readBody(req)) || '{}');
if (p === '/api/push/subscribe') {
const s = b.subscription;
if (!s || !/^https:\/\//.test(s.endpoint || '') || !s.keys || !s.keys.p256dh || !s.keys.auth) return json(res, 400, { error: 'That is not a valid push subscription' });
st.subs = st.subs.filter(x => x.sub.endpoint !== s.endpoint).concat({ sub: { endpoint: s.endpoint, keys: { p256dh: s.keys.p256dh, auth: s.keys.auth } }, created: Date.now() });
if (b.tz && validTz(b.tz)) st.tz = b.tz;
save(dir, st); return json(res, 200, { ok: true, ...pub() });
}
if (p === '/api/push/unsubscribe') { st.subs = st.subs.filter(x => x.sub.endpoint !== b.endpoint); save(dir, st); return json(res, 200, { ok: true, ...pub() }); }
if (p === '/api/push/prefs') {
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
if (typeof b.events === 'boolean') st.events = b.events;
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt;
if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
if (b.tz && validTz(b.tz)) st.tz = b.tz;
save(dir, st); return json(res, 200, { ok: true, ...pub() });
}
if (p === '/api/push/test') {
if (!st.subs.length) return json(res, 400, { error: 'No device is signed up for reminders yet' });
await send(dir, st, { title: 'NE Hub reminders are on', body: 'You will see task and event reminders here.', url: '#/home', tag: 'test' });
return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
}
module.exports = { handle, start, tick, localNow };
+45 -59
View File
@@ -2,6 +2,7 @@
// (Seafile sends no CORS headers, so the browser talks to /sf/* instead). // (Seafile sends no CORS headers, so the browser talks to /sf/* instead).
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
const atomicWrite = require('./atomic');
const crypto = require('crypto'); const crypto = require('crypto');
const path = require('path'); const path = require('path');
const { Readable } = require('stream'); const { Readable } = require('stream');
@@ -10,12 +11,6 @@ const PORT = process.env.PORT || 8080;
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, ''); 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 ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, ''); const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
// 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_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
const AUDIOBOOKS_SSO_SECRET = process.env.AUDIOBOOKS_SSO_SECRET || ''; 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 // 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
@@ -35,6 +30,9 @@ const settings = require('./settings');
const search = require('./search'); const search = require('./search');
const users = require('./users'); const users = require('./users');
const admin = require('./admin'); const admin = require('./admin');
const backup = require('./backup');
const push = require('./push');
const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID)
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL) const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL) const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
const vault = require('./vault')('nehub-plex'); const vault = require('./vault')('nehub-plex');
@@ -97,7 +95,7 @@ function serveStatic(req, res) {
if (err) { res.writeHead(404); return res.end('Not found'); } if (err) { res.writeHead(404); return res.end('Not found'); }
// The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in // The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in
// Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy.
if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/[\w-]+\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`));
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change
else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed
@@ -111,7 +109,7 @@ function serveStatic(req, res) {
} }
// Changes whenever the app's files change, so an open page can tell that a newer version is available. // Changes whenever the app's files change, so an open page can tell that a newer version is available.
const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); const BUILD = (() => { try { return crypto.createHash('sha1').update(fs.readdirSync(PUBLIC).filter(f => /\.(js|css|html)$/.test(f)).sort().map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || ''; const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
@@ -198,7 +196,7 @@ const PLEX_HEADERS = {
// Each person's Plex sign-in is stored (encrypted) in their own folder, so it follows them to every device. // Each person's Plex sign-in is stored (encrypted) in their own folder, so it follows them to every device.
const plexFile = () => require('path').join(users.dir(), 'plex.json'); const plexFile = () => require('path').join(users.dir(), 'plex.json');
const plexLoad = () => { try { return vault.unseal(JSON.parse(require('fs').readFileSync(plexFile(), 'utf8')).data) || {}; } catch { return {}; } }; const plexLoad = () => { try { return vault.unseal(JSON.parse(require('fs').readFileSync(plexFile(), 'utf8')).data) || {}; } catch { return {}; } };
const plexSave = o => require('fs').writeFileSync(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); const plexSave = o => atomicWrite(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
const plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} }; const plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} };
const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this) const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this)
const plexTv = async (path, token, opts = {}) => { const plexTv = async (path, token, opts = {}) => {
@@ -321,6 +319,8 @@ async function plexApi(req, res) {
} catch (e) { json(res, 502, { error: e.message }); } } catch (e) { json(res, 502, { error: e.message }); }
} }
// Only playlists may be changed through the proxy (create, add, remove, rename, delete); everything else on the Plex server stays read-only.
const PLEX_WRITABLE = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
async function plexProxy(req, res) { async function plexProxy(req, res) {
const st = plexLoad(); const st = plexLoad();
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); } if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
@@ -328,7 +328,7 @@ async function plexProxy(req, res) {
res.on('close', () => ac.abort()); res.on('close', () => ac.abort());
// A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response. // A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response.
const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } }; const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } };
const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal, const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal, method: req.method,
headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } }); headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } });
let up = null; let up = null;
try { up = await attempt(st); } catch { up = null; } try { up = await attempt(st); } catch { up = null; }
@@ -349,46 +349,6 @@ async function plexProxy(req, res) {
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); } } catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
} }
// ---- 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(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 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, 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, 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(/</g, '\\u003c'), a = loc.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
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 (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/webmail', 'cache-control': 'no-store' }); r.resume(); return res.end();
}
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 the webmail')));
up.on('error', e => {
if (res.headersSent) return res.destroy();
console.error('[webmail] cannot reach the webmail:', 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) // ---- 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. */ /** 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) { function classifyMount(mountinfoLine) {
@@ -410,6 +370,7 @@ function storageInfo() {
startedAt: STARTED }; startedAt: STARTED };
} }
const STARTED = Date.now(); const STARTED = Date.now();
push.start();
lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' }); lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' });
signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' }); signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' });
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`); { const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
@@ -438,7 +399,8 @@ async function ddgAnswer(req, res) {
} catch { json(res, 200, {}); } // a failed lookup must never break Search } catch { json(res, 200, {}); } // a failed lookup must never break Search
} }
if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.'); if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing is switched off (the save callback would be unauthenticated). Set it, or ALLOW_UNSIGNED_OO=1 for testing only.');
const OO_OK = !!JWT_SECRET || process.env.ALLOW_UNSIGNED_OO === '1';
// Which tab(s) each part of the server belongs to. A person needs at least one of them (admins have all). // Which tab(s) each part of the server belongs to. A person needs at least one of them (admins have all).
const ACCESS = [ const ACCESS = [
@@ -448,16 +410,38 @@ const ACCESS = [
[/^\/api\/audiobooks\//, ['audio']], [/^\/api\/audiobooks\//, ['audio']],
[/^\/api\/signal\//, ['signal']], [/^\/api\/signal\//, ['signal']],
[/^\/api\/lyrics$/, ['music']], [/^\/api\/lyrics$/, ['music']],
[/^\/(webmail(\/|$)|api\/webmail\/)/, ['mail']], [/^\/api\/mail\//, ['mail']],
[/^\/api\/cal\//, ['calendar']], [/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']], [/^\/api\/tasks/, ['tasks']],
[/^\/api\/(search|ddg)$/, ['search']], [/^\/api\/(search|ddg)$/, ['search']],
]; ];
const forbidden = (res, msg = 'You do not have access to this') => { res.writeHead(403, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: msg })); }; const forbidden = (res, msg = 'You do not have access to this') => { res.writeHead(403, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: msg })); };
// Headers for every response. No script/style rules on purpose (the app uses inline handlers); these stop framing by other sites,
// content sniffing, form hijacking and leaking the address to other sites.
function securityHeaders(res) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'");
res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()');
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
}
/** Cross-site request check: a browser request that changes something must come from this same site. */
function crossSite(req) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return false;
const sfs = req.headers['sec-fetch-site'];
if (sfs) return !['same-origin', 'none'].includes(sfs);
const o = req.headers.origin; if (!o) return false; // not a browser form/fetch (scripts, tests, OnlyOffice)
try { const h = new URL(o).host; return ![req.headers['x-forwarded-host'], req.headers.host, PUBLIC_URL && new URL(PUBLIC_URL).host].includes(h); } catch { return true; }
}
async function handleRequest(req, res) { async function handleRequest(req, res) {
const pathname = new URL(req.url, 'http://x').pathname; const pathname = new URL(req.url, 'http://x').pathname;
securityHeaders(res);
if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); }
if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused');
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res); if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
if (pathname === '/api/mail/callback' && mail.enabled()) return mail.callback(req, res); // Microsoft sends the person back here; the one-time state says who they are
if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session
// Everything else needs a passkey session, except the sign-in page and its assets, // Everything else needs a passkey session, except the sign-in page and its assets,
// and OnlyOffice's save callback (which carries its own signed token). // and OnlyOffice's save callback (which carries its own signed token).
@@ -466,7 +450,7 @@ async function handleRequest(req, res) {
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback']; const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
if (!user) { if (!user) {
if (open.includes(pathname) || pathname.startsWith('/icons/')) { if (open.includes(pathname) || pathname.startsWith('/icons/')) {
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res); if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return OO_OK ? ooCallback(req, res) : json(res, 403, { error: 1 });
return serveStatic(req, res); return serveStatic(req, res);
} }
if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); } if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); }
@@ -476,17 +460,20 @@ async function handleRequest(req, res) {
return users.runAs(user, async () => { return users.runAs(user, async () => {
const rule = ACCESS.find(([re]) => re.test(pathname)); const rule = ACCESS.find(([re]) => re.test(pathname));
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res); if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
if ((pathname === '/api/system' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); if ((pathname === '/api/system' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); } if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user); if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
if (req.url === '/api/config') { // only reveal the addresses of things this person may use if (req.url === '/api/config') { // only reveal the addresses of things this person may use
const c = t => users.can(user, t); const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' }); 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 : '', 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 : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD })); podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') && mail.enabled() ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
} }
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res); if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
if (pathname === '/api/ddg') return ddgAnswer(req, res); if (pathname === '/api/ddg') return ddgAnswer(req, res);
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); }
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
if (pathname === '/api/settings') return settings.handle(req, res); if (pathname === '/api/settings') return settings.handle(req, res);
if (pathname === '/api/search') return search.handle(req, res); if (pathname === '/api/search') return search.handle(req, res);
@@ -496,12 +483,11 @@ async function handleRequest(req, res) {
const to = audiobooksLogin(user); const to = audiobooksLogin(user);
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end(); res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
} }
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 (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server'); if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) return proxyWebmail(req, res); if (pathname.startsWith('/api/mail/')) return mail.enabled() ? mail.handle(req, res) : forbidden(res, 'Mail is not set up on this server');
if (req.url.startsWith('/api/plex/')) return plexApi(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('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res); if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
return serveStatic(req, res); return serveStatic(req, res);
}); });
+1 -1
View File
@@ -9,7 +9,7 @@ const users = require('./users');
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
// Only these keys are accepted. (The music queue stays in the browser.) // Only these keys are accepted. (The music queue stays in the browser.)
const KEYS = new Set(['mail_hints', 'mail_on', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']); const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
const SECRET = new Set(['ab_creds', 'sf_token']); const SECRET = new Set(['ab_creds', 'sf_token']);
const MAX_VALUE = 20000; // bytes of JSON per key const MAX_VALUE = 20000; // bytes of JSON per key
+48 -9
View File
@@ -1,6 +1,7 @@
// Tasks: a list stored in NE Hub, plus any iCloud reminder lists the account exposes over CalDAV. // Tasks: a list stored in NE Hub, plus any iCloud reminder lists the account exposes over CalDAV.
// (Apple hides Reminders lists that were upgraded for iOS 13+ from CalDAV, so iCloud lists may be absent; NE Hub lists always work.) // (Apple hides Reminders lists that were upgraded for iOS 13+ from CalDAV, so iCloud lists may be absent; NE Hub lists always work.)
const fs = require('fs'); const fs = require('fs');
const atomicWrite = require('./atomic');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const ics = require('./ics'); const ics = require('./ics');
@@ -10,7 +11,7 @@ const caldav = require('./caldav');
const users = require('./users'); const users = require('./users');
const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } }; const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } };
const save = d => fs.writeFileSync(file(), JSON.stringify(d, null, 2), { mode: 0o600 }); const save = d => atomicWrite(file(), JSON.stringify(d, null, 2), { mode: 0o600 });
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const isLocal = id => String(id || '').startsWith('local:'); const isLocal = id => String(id || '').startsWith('local:');
@@ -37,6 +38,39 @@ function parseTodos(text) {
} }
return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed })); return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed }));
} }
// ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it.
const REPEATS = ['none', 'daily', 'weekdays', 'weekly', 'monthly', 'yearly'];
const toYmd = d => d.toISOString().slice(0, 10);
const fromYmd = s => { const [y, m, d] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)); };
function stepDate(s, repeat) {
const d = fromYmd(s), day = d.getUTCDate();
if (repeat === 'daily') d.setUTCDate(day + 1);
else if (repeat === 'weekdays') { do d.setUTCDate(d.getUTCDate() + 1); while ([0, 6].includes(d.getUTCDay())); }
else if (repeat === 'weekly') d.setUTCDate(day + 7);
else if (repeat === 'monthly') { d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() + 1); d.setUTCDate(Math.min(day, new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate())); }
else if (repeat === 'yearly') { d.setUTCDate(1); d.setUTCFullYear(d.getUTCFullYear() + 1); d.setUTCDate(Math.min(day, new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate())); }
return toYmd(d);
}
/** The next due date after finishing a repeating task: one step after the due date, and always after today. */
function nextDue(due, repeat, today = toYmd(new Date())) {
let n = stepDate(due || today, repeat), guard = 0;
while (n <= today && guard++ < 4000) n = stepDate(n, repeat);
return n;
}
const cleanRepeat = r => REPEATS.includes(r) ? r : 'none';
/** Mark a local task done; a repeating one rolls forward instead. Returns the new due date when it rolled. */
function finishLocal(t, done) {
if (done && !t.done && t.repeat && t.repeat !== 'none') { t.due = nextDue(t.due, t.repeat); t.done = false; t.completedAt = null; t.lastDone = Date.now(); t.updated = Date.now(); return t.due; }
t.done = !!done; t.completedAt = t.done ? Date.now() : null; t.updated = Date.now(); return null;
}
function validParent(d, raw, parent, selfId) {
if (!parent) return '';
const p = d.tasks.find(x => x.id === parent && x.list === raw);
if (!p || p.parent || parent === selfId) throw new Error('A subtask can only go under a main task in the same list');
if (selfId && d.tasks.some(x => x.parent === selfId)) throw new Error('A task that has subtasks cannot become a subtask');
return parent;
}
const fieldsOf = t => ({ title: t.title, notes: t.notes, due: t.due, done: t.done }); const fieldsOf = t => ({ title: t.title, notes: t.notes, due: t.due, done: t.done });
async function handle(req, res) { async function handle(req, res) {
@@ -63,7 +97,7 @@ async function handle(req, res) {
const list = q.get('list'); const list = q.get('list');
if (isLocal(list)) { if (isLocal(list)) {
const raw = list.slice(6); const raw = list.slice(6);
return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null }))); return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '' })));
} }
if (caldav.todoLists().some(l => l.id === list)) { if (caldav.todoLists().some(l => l.id === list)) {
const rows = await caldav.listTodoRaw(list), tasks = []; const rows = await caldav.listTodoRaw(list), tasks = [];
@@ -80,10 +114,15 @@ async function handle(req, res) {
if (!d.lists.some(l => l.id === raw)) throw new Error('List not found'); if (!d.lists.some(l => l.id === raw)) throw new Error('List not found');
if (b.id) { if (b.id) {
const t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found'); const t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
const wasDone = !!t.done; Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, updated: Date.now() }); const wasDone = !!t.done, repeat = f.repeat === undefined ? t.repeat || 'none' : cleanRepeat(f.repeat);
if (t.done && !wasDone) t.completedAt = Date.now(); if (!t.done) t.completedAt = null; Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', repeat, updated: Date.now() });
if (b.moveTo && isLocal(b.moveTo)) t.list = b.moveTo.slice(6); if (f.parent !== undefined) t.parent = validParent(d, raw, f.parent, t.id) || undefined;
} else d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, created: Date.now(), updated: Date.now() }); const rolled = finishLocal(t, !!f.done);
if (wasDone && f.done) { t.done = true; t.completedAt = t.completedAt || Date.now(); }
if (b.moveTo && isLocal(b.moveTo) && !t.parent) { const to = b.moveTo.slice(6); d.tasks.filter(x => x.parent === t.id).forEach(x => { x.list = to; }); t.list = to; }
save(d); return json(res, 200, { ok: true, rolled });
}
d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now() });
save(d); return json(res, 200, { ok: true }); save(d); return json(res, 200, { ok: true });
} }
if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive
@@ -96,7 +135,7 @@ async function handle(req, res) {
const b = JSON.parse(await readBody(req)); const b = JSON.parse(await readBody(req));
if (isLocal(b.list)) { if (isLocal(b.list)) {
const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found'); const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found');
t.done = !!b.done; t.completedAt = t.done ? Date.now() : null; t.updated = Date.now(); save(d); return json(res, 200, { ok: true }); const rolled = finishLocal(t, !!b.done); save(d); return json(res, 200, { ok: true, rolled });
} }
const cur = await caldav.getResource(b.list, b.href); const cur = await caldav.getResource(b.list, b.href);
const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task'); const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task');
@@ -105,7 +144,7 @@ async function handle(req, res) {
} }
if (p === '/api/tasks/delete' && req.method === 'POST') { if (p === '/api/tasks/delete' && req.method === 'POST') {
const b = JSON.parse(await readBody(req)); const b = JSON.parse(await readBody(req));
if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id); save(d); return json(res, 200, { ok: true }); } if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id && x.parent !== b.id); save(d); return json(res, 200, { ok: true }); }
await caldav.deleteEvent(b.list, b.href, b.etag); return json(res, 200, { ok: true }); await caldav.deleteEvent(b.list, b.href, b.etag); return json(res, 200, { ok: true });
} }
if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today
@@ -127,4 +166,4 @@ async function allTasks() {
return out; return out;
} }
module.exports = { handle, parseTodos, allTasks }; module.exports = { handle, parseTodos, allTasks, nextDue, stepDate };
+1 -1
View File
@@ -18,7 +18,7 @@ const lrcLib = require('../lyrics');
ok('word timing inside a line is read', l[1].words && l[1].words.length === 2 && l[1].words[0].t === 14000 && l[1].words[1].t === 14800 && l[1].text === 'Second line', l[1]); ok('word timing inside a line is read', l[1].words && l[1].words.length === 2 && l[1].words[0].t === 14000 && l[1].words[1].t === 14800 && l[1].text === 'Second line', l[1]);
ok('a line that repeats (two times) appears twice, in order', l[2].text === 'Chorus' && l[3].text === 'Chorus' && l[3].t === 30250, l.slice(2)); ok('a line that repeats (two times) appears twice, in order', l[2].text === 'Chorus' && l[3].text === 'Chorus' && l[3].t === 30250, l.slice(2));
const client = (() => { // the same reader in the app itself must agree const client = (() => { // the same reader in the app itself must agree
const src = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8'), a = src.indexOf('function lyrParseLrc'), b = src.indexOf('\n}\n', a) + 3; const src = fs.readFileSync(path.join(root, 'public', 'lyrics-view.js'), 'utf8'), a = src.indexOf('function lyrParseLrc'), b = src.indexOf('\n}\n', a) + 3;
return new Function(src.slice(a, b) + '; return lyrParseLrc;')(); return new Function(src.slice(a, b) + '; return lyrParseLrc;')();
})(); })();
ok('and the app reads it the same way', JSON.stringify(client('[ti:x]\n[00:10.50]Hello there\n[00:14.00]<00:14.00>Second <00:14.80>line\n[00:20.00][00:30.25]Chorus\n')) === JSON.stringify(l)); ok('and the app reads it the same way', JSON.stringify(client('[ti:x]\n[00:10.50]Hello there\n[00:14.00]<00:14.00>Second <00:14.80>line\n[00:20.00][00:30.25]Chorus\n')) === JSON.stringify(l));
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Mail (Outlook.com through Microsoft Graph): connecting, token refresh, reading, sending, privacy between people.
// Uses a fake Microsoft sign-in/token service and a fake Graph. Run: node tools/mail-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 = 'mail-test-secret-'.repeat(3);
(async () => {
// ---- fake Microsoft
const ms = { challenge: '', goodTokens: new Set(), refreshCalls: 0, revoked: false, tokenN: 0, calls: [], expiresIn: 3600 };
const body = rq => new Promise(r => { const c = []; rq.on('data', d => c.push(d)); rq.on('end', () => r(Buffer.concat(c).toString())); });
const out = (rs, code, o, h = {}) => { rs.writeHead(code, { 'content-type': 'application/json', ...h }); rs.end(o === undefined ? '' : JSON.stringify(o)); };
const folder = (id, name, unread, total) => ({ id: 'real-' + id, displayName: name, unreadItemCount: unread, totalItemCount: total });
const msgs = [
{ id: 'M1', subject: 'Hello there', from: { emailAddress: { name: 'Ann', address: 'ann@example.com' } }, receivedDateTime: '2026-10-22T10:00:00Z', isRead: false, hasAttachments: true, bodyPreview: 'Hi…', flag: { flagStatus: 'notFlagged' } },
{ id: 'M2', subject: 'Second', from: { emailAddress: { name: 'Bob', address: 'bob@example.com' } }, receivedDateTime: '2026-10-21T10:00:00Z', isRead: true, hasAttachments: false, bodyPreview: 'x', flag: { flagStatus: 'flagged' } },
];
const server = http.createServer(async (rq, rs) => {
const u = new URL(rq.url, 'http://x'), p = u.pathname, b = await body(rq);
ms.calls.push(rq.method + ' ' + decodeURIComponent(p + u.search));
if (p === '/consumers/oauth2/v2.0/token' && rq.method === 'POST') {
const f = new URLSearchParams(b);
if (f.get('client_id') !== 'test-client') return out(rs, 400, { error: 'invalid_client' });
if (f.get('grant_type') === 'authorization_code') {
if (f.get('code') !== 'GOODCODE') return out(rs, 400, { error: 'invalid_grant', error_description: 'bad code' });
if (crypto.createHash('sha256').update(f.get('code_verifier') || '').digest('base64url') !== ms.challenge) return out(rs, 400, { error: 'invalid_grant', error_description: 'PKCE failed' });
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT1', expires_in: ms.expiresIn });
}
if (f.get('grant_type') === 'refresh_token') {
ms.refreshCalls++;
if (ms.revoked || !/^RT/.test(f.get('refresh_token'))) return out(rs, 400, { error: 'invalid_grant', error_description: 'revoked' });
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT' + (ms.tokenN), expires_in: 3600 });
}
}
if (!p.startsWith('/v1.0/')) { rs.writeHead(404); return rs.end(); }
const tok = (rq.headers.authorization || '').replace('Bearer ', '');
if (!ms.goodTokens.has(tok)) return out(rs, 401, { error: { code: 'InvalidAuthenticationToken', message: 'expired' } });
const g = p.slice(5);
if (g === '/me') return out(rs, 200, { displayName: 'Nate', mail: 'nate@outlook.com' });
let m;
if ((m = /^\/me\/mailFolders\/(\w+)$/.exec(g))) { const f = { inbox: folder('inbox', 'Inbox', 3, 10), drafts: folder('drafts', 'Drafts', 0, 1), sentitems: folder('sentitems', 'Sent Items', 0, 4), archive: folder('archive', 'Archive', 0, 0), junkemail: folder('junkemail', 'Junk Email', 0, 0), deleteditems: folder('deleteditems', 'Deleted Items', 0, 2) }[m[1]]; return f ? out(rs, 200, f) : out(rs, 404, { error: { message: 'no folder' } }); }
if (g === '/me/mailFolders') return out(rs, 200, { value: [folder('inbox', 'Inbox', 3, 10), { id: 'CUSTOM1', displayName: 'Receipts', unreadItemCount: 1, totalItemCount: 5 }, { id: 'CUSTOM2', displayName: 'Outbox', unreadItemCount: 0, totalItemCount: 0 }] });
if ((m = /^\/me\/mailFolders\/([\w=-]+)\/messages$/.exec(g))) { const all = u.searchParams.get('$search') ? msgs.filter(x => x.subject.toLowerCase().includes(u.searchParams.get('$search').replace(/"/g, '').toLowerCase())) : msgs; return out(rs, 200, { value: all, ...(u.searchParams.get('$skip') === '0' && all.length >= 2 ? { '@odata.nextLink': 'x' } : {}) }); }
if ((m = /^\/me\/messages\/([\w=-]+)$/.exec(g))) {
if (rq.method === 'PATCH') { ms.patched = JSON.parse(b); return out(rs, 200, {}); }
if (rq.method === 'DELETE') { ms.deleted = m[1]; return out(rs, 204); }
const x = msgs.find(y => y.id === m[1]); if (!x) return out(rs, 404, { error: { message: 'gone' } });
return out(rs, 200, { ...x, toRecipients: [{ emailAddress: { name: 'Nate', address: 'nate@outlook.com' } }], ccRecipients: [], body: { contentType: 'html', content: '<p>Hi <img src="cid:logo1"></p><script>alert(1)</script>' },
attachments: [{ id: 'A1', name: 'report.pdf', contentType: 'application/pdf', size: 5, isInline: false }, { id: 'A2', name: 'logo.png', contentType: 'image/png', size: 4, isInline: true, contentId: '<logo1>' }] });
}
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)$/.exec(g))) return out(rs, 200, m[2] === 'A2' ? { contentBytes: Buffer.from('PNG!').toString('base64'), contentType: 'image/png' } : { name: 'rep"ort\r\n.pdf', contentType: 'application/pdf', size: 5 });
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)\/\$value$/.exec(g))) { rs.writeHead(200, { 'content-type': 'application/pdf' }); return rs.end('%PDF-'); }
if ((m = /^\/me\/messages\/([\w=-]+)\/move$/.exec(g))) { ms.moved = [m[1], JSON.parse(b).destinationId]; return out(rs, 201, {}); }
if (g === '/me/sendMail') { ms.sent = JSON.parse(b); return out(rs, 202); }
if ((m = /^\/me\/messages\/([\w=-]+)\/(reply|replyAll|forward)$/.exec(g))) { ms.replied = [m[1], m[2], JSON.parse(b)]; return out(rs, 202); }
rs.writeHead(404); rs.end();
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const msBase = `http://127.0.0.1:${server.address().port}`;
// ---- the hub with two people: Nate (admin) and Sam (no Mail tab)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mail-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [
{ id: 'unate', name: 'Nate', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
{ id: 'usam', name: 'Sam', role: 'user', tabs: ['tasks'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
{ id: 'uann', name: 'Ann', role: 'user', tabs: ['mail'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 }] }));
const exp = Date.now() + 864e5, ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, MS_CLIENT_ID: 'test-client', MS_AUTH_URL: msBase, MS_GRAPH_URL: msBase + '/v1.0', PUBLIC_URL: '' } });
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const call = async (uid, p, o = {}) => { const r = await fetch(base + p, { redirect: 'manual', ...o, headers: { ...(uid ? { cookie: ck(uid) } : {}), 'content-type': 'application/json', ...(o.headers || {}) }, body: o.body && typeof o.body !== 'string' ? JSON.stringify(o.body) : o.body }); const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, json: j, headers: r.headers, text: t }; };
const post = (uid, p, b) => call(uid, p, { method: 'POST', body: b || {} });
// ================= access =================
let r = await call('uann', '/api/config'); ok('the app is told Mail is set up', r.json.mail === '1', r.json);
r = await call('usam', '/api/config'); ok('a person without the Mail tab is not told', r.json.mail === '', r.json);
r = await call('usam', '/api/mail/status'); ok('and cannot use mail at all', r.status === 403, r.status);
r = await call(null, '/api/mail/status'); ok('nor can someone who is not signed in', r.status === 401, r.status);
r = await call('unate', '/api/mail/status'); ok('before connecting: not connected', r.status === 200 && r.json.connected === false, r.json);
r = await call('unate', '/api/mail/messages'); ok('reading mail before connecting says to connect (409)', r.status === 409, r);
// ================= connecting =================
r = await call('unate', '/api/mail/connect'); const au = new URL(r.json.url);
ok('connect gives a Microsoft sign-in link for personal accounts with PKCE and mail permissions', au.pathname === '/consumers/oauth2/v2.0/authorize' && au.searchParams.get('client_id') === 'test-client' && au.searchParams.get('code_challenge_method') === 'S256' && /Mail\.ReadWrite/.test(au.searchParams.get('scope')) && /Mail\.Send/.test(au.searchParams.get('scope')) && au.searchParams.get('redirect_uri') === base + '/api/mail/callback', r.json);
ms.challenge = au.searchParams.get('code_challenge'); const state = au.searchParams.get('state');
r = await call(null, `/api/mail/callback?code=GOODCODE&state=WRONG`); ok('a callback with an unknown state is refused', r.status === 400, r.status);
r = await call(null, `/api/mail/callback?code=BAD&state=${state}`); ok('a rejected code does not connect anyone', r.status === 400 && /Could not connect/.test(r.text), r.status);
r = await call('unate', '/api/mail/connect'); const au2 = new URL(r.json.url); ms.challenge = au2.searchParams.get('code_challenge');
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the real callback connects and sends the person back to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail', [r.status, r.headers.get('location')]);
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the sign-in link works only once', r.status === 400, r.status);
r = await call('unate', '/api/mail/status'); ok('now connected, with the address and the unread count', r.json.connected && r.json.address === 'nate@outlook.com' && r.json.unread === 3, r.json);
const raw = fs.readFileSync(path.join(dir, 'users', 'unate', 'mail.json'), 'utf8'); ok('the tokens are saved encrypted', !/AT1|RT1/.test(raw), raw.slice(0, 80));
r = await call('uann', '/api/mail/status'); ok("another person is not connected to Nate's mailbox", r.json.connected === false, r.json);
r = await call('uann', '/api/mail/messages'); ok("and cannot read it", r.status === 409, r.status);
// ================= reading =================
r = await call('unate', '/api/mail/folders'); const names = (r.json || []).map(f => f.name);
ok('folders: the usual six first with unread counts, then your own; internal ones hidden', names.slice(0, 6).join() === 'Inbox,Drafts,Sent,Archive,Junk,Deleted' && names.includes('Receipts') && !names.includes('Outbox') && r.json[0].unread === 3 && names.filter(n => n === 'Inbox').length === 1, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('the inbox lists messages, newest first, with who, read state and attachment flag', r.status === 200 && r.json.items.length === 2 && r.json.items[0].from.address === 'ann@example.com' && r.json.items[0].read === false && r.json.items[0].attach === true && r.json.items[1].flagged === true, r.json);
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && r.json.items[0].id === 'M2', r.json);
r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status);
r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json;
ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html);
ok('only real attachments are listed (not the inline picture)', mm.attachments.length === 1 && mm.attachments[0].name === 'report.pdf', mm.attachments);
r = await call('unate', '/api/mail/attachment?id=M1&aid=A1'); ok('an attachment downloads as a file, with a safe name', r.status === 200 && r.text === '%PDF-' && /attachment; filename="rep_ort__?\.pdf"/.test(r.headers.get('content-disposition')) && !/[\r\n]/.test(r.headers.get('content-disposition')), [r.status, r.headers.get('content-disposition')]);
// ================= changing things =================
await post('unate', '/api/mail/update', { id: 'M1', read: true }); ok('mark as read', ms.patched && ms.patched.isRead === true, ms.patched);
await post('unate', '/api/mail/update', { id: 'M1', flagged: true }); ok('flag', ms.patched.flag.flagStatus === 'flagged', ms.patched);
await post('unate', '/api/mail/move', { id: 'M1', to: 'archive' }); ok('archive moves to the Archive folder', ms.moved[0] === 'M1' && ms.moved[1] === 'archive', ms.moved);
await post('unate', '/api/mail/delete', { id: 'M2' }); ok('delete moves to Deleted Items', ms.moved[0] === 'M2' && ms.moved[1] === 'deleteditems', ms.moved);
await post('unate', '/api/mail/delete', { id: 'M2', permanent: true }); ok('and from Deleted Items it is removed for good', ms.deleted === 'M2', ms.deleted);
// ================= sending =================
r = await post('unate', '/api/mail/send', { to: 'bob@example.com, Ann <ann@example.com>', cc: '', subject: 'Hi', body: 'Line 1\n<b>not bold</b>' });
ok('send builds the message (several recipients, text turned into safe HTML)', r.status === 200 && ms.sent.message.toRecipients.length === 2 && ms.sent.message.toRecipients[1].emailAddress.address === 'ann@example.com' && /&lt;b&gt;not bold/.test(ms.sent.message.body.content) && ms.sent.saveToSentItems === true, ms.sent);
r = await post('unate', '/api/mail/send', { to: 'nobody', subject: 'x', body: 'y' }); ok('a bad address is refused with its name in the message', r.status === 400 && /nobody/.test(r.json.error), r.json);
r = await post('unate', '/api/mail/send', { to: '', subject: 'x', body: 'y' }); ok('no recipient is refused', r.status === 400, r.json);
r = await post('unate', '/api/mail/send', { to: 'a@b.co', subject: 'x', body: 'y', attachments: [{ name: 'big.bin', type: 'x/y', data: Buffer.alloc(3.5 * 1024 * 1024).toString('base64') }] }); ok('attachments over about 3 MB are refused', r.status === 400 && /3 MB/.test(r.json.error), r.json);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'replyAll', comment: 'Thanks' }); ok('reply all goes through the thread', r.status === 200 && ms.replied[1] === 'replyAll' && ms.replied[2].comment === 'Thanks', ms.replied);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI', to: 'x@y.org' }); ok('forward needs a recipient and sends', r.status === 200 && ms.replied[2].toRecipients[0].emailAddress.address === 'x@y.org', ms.replied);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI' }); ok('forward with no recipient is refused', r.status === 400, r.json);
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'bogus' }); ok('an unknown action is refused', r.status === 400, r.json);
// ================= tokens =================
const refreshBefore = ms.refreshCalls;
ms.goodTokens = new Set(); // Microsoft no longer accepts the access token (expired early)
r = await call('unate', '/api/mail/folders'); ok('an expired access token is refreshed automatically and the request still works', r.status === 200 && ms.refreshCalls === refreshBefore + 1, [r.status, ms.refreshCalls]);
ms.revoked = true; ms.goodTokens = new Set();
r = await call('unate', '/api/mail/messages'); ok('if Microsoft revokes access, the person is told to connect again (409)', r.status === 409 && /Connect again/.test(r.json.error), r);
r = await call('unate', '/api/mail/status'); ok('and shown as not connected', r.json.connected === false, r.json);
ok('the dead sign-in is removed from disk', !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')));
// reconnect, then disconnect
ms.revoked = false; r = await call('unate', '/api/mail/connect'); const au3 = new URL(r.json.url); ms.challenge = au3.searchParams.get('code_challenge');
await call(null, `/api/mail/callback?code=GOODCODE&state=${au3.searchParams.get('state')}`); r = await call('unate', '/api/mail/status'); ok('connecting again works', r.json.connected === true, r.json);
r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false && !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')), r.json);
r = await call('unate', '/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting, sending and disconnecting are in the activity log (without any message text)', ['mail connected', 'mail sent', 'mail disconnected'].every(e => evs.includes(e)) && !JSON.stringify(r.json).includes('not bold'), evs);
r = await call('unate', '/webmail/'); ok('the old /webmail/ pass-through is gone', r.status === 404 || r.status === 200 && !/Cypht/i.test(r.text), r.status);
ok('the server is still running', child.exitCode === null, log.slice(-300));
child.kill(); server.close(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll mail checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+3 -3
View File
@@ -1,6 +1,6 @@
const fs=require('fs'), vm=require('vm'); const fs=require('fs'), vm=require('vm');
const src=fs.readFileSync(''+require('path').join(__dirname,'..','public','app.js')+'','utf8'); const src=['music.js','player.js','lyrics-view.js'].map(f=>fs.readFileSync(require('path').join(__dirname,'..','public',f),'utf8')).join('\n');
const a=src.indexOf("const shuffled = a =>"), b=src.indexOf("// ---- People (admin)"); const a=src.indexOf("const shuffled = a =>"), b=src.length;
let code=src.slice(a,b); let code=src.slice(a,b);
const listeners={}; const listeners={};
class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration=100;this.readyState=4;this.error=null;this._l={};this.src='';} class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration=100;this.readyState=4;this.error=null;this._l={};this.src='';}
@@ -8,7 +8,7 @@ class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration=
play(){this.paused=false;this.fire('play');return Promise.resolve()} pause(){if(!this.paused){this.paused=true;this.fire('pause')}} getAttribute(){return this.src||null} load(){}} play(){this.paused=false;this.fire('play');return Promise.resolve()} pause(){if(!this.paused){this.paused=true;this.fire('pause')}} getAttribute(){return this.src||null} load(){}}
const toasts=[];const store={d:{},get(k,d){return this.d[k]??d},set(k,v){this.d[k]=v},del(k){delete this.d[k]}}; const toasts=[];const store={d:{},get(k,d){return this.d[k]??d},set(k,v){this.d[k]=v},del(k){delete this.d[k]}};
const el=()=>({remove(){},classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>el(),querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}}); const el=()=>({remove(){},classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>el(),querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}});
const ctx={Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[],querySelector:()=>null},allowed:()=>true, const ctx={sleepT:{eot:false},sleepClear(){},Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[],querySelector:()=>null},allowed:()=>true,
navigator:{onLine:true},addEventListener:(e,f)=>{listeners[e]=f},setTimeout,clearTimeout,fetch:()=>new Promise(()=>{}),URL:{createObjectURL:()=>'blob:x',revokeObjectURL(){}}, navigator:{onLine:true},addEventListener:(e,f)=>{listeners[e]=f},setTimeout,clearTimeout,fetch:()=>new Promise(()=>{}),URL:{createObjectURL:()=>'blob:x',revokeObjectURL(){}},
Image:class{},MediaMetadata:class{},openSheet:()=>({}),closeSheet(){},actionSheet(){},Response,URLSearchParams,AbortController,Promise,Set,Map,Date,Math,saveStateHook:0,location:{hash:'#/tasks'}}; Image:class{},MediaMetadata:class{},openSheet:()=>({}),closeSheet(){},actionSheet(){},Response,URLSearchParams,AbortController,Promise,Set,Map,Date,Math,saveStateHook:0,location:{hash:'#/tasks'}};
vm.createContext(ctx); vm.runInContext(code+";this.pl=pl;this.next=next;this.prev=prev;this.setShuffle=setShuffle;this.setRepeat=setRepeat;this.playQueue=playQueue;this.advance=advance;this.cur=cur;this.recover=recover;this.srcOf=srcOf;",ctx); vm.createContext(ctx); vm.runInContext(code+";this.pl=pl;this.next=next;this.prev=prev;this.setShuffle=setShuffle;this.setRepeat=setRepeat;this.playQueue=playQueue;this.advance=advance;this.cur=cur;this.recover=recover;this.srcOf=srcOf;",ctx);
+13 -1
View File
@@ -30,9 +30,10 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
rs.writeHead(404); rs.end(); rs.writeHead(404); rs.end();
}); });
// ---- fake Plex server: /identity is open; everything else needs the server token // ---- fake Plex server: /identity is open; everything else needs the server token
const seen = []; const seen = [], plexReqs = [];
const makePlex = () => listen((rq, rs) => { const makePlex = () => listen((rq, rs) => {
if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } }); if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } });
plexReqs.push(rq.method + ' ' + rq.url);
seen.push(rq.headers['x-plex-token']); seen.push(rq.headers['x-plex-token']);
if (rq.headers['x-plex-token'] !== tv.serverToken) { rs.writeHead(401); return rs.end(); } if (rq.headers['x-plex-token'] !== tv.serverToken) { rs.writeHead(401); return rs.end(); }
jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } }); jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } });
@@ -70,6 +71,17 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
r = await call('/api/plex/status'); ok('AFTER A RESTART: still signed in, same server', r.json.signedIn === true && r.json.server === 'E-va', r.json); r = await call('/api/plex/status'); ok('AFTER A RESTART: still signed in, same server', r.json.signedIn === true && r.json.server === 'E-va', r.json);
r = await call('/plex/library/sections'); ok('AFTER A RESTART: Plex pages still load', r.status === 200, r.status); r = await call('/plex/library/sections'); ok('AFTER A RESTART: Plex pages still load', r.status === 200, r.status);
// ================= playlists can be changed, nothing else can =================
plexReqs.length = 0;
r = await call('/plex/playlists?type=audio&smart=0&title=Road&uri=x', undefined, 'POST'); ok('a playlist can be created through the hub', r.status === 200 && plexReqs.includes('POST /playlists?type=audio&smart=0&title=Road&uri=x'), [r.status, plexReqs]);
r = await call('/plex/playlists/7/items?uri=x', undefined, 'PUT'); ok('and have tracks added', r.status === 200 && plexReqs.includes('PUT /playlists/7/items?uri=x'), [r.status, plexReqs]);
r = await call('/plex/playlists/7/items/55', undefined, 'DELETE'); ok('and have a track removed', r.status === 200 && plexReqs.includes('DELETE /playlists/7/items/55'), [r.status, plexReqs]);
r = await call('/plex/playlists/7', undefined, 'DELETE'); ok('and be deleted', r.status === 200 && plexReqs.includes('DELETE /playlists/7'), [r.status, plexReqs]);
plexReqs.length = 0;
const bad = [['DELETE', '/plex/library/metadata/5'], ['PUT', '/plex/library/sections/1/refresh'], ['POST', '/plex/playlists/7/../library/metadata/5'], ['DELETE', '/plex/library/sections/1']];
const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); }
ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]);
// ================= repairing itself ================= // ================= repairing itself =================
tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token
r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status); r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status);
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Push reminders: who gets told, when, and never twice. The push service itself is replaced by a recorder.
const fs = require('fs'), os = require('os'), path = require('path');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-push-'));
process.env.DATA_DIR = dir; process.env.COOKIE_SECRET = 'push-test-secret-'.repeat(3);
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const webpush = require('web-push');
const sent = []; let dead = false;
webpush.sendNotification = async (sub, payload) => { if (dead) throw Object.assign(new Error('gone'), { statusCode: 410 }); sent.push({ sub, ...JSON.parse(payload) }); };
const users = require('../users'), push = require('../push');
(async () => {
const u = users.create({ name: 'Pat', role: 'user', tabs: ['tasks', 'calendar'] });
const d = users.dirOf(u.id);
const TZ = 'America/New_York', now = new Date('2026-10-22T13:00:00Z'); // 09:00 in New York
const { day, hm } = push.localNow(TZ, now);
ok('the person\'s own clock is used (09:00 in New York at 13:00 UTC)', day === '2026-10-22' && hm === '09:00', { day, hm });
const writePush = o => fs.writeFileSync(path.join(d, 'push.json'), JSON.stringify({ tz: TZ, tasksAt: '08:00', eventMins: 15, tasks: true, events: true, subs: [{ sub: { endpoint: 'https://push.example/abc', keys: { p256dh: 'x', auth: 'y' } } }], ...o }));
fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [
{ id: 't1', list: 'inbox', title: 'Pay rent', due: day, done: false }, { id: 't2', list: 'inbox', title: 'Old thing', due: '2026-10-01', done: false },
{ id: 't3', list: 'inbox', title: 'Done thing', due: day, done: true }, { id: 't4', list: 'inbox', title: 'Later', due: '2026-12-01', done: false }] }));
writePush({}); await push.tick(new Date('2026-10-22T11:00:00Z')); // 07:00 local: too early
ok('nothing is sent before the morning time', sent.length === 0, sent);
await push.tick(now);
ok('at the morning time one summary is sent for tasks due or overdue', sent.length === 1 && /2 tasks/.test(sent[0].title) && /1 overdue/.test(sent[0].body) && !/Done thing|Later/.test(sent[0].body), sent);
await push.tick(new Date(now.getTime() + 60e3));
ok('and not again the same day', sent.length === 1, sent.length);
await push.tick(new Date(now.getTime() + 864e5));
ok('but again the next day', sent.length === 2, sent.length);
sent.length = 0; writePush({ tasks: false, subs: [] }); fs.writeFileSync(path.join(d, 'push.json'), JSON.stringify({ tz: TZ, subs: [] }));
await push.tick(new Date(now.getTime() + 2 * 864e5));
ok('a person with no device signed up is never contacted', sent.length === 0, sent);
// events: a local event starting in 10 minutes
const soon = new Date(Date.now() + 10 * 60e3), pad = n => String(n).padStart(2, '0');
const st = `${soon.getUTCFullYear()}-${pad(soon.getUTCMonth() + 1)}-${pad(soon.getUTCDate())}T${pad(soon.getUTCHours())}:${pad(soon.getUTCMinutes())}`;
fs.writeFileSync(path.join(d, 'events.json'), JSON.stringify([{ uid: 'e1', fields: { title: 'Dentist', location: 'Main St', notes: '', allDay: false, start: st, end: st.replace(/T(\d\d)/, (m, h) => 'T' + pad((+h + 1) % 24)), tz: 'UTC', repeat: 'none' }, created: 1, updated: 1 }]));
writePush({ tasks: false }); sent.length = 0;
await push.tick(new Date(Date.now() - 3 * 36e5), true);
ok('an event hours away sends nothing', sent.length === 0, sent);
await push.tick(new Date(), true);
ok('an event about to start is announced with its place', sent.some(s => s.title === 'Dentist' && /Main St/.test(s.body)), sent);
await push.tick(new Date(Date.now() + 60e3), true);
ok('and only once', sent.filter(s => s.title === 'Dentist').length === 1, sent);
// a subscription the push service says is gone is dropped
dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] }));
await push.tick(new Date());
ok('a device that has unsubscribed (410) is removed', JSON.parse(fs.readFileSync(path.join(d, 'push.json'), 'utf8')).subs.length === 0, fs.readFileSync(path.join(d, 'push.json'), 'utf8'));
fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll push checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+15 -1
View File
@@ -59,7 +59,21 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
const home = await (await fetch(base + '/', { headers: { cookie } })).text(), cfg2 = await (await fetch(base + '/api/config', { headers: { cookie } })).json(); const home = await (await fetch(base + '/', { headers: { cookie } })).text(), cfg2 = await (await fetch(base + '/api/config', { headers: { cookie } })).json();
ok('the app page links its code with the current build number, so a cached old copy can never be used', home.includes(`/app.js?v=${cfg2.build}`) && home.includes(`/style.css?v=${cfg2.build}`), home.match(/app\.js[^"]*/)); ok('the app page links its code with the current build number, so a cached old copy can never be used', home.includes(`/app.js?v=${cfg2.build}`) && home.includes(`/style.css?v=${cfg2.build}`), home.match(/app\.js[^"]*/));
const login = await (await fetch(base + '/login')).text(); ok('so does the sign-in page', login.includes(`/login.js?v=${cfg2.build}`), login.match(/login\.js[^"]*/)); const login = await (await fetch(base + '/login')).text(); ok('so does the sign-in page', login.includes(`/login.js?v=${cfg2.build}`), login.match(/login\.js[^"]*/));
const va = await fetch(base + `/app.js?v=${cfg2.build}`, { headers: { cookie } }); ok('the versioned address serves the file', va.status === 200 && (await va.text()).includes('addPersonSheet'), va.status); const va = await fetch(base + `/app.js?v=${cfg2.build}`, { headers: { cookie } }); ok('the versioned address serves the file', va.status === 200 && (await va.text()).includes('routesOf'), va.status);
// hardening: headers, cross-site writes, health check, backup, links setting
const h1 = await fetch(base + '/login'); await h1.text();
ok('responses carry security headers', h1.headers.get('x-content-type-options') === 'nosniff' && /frame-ancestors 'self'/.test(h1.headers.get('content-security-policy') || ''), [...h1.headers]);
const hz = await fetch(base + '/healthz'); ok('/healthz answers without signing in', hz.status === 200 && (await hz.json()).ok === true, hz.status);
const cs = await fetch(base + '/api/tasks/lists', { method: 'POST', headers: { cookie, origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' }); await cs.text();
ok('a write coming from another website is refused', cs.status === 403, cs.status);
const cs2 = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, 'sec-fetch-site': 'cross-site', 'content-type': 'application/json' }, body: '{"key":"links","value":[]}' }); await cs2.text();
ok('and so is one the browser marks cross-site', cs2.status === 403, cs2.status);
const sl = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, origin: base, 'content-type': 'application/json' }, body: JSON.stringify({ key: 'links', value: [{ id: 'a', title: 'T', url: 'https://example.com', group: '' }] }) }); await sl.text();
const gl = await (await fetch(base + '/api/settings', { headers: { cookie } })).json();
ok('links are saved with the settings and read back', sl.status === 200 && gl.values.links && gl.values.links[0].url === 'https://example.com', [sl.status, gl]);
const bk = await fetch(base + '/api/backup', { headers: { cookie } }); const bb = Buffer.from(await bk.arrayBuffer());
ok('an admin can download a backup (a gzip file)', bk.status === 200 && bb[0] === 0x1f && bb[1] === 0x8b, [bk.status, bb.length]);
const bn = await fetch(base + '/api/backup'); await bn.text(); ok('but not without signing in', bn.status === 401, bn.status);
ok('the server is still running after all of that', alive); ok('the server is still running after all of that', alive);
child.kill(); fs.rmSync(dir, { recursive: true, force: true }); child.kill(); fs.rmSync(dir, { recursive: true, force: true });
if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); } if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); }
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// Tasks: repeating tasks roll forward, subtasks nest one level and go away with their parent.
const { spawn } = require('child_process');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..'), dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-tasks-'));
const port = 20000 + Math.floor(Math.random() * 20000), base = `http://127.0.0.1:${port}`;
let failed = 0;
const ok = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (cond ? '' : ' -> ' + JSON.stringify(extra))); if (!cond) failed++; };
const SECRET = 'tasks-'.repeat(8), UID = 'utasks';
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] }));
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET } });
const exp = Date.now() + 864e5, cookie = `hub_session=${UID}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url')}`;
const call = async (p, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); return { status: r.status, json: await r.json().catch(() => ({})) }; };
const ymd = d => d.toISOString().slice(0, 10), plus = n => { const d = new Date(); d.setUTCDate(d.getUTCDate() + n); return ymd(d); };
const wait = ms => new Promise(r => setTimeout(r, ms));
(async () => {
for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const L = 'local:inbox', list = async () => (await call('/api/tasks?list=' + L)).json;
await call('/api/tasks', { list: L, fields: { title: 'Water plants', notes: '', due: plus(-3), done: false, repeat: 'daily' } });
await call('/api/tasks', { list: L, fields: { title: 'Plain', notes: '', due: '', done: false } });
let ts = await list(), rep = ts.find(t => t.title === 'Water plants'), plain = ts.find(t => t.title === 'Plain');
ok('a task remembers that it repeats', rep.repeat === 'daily' && plain.repeat === 'none', ts);
let r = await call('/api/tasks/toggle', { list: L, id: rep.id, done: true });
ts = await list(); rep = ts.find(t => t.title === 'Water plants');
ok('completing a repeating task moves it to the next date (after today) and keeps it open', r.json.rolled === plus(1) && rep.due === plus(1) && !rep.done, [r.json, rep]);
r = await call('/api/tasks/toggle', { list: L, id: plain.id, done: true }); ts = await list();
ok('a plain task is simply completed', ts.find(t => t.title === 'Plain').done === true && !r.json.rolled, r.json);
r = await call('/api/tasks', { list: L, id: rep.id, fields: { title: 'Water plants', notes: '', due: rep.due, done: false, repeat: 'bogus' } }); ts = await list();
ok('an unknown repeat is treated as none', ts.find(t => t.id === rep.id).repeat === 'none', ts);
await call('/api/tasks', { list: L, fields: { title: 'Sub 1', notes: '', due: '', done: false, parent: rep.id } });
ts = await list(); const sub = ts.find(t => t.title === 'Sub 1');
ok('a subtask is stored under its parent', sub && sub.parent === rep.id, ts);
r = await call('/api/tasks', { list: L, fields: { title: 'Deep', notes: '', due: '', done: false, parent: sub.id } });
ok('a subtask cannot have subtasks of its own', r.status === 400, r);
r = await call('/api/tasks', { list: L, fields: { title: 'Orphan', notes: '', due: '', done: false, parent: 'nope' } });
ok('a missing parent is refused', r.status === 400, r);
r = await call('/api/tasks/delete', { list: L, id: rep.id });
ts = await list(); ok('deleting the parent deletes its subtasks', !ts.some(t => t.id === rep.id || t.id === sub.id), ts);
ok('the server is still running', child.exitCode === null);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);
})();
+16 -3
View File
@@ -71,7 +71,7 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
await admin.call('/api/tasks', { list: 'local:inbox', fields: T('Admin private task') }); await admin.call('/api/tasks', { list: 'local:inbox', fields: T('Admin private task') });
await admin.call('/api/cal/event', { cal: 'local', fields: { title: 'Admin private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } }); await admin.call('/api/cal/event', { cal: 'local', fields: { title: 'Admin private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await admin.call('/api/settings', { key: 'mail_hints', value: { gmail: 'admin@gmail.com' } }, 'PUT'); await admin.call('/api/settings', { key: 'office_loc', value: { gmail: 'admin@gmail.com' } }, 'PUT');
// ---- add Sam with only Tasks and Calendar // ---- add Sam with only Tasks and Calendar
r = await admin.call('/api/users', { name: 'Sam', tabs: ['tasks', 'calendar', 'search'] }); const samUser = r.json.user, samCode = r.json.code; r = await admin.call('/api/users', { name: 'Sam', tabs: ['tasks', 'calendar', 'search'] }); const samUser = r.json.user, samCode = r.json.code;
@@ -80,19 +80,32 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
r = await admin.call('/api/users', { name: ' ', tabs: [] }); ok('a name is required', r.status === 400, r); r = await admin.call('/api/users', { name: ' ', tabs: [] }); ok('a name is required', r.status === 400, r);
r = await register(sam, dS, { inviteCode: samCode }, 'Sam phone', base); ok('Sam enrols with the code and is signed in as Sam', r.status === 200 && sam.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, sam.cookie.slice(0, 24)]); r = await register(sam, dS, { inviteCode: samCode }, 'Sam phone', base); ok('Sam enrols with the code and is signed in as Sam', r.status === 200 && sam.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, sam.cookie.slice(0, 24)]);
r = await register(client(base), device(), { inviteCode: samCode }, 'again', base); ok('the code works only once', r.status === 403, r); r = await register(client(base), device(), { inviteCode: samCode }, 'again', base); ok('the code works only once', r.status === 403, r);
// ---- recovery codes and the audit log
r = await sam.call('/api/auth/recovery'); ok('a person starts with no recovery codes', r.status === 200 && r.json.remaining === 0, r.json);
r = await sam.call('/api/auth/recovery', {}); const recCodes = r.json.codes; ok('making recovery codes gives 8 one-time codes', r.status === 200 && recCodes.length === 8 && recCodes.every(c => /^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(c)), r.json);
ok('and they are stored only as hashes', !fs.readFileSync(path.join(dir, 'recovery.json'), 'utf8').includes(recCodes[0]), 'plain code found on disk');
const lost = client(base);
r = await register(lost, device(), { inviteCode: recCodes[0] }, 'Sam new phone', base); ok('a recovery code enrols a new passkey and signs Sam in', r.status === 200 && lost.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, lost.cookie.slice(0, 24)]);
r = await register(client(base), device(), { inviteCode: recCodes[0] }, 'again', base); ok('the same recovery code cannot be used twice', r.status === 403, r.status);
{ const pk = (await lost.call('/api/auth/passkeys')).json.find(p => p.name === 'Sam new phone'); await lost.call('/api/auth/passkeys', { id: pk.id }, 'DELETE'); } // tidy up so later checks see Sam's original passkeys
r = await sam.call('/api/auth/recovery'); ok('seven codes remain', r.json.remaining === 7, r.json);
r = await sam.call('/api/audit'); ok('an ordinary person cannot read the audit log', r.status === 403, r.status);
r = await admin.call('/api/audit'); const evs = (r.json.events || []).map(e => e.event);
ok('the admin sees the audit log: invite use, recovery codes and recovery use are recorded', ['recovery codes created', 'recovery code used', 'passkey added with invite'].every(e => evs.includes(e)), evs);
ok('and it never contains a code', !JSON.stringify(r.json).includes(recCodes[0]) && !JSON.stringify(r.json).includes(samCode), 'code leaked');
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json); r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json);
// ---- private data // ---- private data
r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json); r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json);
await sam.call('/api/tasks', { list: 'local:inbox', fields: T('Sam private task') }); await sam.call('/api/tasks', { list: 'local:inbox', fields: T('Sam private task') });
await sam.call('/api/cal/event', { cal: 'local', fields: { title: 'Sam private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } }); await sam.call('/api/cal/event', { cal: 'local', fields: { title: 'Sam private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await sam.call('/api/settings', { key: 'mail_hints', value: { gmail: 'sam@gmail.com' } }, 'PUT'); await sam.call('/api/settings', { key: 'office_loc', value: { gmail: 'sam@gmail.com' } }, 'PUT');
const q = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07'; const q = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
const at = (await admin.call('/api/tasks?list=local:inbox')).json.map(t => t.title), st = (await sam.call('/api/tasks?list=local:inbox')).json.map(t => t.title); const at = (await admin.call('/api/tasks?list=local:inbox')).json.map(t => t.title), st = (await sam.call('/api/tasks?list=local:inbox')).json.map(t => t.title);
ok('tasks are private to each person', at.join() === 'Admin private task' && st.join() === 'Sam private task', [at, st]); ok('tasks are private to each person', at.join() === 'Admin private task' && st.join() === 'Sam private task', [at, st]);
const ae = (await admin.call(q)).json.events.map(e => e.title), se = (await sam.call(q)).json.events.map(e => e.title); const ae = (await admin.call(q)).json.events.map(e => e.title), se = (await sam.call(q)).json.events.map(e => e.title);
ok('NE Hub calendar events are private to each person', ae.join() === 'Admin private event' && se.join() === 'Sam private event', [ae, se]); ok('NE Hub calendar events are private to each person', ae.join() === 'Admin private event' && se.join() === 'Sam private event', [ae, se]);
ok('settings are private to each person', (await admin.call('/api/settings')).json.values.mail_hints.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.mail_hints.gmail === 'sam@gmail.com'); ok('settings are private to each person', (await admin.call('/api/settings')).json.values.office_loc.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.office_loc.gmail === 'sam@gmail.com');
const fa = (await admin.call('/api/cal/feed')).json.url, fs_ = (await sam.call('/api/cal/feed')).json.url; const fa = (await admin.call('/api/cal/feed')).json.url, fs_ = (await sam.call('/api/cal/feed')).json.url;
const fetchFeed = async u => { const x = await fetch(u.replace(/^https?:\/\/[^/]+/, base)); return { status: x.status, text: await x.text() }; }; const fetchFeed = async u => { const x = await fetch(u.replace(/^https?:\/\/[^/]+/, base)); return { status: x.status, text: await x.text() }; };
const ta = await fetchFeed(fa), ts = await fetchFeed(fs_); const ta = await fetchFeed(fa), ts = await fetchFeed(fs_);
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
// 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');
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('/webmail/?_task=login') && rq.method === 'POST') { rs.writeHead(401, { 'content-type': 'text/html' }); return rs.end('<p>Login failed.</p>'); }
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('<h1>the webmail</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, 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 + '; 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 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('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 (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/?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/webmail' && (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 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);
})();
+2 -2
View File
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own). // Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search']; const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const ALWAYS = ['home', 'settings']; const ALWAYS = ['home', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' }; const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
let cache = null; let cache = null;
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; }; const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };