NE Hub: Seafile, OnlyOffice, Plex, calendar and mail in one PWA
build-container / build (push) Canceled after 4m16s

- Mobile-first PWA (phone tab bar with More menu, iPad/desktop rail)
- Passkey (WebAuthn) sign-in gates the whole app; first passkey via setup code
- Files: Seafile browse/upload/new doc/rename/move/share link/preview/delete
- In-app OnlyOffice editing with JWT-signed configs and save-back to Seafile
- Plex music player via plex.tv sign-in and a server-side proxy
- Calendar agenda from private ICS links (recurrence + time zones)
- Search, Home dashboard, webmail deep links, audiobooks frame
- Dockerfile, compose, and a Gitea Actions workflow that builds the image

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 13:22:03 -04:00
commit 55e0c39171
22 files changed
+2341

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
node_modules
data
.env
.git
.gitea
sample.docx
*.md
+11
View File
@@ -0,0 +1,11 @@
# Copy to .env and fill in. Must match the Document Server's JWT secret.
ONLYOFFICE_JWT_SECRET=
# Address the OnlyOffice server can reach this app on (used for save-back callbacks), e.g. https://hub.necloud.us
PUBLIC_URL=
SEAFILE_URL=https://seafile.necloud.us
ONLYOFFICE_URL=https://onlyoffice.necloud.us
# Optional: keeps Plex/session cookies valid across restarts. Any long random string.
COOKIE_SECRET=
AUDIOBOOKS_URL=https://audiobooks.necloud.us
# First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists.
HUB_SETUP_CODE=
+44
View File
@@ -0,0 +1,44 @@
name: build-container
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
# Repo secrets needed (Settings -> Actions -> Secrets):
# REGISTRY_TOKEN a Gitea access token with write:package scope
# REGISTRY_USER optional; defaults to the user who pushed
- uses: docker/login-action@v3
with:
registry: git.necloud.us
username: ${{ secrets.REGISTRY_USER || gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: git.necloud.us/nate/hub
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: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache
cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max
+3
View File
@@ -0,0 +1,3 @@
.env
data/
node_modules/
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js auth.js cal.js ./
COPY public ./public
ENV PORT=8080 DATA_DIR=/data
RUN mkdir -p /data && chown node:node /data
VOLUME /data
EXPOSE 8080
USER node
CMD ["node", "server.js"]
+44
View File
@@ -0,0 +1,44 @@
# NE Hub
One installable web app (PWA) for Seafile, OnlyOffice and your four webmail accounts.
Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide).
## Configure
cp .env.example .env # set ONLYOFFICE_JWT_SECRET (same value as the Document Server) and PUBLIC_URL
`PUBLIC_URL` must be an address the OnlyOffice server can reach (not localhost): it posts saved edits back there.
## Run
docker compose up -d --build # http://localhost:8080
# or: node server.js
Put it behind your reverse proxy with HTTPS (needed for "Add to Home Screen" and the service worker),
e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docker-compose.yml.
## What each part does
- **Files**: signs in with your Seafile account, browses libraries, uploads, creates folders, downloads, deletes.
The server proxies `/sf/*` to Seafile because Seafile sends no CORS headers. Token lives in the browser only.
Office files open in Seafile's own OnlyOffice integration.
- **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.
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs.
- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap.
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
- **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose).
Providers forbid framing, and a real unified inbox would need Google/Azure app registrations.
## Build and deploy with Gitea
`.gitea/workflows/build.yml` builds the container on every push to `main` (and `v*` tags) and pushes it to
`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag). Multi-arch: amd64 + arm64.
One-time setup in Gitea: create an access token with the **write:package** scope, then add it to the repo under
Settings -> Actions -> Secrets as `REGISTRY_TOKEN` (optionally `REGISTRY_USER`). The runner needs Docker.
On the server:
cp .env.example .env # fill in secrets, PUBLIC_URL, etc.
docker compose pull && docker compose up -d
docker compose logs hub | grep "Setup code" # first sign-in: create your passkey
+149
View File
@@ -0,0 +1,149 @@
// Passkey (WebAuthn) sign-in that gates the whole app.
// First passkey: enrolled with a setup code (HUB_SETUP_CODE, or a random one printed to the server log).
// After that, further passkeys can only be added from an already signed-in session.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
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 writeJson = (f, v) => fs.writeFileSync(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.
let secret = process.env.COOKIE_SECRET;
if (!secret) {
secret = readJson('secret.json', {}).secret;
if (!secret) { secret = crypto.randomBytes(32).toString('hex'); writeJson('secret.json', { secret }); }
}
const store = readJson('passkeys.json', { userId: crypto.randomBytes(32).toString('base64url'), creds: [] });
const save = () => writeJson('passkeys.json', store);
const SETUP_CODE = process.env.HUB_SETUP_CODE || crypto.randomBytes(4).toString('hex');
if (!store.creds.length) console.log(`[auth] No passkeys yet. Setup code: ${SETUP_CODE}`);
const SESSION_DAYS = 30;
const mac = v => crypto.createHmac('sha256', secret).update(v).digest('base64url');
const cookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; }));
const isHttps = req => (req.headers['x-forwarded-proto'] || '') === 'https';
function isAuthed(req) {
const [exp, sig] = String(cookies(req).hub_session || '').split('.');
if (!exp || !sig) return false;
const want = mac('s' + exp);
return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > Date.now();
}
function startSession(req, res) {
const exp = Date.now() + SESSION_DAYS * 864e5;
res.setHeader('Set-Cookie', `hub_session=${exp}.${mac('s' + exp)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`);
}
const challenges = new Map(); // cid -> { challenge, kind, exp }
const newChallenge = (challenge, kind) => {
for (const [k, v] of challenges) if (v.exp < Date.now()) challenges.delete(k);
const cid = crypto.randomBytes(16).toString('base64url');
challenges.set(cid, { challenge, kind, exp: Date.now() + 5 * 60e3 });
return cid;
};
const takeChallenge = (cid, kind) => {
const c = challenges.get(cid); challenges.delete(cid);
return c && c.kind === kind && c.exp > Date.now() ? c.challenge : null;
};
const hits = new Map(); // crude per-IP throttle on anything that checks a secret
const throttled = req => {
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
const h = hits.get(ip) || { n: 0, t: Date.now() };
if (Date.now() - h.t > 60e3) { h.n = 0; h.t = Date.now(); }
h.n++; hits.set(ip, h);
return h.n > 10;
};
const rp = req => {
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
const proto = req.headers['x-forwarded-proto'] || 'http';
return { rpID: host.split(':')[0], origin: `${proto}://${host}` };
};
const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); };
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 send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); };
async function handle(req, res) {
const url = new URL(req.url, 'http://x'), p = url.pathname;
try {
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: isAuthed(req) });
if (p === '/api/auth/logout' && req.method === 'POST') {
res.setHeader('Set-Cookie', 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
return send(res, 200, { ok: true });
}
const body = req.method === 'POST' || req.method === 'DELETE' ? JSON.parse((await readBody(req)) || '{}') : {};
const { rpID, origin } = rp(req);
if (p === '/api/auth/register/options' && req.method === 'POST') {
const authed = isAuthed(req);
if (!authed) {
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
if (store.creds.length || !equalStr(body.setupCode || '', SETUP_CODE)) return send(res, 403, { error: 'Wrong setup code' });
}
const options = await generateRegistrationOptions({
rpName: 'NE Hub', rpID, userName: 'NE Hub owner', userID: Buffer.from(store.userId, 'base64url'),
attestationType: 'none',
excludeCredentials: store.creds.map(c => ({ id: c.id, transports: c.transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
});
return send(res, 200, { cid: newChallenge(options.challenge, authed ? 'reg-authed' : 'reg-setup'), options });
}
if (p === '/api/auth/register/verify' && req.method === 'POST') {
const authed = isAuthed(req);
if (!authed && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
const challenge = takeChallenge(body.cid, authed ? 'reg-authed' : 'reg-setup');
if (!challenge) return send(res, 400, { error: 'Request expired, try again' });
if (!authed && store.creds.length) return send(res, 403, { error: 'Not allowed' });
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID });
if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' });
const c = v.registrationInfo.credential;
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() });
save();
if (!authed) startSession(req, res);
return send(res, 200, { ok: true });
}
if (p === '/api/auth/login/options' && req.method === 'POST') {
if (!store.creds.length) return send(res, 400, { error: 'No passkeys set up yet' });
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
return send(res, 200, { cid: newChallenge(options.challenge, 'login'), options });
}
if (p === '/api/auth/login/verify' && req.method === 'POST') {
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
const challenge = takeChallenge(body.cid, 'login');
const cred = store.creds.find(c => c.id === body.response?.id);
if (!challenge || !cred) return send(res, 400, { error: 'Passkey not recognised' });
const v = await verifyAuthenticationResponse({
response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID,
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' });
cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save();
startSession(req, res);
return send(res, 200, { ok: true });
}
// Everything below needs a signed-in session.
if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in' });
if (p === '/api/auth/passkeys' && req.method === 'GET') {
return send(res, 200, store.creds.map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
}
if (p === '/api/auth/passkeys' && req.method === 'DELETE') {
store.creds = store.creds.filter(c => c.id !== body.id); save();
return send(res, 200, { ok: true, remaining: store.creds.length });
}
send(res, 404, { error: 'Not found' });
} catch (e) { send(res, 400, { error: e.message }); }
}
module.exports = { handle, isAuthed, secret };
+255
View File
@@ -0,0 +1,255 @@
// 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.
const fs = require('fs');
const path = require('path');
const dns = require('dns').promises;
const net = require('net');
const crypto = require('crypto');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'calendars.json');
const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return []; } };
const save = v => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(v, null, 2), { mode: 0o600 }); };
// ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses.
const isPrivate = ip => {
if (net.isIPv6(ip)) { const l = ip.toLowerCase(); return l === '::1' || l.startsWith('fc') || l.startsWith('fd') || l.startsWith('fe80') || l.startsWith('::ffff:') && isPrivate(l.slice(7)); }
const [a, b] = ip.split('.').map(Number);
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
};
async function assertPublic(u) {
if (u.protocol !== 'https:') throw new Error('Calendar links must be https (webcal:// is fine)');
const host = u.hostname.replace(/^\[|\]$/g, '');
const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true });
if (!addrs.length || addrs.some(a => isPrivate(a.address))) throw new Error('That address is not allowed');
}
async function fetchIcs(raw) {
let u = new URL(raw.replace(/^webcal:/i, 'https:'));
for (let hop = 0; hop < 4; hop++) {
await assertPublic(u);
const r = await fetch(u, { redirect: 'manual', signal: AbortSignal.timeout(12000), headers: { Accept: 'text/calendar, */*' } });
if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; }
if (!r.ok) throw new Error('Calendar server answered ' + r.status);
const text = await r.text();
if (text.length > 8e6) throw new Error('Calendar file is too large');
if (!/BEGIN:VCALENDAR/i.test(text)) throw new Error('That link is not a calendar (ICS) file');
return text;
}
throw new Error('Too many redirects');
}
const cache = new Map(); // url -> { t, text }
async function getIcs(url) {
const c = cache.get(url);
if (c && Date.now() - c.t < 10 * 60e3) return c.text;
const text = await fetchIcs(url);
cache.set(url, { t: Date.now(), text });
return text;
}
// ---- Parsing
const WIN_TZ = { 'Eastern Standard Time': 'America/New_York', 'Central Standard Time': 'America/Chicago', 'Mountain Standard Time': 'America/Denver',
'Pacific Standard Time': 'America/Los_Angeles', 'US Mountain Standard Time': 'America/Phoenix', 'Alaskan Standard Time': 'America/Anchorage',
'Hawaiian Standard Time': 'Pacific/Honolulu', 'GMT Standard Time': 'Europe/London', 'W. Europe Standard Time': 'Europe/Berlin',
'Central Europe Standard Time': 'Europe/Budapest', 'Romance Standard Time': 'Europe/Paris', 'UTC': 'UTC' };
const validTz = tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } };
const tzName = tz => (tz && (validTz(tz) ? tz : WIN_TZ[tz])) || null;
const fmts = new Map();
// Wall-clock time in a zone -> UTC ms.
function zonedToUtc(y, mo, d, h, mi, s, tz) {
const guess = Date.UTC(y, mo - 1, d, h, mi, s);
if (!tz) return guess;
let fmt = fmts.get(tz);
if (!fmt) fmts.set(tz, fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }));
const offset = t => { const p = Object.fromEntries(fmt.formatToParts(new Date(t)).map(x => [x.type, +x.value])); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - t; };
let t = guess - offset(guess);
t = guess - offset(t); // second pass settles DST edges
return t;
}
function unfold(text) { return text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/); }
function parseLine(line) {
const i = line.indexOf(':'); if (i < 0) return null;
const [name, ...params] = line.slice(0, i).split(';');
const p = {}; params.forEach(x => { const [k, v] = x.split('='); p[k.toUpperCase()] = (v || '').replace(/^"|"$/g, ''); });
return { name: name.toUpperCase(), params: p, value: line.slice(i + 1) };
}
const unesc = v => v.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1');
// "20261020T090000Z" | "20261020T090000" | "20261020" -> { allDay, y, mo, d, h, mi, s, utc, tz }
function parseDate(value, params) {
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value.trim());
if (!m) return null;
const allDay = !m[4] || params.VALUE === 'DATE';
const r = { allDay, y: +m[1], mo: +m[2], d: +m[3], h: +(m[4] || 0), mi: +(m[5] || 0), s: +(m[6] || 0), tz: m[7] ? 'UTC' : tzName(params.TZID) };
r.ms = allDay ? Date.UTC(r.y, r.mo - 1, r.d) : zonedToUtc(r.y, r.mo, r.d, r.h, r.mi, r.s, r.tz);
return r;
}
function parseDuration(v) {
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(v.trim());
if (!m) return null;
const sign = m[1] === '-' ? -1 : 1;
return sign * (((+m[2] || 0) * 7 + (+m[3] || 0)) * 864e5 + (+m[4] || 0) * 36e5 + (+m[5] || 0) * 6e4 + (+m[6] || 0) * 1e3);
}
function parseEvents(text) {
const events = []; let cur = null;
for (const line of unfold(text)) {
if (line === 'BEGIN:VEVENT') { cur = { ex: [] }; continue; }
if (line === 'END:VEVENT') { if (cur && cur.start) events.push(cur); cur = null; continue; }
if (!cur) continue;
const l = parseLine(line); if (!l) continue;
switch (l.name) {
case 'DTSTART': cur.start = parseDate(l.value, l.params); break;
case 'DTEND': cur.end = parseDate(l.value, l.params); break;
case 'DURATION': cur.dur = parseDuration(l.value); break;
case 'SUMMARY': cur.title = unesc(l.value); break;
case 'LOCATION': cur.location = unesc(l.value); break;
case 'UID': cur.uid = l.value; break;
case 'STATUS': cur.status = l.value.toUpperCase(); break;
case 'RRULE': cur.rrule = Object.fromEntries(l.value.split(';').map(x => x.split('='))); break;
case 'EXDATE': l.value.split(',').forEach(v => { const d = parseDate(v, l.params); if (d) cur.ex.push(d.allDay ? d.ms : d.ms); }); break;
case 'RECURRENCE-ID': cur.recId = parseDate(l.value, l.params); break;
}
}
return events;
}
// ---- Expansion
const DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();
function nthWeekday(y, m, dow, n) { // n>0: nth, n<0: nth from end
if (n > 0) { const first = new Date(Date.UTC(y, m - 1, 1)).getUTCDay(); const d = 1 + ((dow - first + 7) % 7) + (n - 1) * 7; return d <= daysInMonth(y, m) ? d : null; }
const dim = daysInMonth(y, m), last = new Date(Date.UTC(y, m - 1, dim)).getUTCDay();
const d = dim - ((last - dow + 7) % 7) + (n + 1) * 7; return d >= 1 ? d : null;
}
// Occurrence start dates as wall-clock {y,mo,d}, from DTSTART forward, within [..until].
function* occurrences(ev, windowStartMs, windowEndMs) {
const s = ev.start, r = ev.rrule;
const tod = { h: s.h, mi: s.mi, s: s.s };
const toMs = ({ y, mo, d }) => s.allDay ? Date.UTC(y, mo - 1, d) : zonedToUtc(y, mo, d, tod.h, tod.mi, tod.s, s.tz);
if (!r) { yield { y: s.y, mo: s.mo, d: s.d }; return; }
const interval = Math.max(1, +r.INTERVAL || 1), count = r.COUNT ? +r.COUNT : Infinity;
const until = r.UNTIL ? parseDate(r.UNTIL, {}) : null;
const byday = r.BYDAY ? r.BYDAY.split(',').map(x => { const m = /^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(x); return m && { n: m[1] ? +m[1] : 0, dow: DAYS.indexOf(m[2]) }; }).filter(Boolean) : null;
const bymd = r.BYMONTHDAY ? r.BYMONTHDAY.split(',').map(Number) : null;
const bymonth = r.BYMONTH ? r.BYMONTH.split(',').map(Number) : null;
let n = 0;
const ok = c => {
const ms = toMs(c);
return ms >= s.ms && (!until || ms <= until.ms + (until.allDay ? 864e5 - 1 : 0));
};
const emit = function* (c) { if (!ok(c)) return; if (n >= count) return 'stop'; n++; yield c; };
const startDate = Date.UTC(s.y, s.mo - 1, s.d);
// Without COUNT we can jump close to the window instead of walking from the original start.
let i0 = 0;
if (!r.COUNT && windowStartMs > s.ms) {
const dayDiff = (windowStartMs - startDate) / 864e5, monthDiff = (new Date(windowStartMs).getUTCFullYear() - s.y) * 12 + new Date(windowStartMs).getUTCMonth() - (s.mo - 1);
const steps = { DAILY: dayDiff / interval, WEEKLY: dayDiff / 7 / interval, MONTHLY: monthDiff / interval, YEARLY: monthDiff / 12 / interval }[r.FREQ] || 0;
i0 = Math.max(0, Math.floor(steps) - 2);
}
for (let i = i0; i < i0 + 5000; i++) {
let cands = [];
if (r.FREQ === 'DAILY') {
const dt = new Date(startDate + i * interval * 864e5);
cands = [{ y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }];
} else if (r.FREQ === 'WEEKLY') {
const weekStart = startDate - new Date(startDate).getUTCDay() * 864e5 + i * interval * 7 * 864e5;
const dows = byday ? byday.map(b => b.dow) : [new Date(startDate).getUTCDay()];
cands = dows.sort((a, b) => a - b).map(dow => { const dt = new Date(weekStart + dow * 864e5); return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }; });
} else if (r.FREQ === 'MONTHLY') {
const mIdx = s.mo - 1 + i * interval, y = s.y + Math.floor(mIdx / 12), mo = (mIdx % 12) + 1;
if (byday) cands = byday.map(b => b.n ? nthWeekday(y, mo, b.dow, b.n) : null).filter(Boolean).map(d => ({ y, mo, d }));
else (bymd || [s.d]).forEach(d => { const dd = d < 0 ? daysInMonth(y, mo) + d + 1 : d; if (dd >= 1 && dd <= daysInMonth(y, mo)) cands.push({ y, mo, d: dd }); });
cands.sort((a, b) => a.d - b.d);
} else if (r.FREQ === 'YEARLY') {
const y = s.y + i * interval;
const months = bymonth || [s.mo];
for (const mo of months) {
if (byday) byday.forEach(b => { const d = b.n ? nthWeekday(y, mo, b.dow, b.n) : null; if (d) cands.push({ y, mo, d }); });
else (bymd || [s.d]).forEach(d => { if (d <= daysInMonth(y, mo)) cands.push({ y, mo, d }); });
}
} else { yield { y: s.y, mo: s.mo, d: s.d }; return; }
if (bymonth && r.FREQ !== 'YEARLY') cands = cands.filter(c => bymonth.includes(c.mo));
if (!cands.length && i > 4000) return;
for (const c of cands) {
if (toMs(c) < s.ms) continue;
if (toMs(c) > windowEndMs) return;
const res = yield* emit(c);
if (res === 'stop') return;
}
if (cands.length && toMs(cands[0]) > windowEndMs) return;
if (until && cands.length && toMs(cands[0]) > until.ms + 864e5) return;
}
}
const pad = n => String(n).padStart(2, '0');
const dateStr = ms => { const d = new Date(ms); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; };
function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD, inclusive) }
const out = [];
const overrides = new Set(events.filter(e => e.recId).map(e => e.uid + '|' + e.recId.ms));
for (const ev of events) {
if (ev.status === 'CANCELLED') continue;
const s = ev.start;
const durMs = ev.end ? ev.end.ms - s.ms : ev.dur != null ? ev.dur : s.allDay ? 864e5 : 0;
const add = (startMs) => {
const endMs = startMs + durMs;
if (s.allDay) {
const sd = dateStr(startMs), ed = dateStr(Math.max(endMs, startMs + 864e5));
if (ed <= win.fd || sd > win.td) return;
out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed });
} else if (endMs >= win.from && startMs <= win.to) out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: false, start: startMs, end: endMs });
};
if (!ev.rrule || ev.recId) { add(s.ms); continue; }
let guard = 0;
for (const c of occurrences(ev, win.from - 864e5, win.to + 864e5)) {
const ms = s.allDay ? Date.UTC(c.y, c.mo - 1, c.d) : zonedToUtc(c.y, c.mo, c.d, s.h, s.mi, s.s, s.tz);
if (ev.ex.includes(ms) || overrides.has(ev.uid + '|' + ms)) continue;
add(ms);
if (++guard > 3000) break;
}
}
return out;
}
// ---- HTTP API (called by server.js, behind the passkey gate)
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 COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
async function handle(req, res) {
const u = new URL(req.url, 'http://x');
try {
if (u.pathname === '/api/cal/list') return json(res, 200, load().map(({ id, name, color }) => ({ id, name, color })));
if (u.pathname === '/api/cal/add' && req.method === 'POST') {
const { name, url } = JSON.parse(await readBody(req));
if (!url) throw new Error('Paste the calendar link');
await getIcs(url); // validates the link before saving it
const cals = load();
cals.push({ id: crypto.randomBytes(6).toString('hex'), name: String(name || 'Calendar').slice(0, 40), url, color: COLORS[cals.length % COLORS.length] });
save(cals);
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/remove' && req.method === 'POST') {
const { id } = JSON.parse(await readBody(req));
save(load().filter(c => c.id !== id));
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/events') {
const q = u.searchParams;
const win = { from: +q.get('from'), to: +q.get('to'), fd: q.get('fd'), td: q.get('td') };
if (!win.from || !win.to || !win.fd || !win.td) throw new Error('from/to required');
const all = [], errors = [];
await Promise.all(load().map(async c => {
try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, color: c.color }); }
catch (e) { errors.push(`${c.name}: ${e.message}`); }
}));
return json(res, 200, { events: all, errors });
}
json(res, 404, { error: 'Not found' });
} catch (e) { json(res, 400, { error: e.message }); }
}
module.exports = { handle, parseEvents, expand, zonedToUtc, assertPublic };
+12
View File
@@ -0,0 +1,12 @@
services:
hub:
image: git.necloud.us/nate/hub:latest # built by the Gitea workflow; `docker compose build` builds locally instead
build: .
restart: unless-stopped
ports:
- "8080:8080"
env_file: .env
volumes:
- hub-data:/data # passkeys + session secret live here
volumes:
hub-data:
+344
View File
@@ -0,0 +1,344 @@
{
"name": "project",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@simplewebauthn/server": "^14.0.3"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@peculiar/asn1-android": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.11.0.tgz",
"integrity": "sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-asym-key": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.11.0.tgz",
"integrity": "sha512-r2957WdSZle/Zt4t9og7OMQYXNJ9/3ANXVtFwLCDJuHORlBZ4AFmDt1Qee0CzyTbHQg4ZZApBGEz5/6cFsyCRg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-pkcs8": "^2.11.0",
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.0.tgz",
"integrity": "sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"@peculiar/asn1-x509-attr": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.11.0.tgz",
"integrity": "sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.11.0.tgz",
"integrity": "sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.11.0.tgz",
"integrity": "sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.11.0",
"@peculiar/asn1-pkcs8": "^2.11.0",
"@peculiar/asn1-rsa": "^2.11.0",
"@peculiar/asn1-schema": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.0.tgz",
"integrity": "sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.0.tgz",
"integrity": "sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.11.0",
"@peculiar/asn1-pfx": "^2.11.0",
"@peculiar/asn1-pkcs8": "^2.11.0",
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"@peculiar/asn1-x509-attr": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.11.0.tgz",
"integrity": "sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.0.tgz",
"integrity": "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.11.0.tgz",
"integrity": "sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.0.tgz",
"integrity": "sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509-post-quantum": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.11.0.tgz",
"integrity": "sha512-K6EnquTX3xJ2G+uLPFH0jVkh7W8y6eTzoCxvSoXAj0sWS5uvVB2PEvpBPdzY4+ytaedUM3x9ZVgX0AZKb316ew==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-asym-key": "^2.11.0",
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.2.0.tgz",
"integrity": "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.11.0",
"@peculiar/asn1-csr": "^2.11.0",
"@peculiar/asn1-ecc": "^2.11.0",
"@peculiar/asn1-pkcs9": "^2.11.0",
"@peculiar/asn1-rsa": "^2.11.0",
"@peculiar/asn1-schema": "^2.11.0",
"@peculiar/asn1-x509": "^2.11.0",
"@peculiar/asn1-x509-post-quantum": "^2.11.0",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@simplewebauthn/server": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",
"integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
"@peculiar/x509": "^2.1.0",
"reflect-metadata": "^0.2.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/asn1js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",
"integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.2.0",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "ne-hub",
"version": "1.0.0",
"description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@simplewebauthn/server": "^14.0.3"
}
}
+840
View File
@@ -0,0 +1,840 @@
'use strict';
// If the server says the passkey session is gone, go back to the sign-in page.
const _fetch = window.fetch.bind(window);
window.fetch = async (...a) => {
const r = await _fetch(...a);
if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login');
return r;
};
const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
del(k) { try { localStorage.removeItem(k); } catch {} },
};
const ICONS = {
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
files: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
doc: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5M10 13h6M10 17h6"/>',
cog: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
chev: '<path d="M9 6l6 6-6 6"/>',
back: '<path d="M15 6l-6 6 6 6"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/>',
up: '<path d="M12 16V4M7 9l5-5 5 5M5 20h14"/>',
newdir: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2zM12 10v6M9 13h6"/>',
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
dl: '<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>',
audio: '<path d="M4 14v-2a8 8 0 0116 0v2"/><rect x="3" y="14" width="4" height="7" rx="1.5"/><rect x="17" y="14" width="4" height="7" rx="1.5"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
prev: '<path d="M18 5L8 12l10 7zM6 5v14"/>',
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
};
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
// ---- Email: providers block iframes and need OAuth apps for API access, so we deep-link to webmail.
const MAIL = [
{ id: 'gmail', name: 'Gmail', color: '#d9453a', inbox: 'https://mail.google.com/mail/u/0/#inbox', compose: 'https://mail.google.com/mail/?view=cm&fs=1' },
{ id: 'outlook', name: 'Outlook.com', color: '#0a64c8', inbox: 'https://outlook.live.com/mail/0/inbox', compose: 'https://outlook.live.com/mail/0/deeplink/compose' },
{ id: 'm365', name: 'Microsoft 365', color: '#d83b01', inbox: 'https://outlook.office.com/mail/inbox', compose: 'https://outlook.office.com/mail/deeplink/compose' },
{ id: 'icloud', name: 'iCloud Mail', color: '#3b97e8', inbox: 'https://www.icloud.com/mail', compose: 'https://www.icloud.com/mail' },
];
// Saved account addresses (browser-only, no passwords) are passed as sign-in hints so the provider opens the right mailbox.
const mailHints = () => store.get('mail_hints', {});
function mailUrl(m, kind) {
const base = m[kind], hint = (mailHints()[m.id] || '').trim();
if (!hint) return base;
const u = new URL(base), q = encodeURIComponent(hint);
if (m.id === 'gmail') { if (u.pathname.startsWith('/mail/u/')) u.pathname = '/mail/'; u.searchParams.set('authuser', hint); return u.toString(); }
if (m.id === 'outlook' || m.id === 'm365') { u.searchParams.set('login_hint', hint); return u.toString(); }
return base; // iCloud has no sign-in hint
}
let cfg = { audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
// ---- Seafile client (via same-origin proxy)
const sf = {
token: () => store.get('sf_token'),
async req(path, opts = {}) {
const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) };
const r = await fetch('/sf' + path, { ...opts, headers });
if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); }
if (!r.ok) throw new Error((await r.text().catch(() => '')).slice(0, 200) || 'Request failed (' + r.status + ')');
const t = await r.text();
try { return JSON.parse(t); } catch { return t; }
},
async login(username, password, otp) {
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (otp) headers['X-SEAFILE-OTP'] = otp;
const r = await fetch('/sf/api2/auth-token/', { method: 'POST', headers, body: new URLSearchParams({ username, password }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
if (r.headers.get('x-seafile-otp') === 'required') throw Object.assign(new Error('Enter your 2FA code'), { otp: true });
throw new Error((j.non_field_errors || [j.detail || j.error || 'Sign in failed']).join(' '));
}
store.set('sf_token', j.token);
},
};
const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2600); };
const ext = n => (n.split('.').pop() || '').toLowerCase();
const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']);
const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']);
const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1073741824).toFixed(1) + ' GB';
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const phone = matchMedia('(max-width: 767px)');
let activeTab = 'home';
function renderNav(active = activeTab) {
activeTab = active;
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span></a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
if (phone.matches) {
const more = !PRIMARY.includes(active);
$('#nav').innerHTML = TABS.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
`<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>`;
$('#more').onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + TABS.map(tab).join('');
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0])).map(([id, label]) => `
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${icon('chev', 'chev')}</a>`).join('')}</div>`);
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
let editor;
function route() {
const [, id = 'home', ...rest] = location.hash.split('/');
const name = routes[id] ? id : 'home';
try { editor?.destroyEditor(); } catch {}
editor = null;
document.body.classList.toggle('editing', name === 'edit');
renderNav(name === 'edit' ? 'files' : name);
routes[name](rest.map(decodeURIComponent));
$('#view').scrollTop = 0;
}
function tile(href, color, ic, title, sub, external) {
return `<a class="tile" href="${esc(href)}" ${external ? 'target="_blank" rel="noopener"' : ''}>
<span class="ic" style="background:${color}">${icon(ic)}</span><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
}
// ---- Views
function viewHome() {
const h = new Date().getHours();
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
const recents = store.get('recents', []).slice(0, 5);
const t = typeof cur === 'function' ? cur() : null;
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
<a class="searchbar" href="#/search">${icon('search')}<span>Search files, music, libraries</span></a>
${t ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
<div id="agenda"></div>
${recents.length ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
<h2>Apps</h2><div class="grid">
${tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile')}
${tile('#/music', '#e5a00d', 'music', 'Music', 'Plex')}
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
</div>
<h2>Mail</h2><div class="grid">
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
</div></div>`;
const np = $('#hnp'); if (np) np.onclick = openNow;
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
});
if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
}
// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
function viewSearch() {
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Files, libraries, artists, albums, tracks" autocomplete="off" autocapitalize="off">
<div id="res"></div></div>`;
const q = $('#q'); q.focus();
let timer, seq = 0;
q.oninput = () => { clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
async function runSearch(term, mySeq) {
const res = $('#res');
if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
const enc = encodeURIComponent;
const [files, libs, music] = await Promise.allSettled([
sf.token() ? sf.req(`/api2/search/?q=${enc(term)}&per_page=20`) : Promise.reject(new Error('signed out')),
sf.token() ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')),
jfetch('/api/plex/status').then(st => st.server ? px.get(`/hubs/search?query=${enc(term)}&limit=8`) : Promise.reject(new Error('no server'))),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
const lower = term.toLowerCase();
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 fileHtml = files.status === 'fulfilled' ? (files.value.results || []).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 = '';
if (music.status === 'fulfilled') {
for (const hub of music.value.Hub || []) for (const m of hub.Metadata || []) {
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 note = files.status === 'rejected' && sf.token() ? '<p class="sub">File search is not available on your Seafile server (it needs Seafile\'s search index). Libraries and music still work.</p>' : '';
res.innerHTML = sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note || '<p class="empty">No results</p>';
if (!libHtml && !fileHtml && !musicHtml) res.innerHTML = '<p class="empty">No results</p>' + note;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, d] = JSON.parse(b.dataset.f); openFile(r, p, n, d); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
}
}
// ---- 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 loadAgenda(days) {
const start = new Date(); start.setHours(0, 0, 0, 0);
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 fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evRow = e => `<div class="row"><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)}</small></span></div>`;
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'); if (!cals.length) return;
const { byDay } = await loadAgenda(1);
const list = byDay.get(ymd(new Date())) || [];
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 {}
}
async function viewCalendar() {
const range = store.get('cal_range', 7);
const v = $('#view');
v.innerHTML = `<div class="wrap"><h1>Calendar</h1><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>
<div id="ag"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
v.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
$('#mc').onclick = manageCalendars;
try {
const cals = await jfetch('/api/cal/list');
if (!cals.length) { $('#ag').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; }
const { byDay, errors } = await loadAgenda(range);
if (!location.hash.startsWith('#/calendar')) return;
const keys = [...byDay.keys()].sort();
$('#ag').innerHTML = (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('');
} catch (e) { $('#ag').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
function addCalendar() {
const el = openSheet('Add a calendar', '', `<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. iCloud: calendar's Share → Public Calendar → copy link. 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 = await jfetch('/api/cal/list');
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">${cals.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span>
<button class="btn danger" data-id="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('') || '<div class="row"><small>No calendars</small></div>'}</div>
<div class="bar"><button class="btn" id="ac" style="flex:1;justify-content:center">Add a calendar</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#ac').onclick = addCalendar;
el.querySelectorAll('button[data-id]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); closeSheet(); viewCalendar(); });
}
function viewMail() {
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
<p class="sub">Opens each provider's own webmail in a new tab. Nothing is stored here.</p>
<h2>Inboxes</h2><div class="list">${MAIL.map(m => `
<a class="row" href="${esc(mailUrl(m, 'inbox'))}" target="_blank" rel="noopener">
<span class="ic" style="background:${m.color}">${icon('mail')}</span>
<span class="grow"><b>${esc(m.name)}</b><small>Inbox</small></span>${icon('chev', 'chev')}</a>`).join('')}</div>
<h2>Compose</h2><div class="list">${MAIL.map(m => `
<a class="row" href="${esc(mailUrl(m, 'compose'))}" target="_blank" rel="noopener">
<span class="ic" style="background:${m.color}">${icon('edit')}</span>
<span class="grow"><b>New message</b><small>from ${esc(m.name)}</small></span>${icon('chev', 'chev')}</a>`).join('')}</div></div>`;
}
function viewOffice() {
const url = cfg.onlyoffice;
$('#view').innerHTML = `<div class="wrap"><h1>Office</h1>
<p class="sub">OnlyOffice at ${esc(new URL(url).host)}. To edit a file from Seafile, open it in Files.</p>
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a>
<a class="btn ghost" href="#/files">Go to Files</a></div>
<iframe class="frame" src="${esc(url)}" title="OnlyOffice" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
<p class="sub" style="margin-top:8px">If the frame above is blank, your OnlyOffice server blocks embedding. Use the button, or allow this app's origin in its frame-ancestors / X-Frame-Options.</p></div>`;
}
// ---- 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: '' });
function viewAudio() {
const url = cfg.audiobooks, c = abCreds();
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
<p class="sub">${esc(new URL(url).host)}</p>
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a>
${c.u ? `<button class="btn ghost" id="cu">${icon('copy')}Username</button>` : ''}
${c.p ? `<button class="btn ghost" id="cp">${icon('copy')}Password</button>` : ''}
${c.u || c.p ? '' : '<a class="btn ghost" href="#/settings">Save sign-in</a>'}</div>
<iframe class="frame" src="${esc(url)}" title="Audiobooks" allow="autoplay; fullscreen; picture-in-picture; clipboard-write"></iframe></div>`;
const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } };
const cu = $('#cu'), cp = $('#cp');
if (cu) cu.onclick = () => copy(c.u, 'Username');
if (cp) cp.onclick = () => copy(c.p, 'Password');
}
// ---- 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 = {
sec: 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==',
async section() {
if (px.sec) return px.sec;
const d = (await px.get('/library/sections')).Directory || [];
const m = d.find(x => x.type === 'artist');
if (!m) throw new Error('No music library found on this Plex server');
return (px.sec = m.key);
},
};
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);
} 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>`;
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.sec = null; route(); }
catch (e) { b.disabled = false; $('#pe').textContent = e.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) {
const v = $('#view'), sec = await px.section();
const tabs = ['albums', 'artists', 'playlists'];
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)}</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>`;
$('#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 === '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 => mapTrack(t, mc)).filter(Boolean);
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></div>
<div class="list">${tracks.map((t, i) => `<button class="row" data-i="${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>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
v.querySelectorAll('button.row').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.i));
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0);
markPlaying();
return;
}
location.hash = '#/music/albums';
}
// ---- 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() };
const cur = () => pl.q[pl.i];
function playQueue(q, i = 0) { pl.q = q; pl.i = i; loadCur(); }
function loadCur() {
const t = cur(); if (!t) return;
pl.a.src = t.src; pl.a.play().catch(() => toast('Tap play to start'));
if ('mediaSession' in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
renderMini(); syncUI(); markPlaying();
}
const next = () => { if (pl.i < pl.q.length - 1) { pl.i++; loadCur(); } else { pl.a.pause(); } };
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else { pl.i--; loadCur(); } };
const toggle = () => (pl.a.paused ? pl.a.play() : pl.a.pause());
pl.a.addEventListener('ended', next);
['play', 'pause', 'timeupdate', 'loadedmetadata'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('error', () => toast('Could not play this track'));
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 {}
}
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"><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>
<button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button></div>`;
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mo').onclick = openNow;
}
function syncUI() {
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
const ic = icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== String(a.paused)) { mt.innerHTML = ic; mt.dataset.s = a.paused; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const nt = $('#npt'); if (nt && nt.dataset.s !== String(a.paused)) { nt.innerHTML = ic; nt.dataset.s = a.paused; }
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);
}
function openNow() {
const t = cur(); if (!t) return;
const el = openSheet('', '', `<div class="np"><img class="art" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0">${esc(t.title)}</h3>
<p class="sub" 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>`);
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('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
syncUI();
}
function viewSettings() {
const signed = !!sf.token();
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
<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 danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Connections</h2><div class="list">
<div class="row"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row"><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"><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>' : ''}
<h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Stored only in this browser. Passwords are never saved here.</p>
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></div>
<h2>Audiobooks sign-in</h2><p class="sub">Saved only in this browser, in plain text, so anyone with access to this device can read it. Leave blank to skip. Use the copy buttons on the Audio 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>
<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
});
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
$('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb;
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'; });
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); }
});
$('#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(); };
}
// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
async function viewEdit([repo, ...parts]) {
const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
const back = '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
const v = $('#view');
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}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 (!location.hash.startsWith('#/edit/')) 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>`;
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));
$('#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); }
}
async function loadDir(repo, dir) {
const main = $('#fmain');
const enc = encodeURIComponent;
const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
const segs = dir.split('/').filter(Boolean);
const crumbs = [`<button onclick="location.hash='#/files/${enc(repo)}'">Library</button>`].concat(segs.map((s, i) =>
`<span>/</span><button onclick="location.hash='${hash('/' + segs.slice(0, i + 1).join('/'))}'">${esc(s)}</button>`)).join('');
main.innerHTML = `<div class="bar">
<a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
<label class="btn ghost" style="margin:0;color:var(--text)">${icon('up')}Upload<input id="up" type="file" multiple hidden></label>
<button class="btn ghost" id="mk">${icon('newdir')}New…</button></div>
<div class="crumbs">${crumbs}</div><div id="items"><p class="empty">Loading…</p></div>`;
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: { 'Content-Type': 'application/x-www-form-urlencoded' } }); loadDir(repo, dir); }
catch (e) { toast(e.message); }
};
$('#mk').onclick = () => actionSheet('Create new', '', [
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir')) },
...[['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 name = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
await create(joinP(dir, name), 'create');
if (EDIT_EXT.has(x) && cfg.editor && x !== 'txt') openFile(repo, joinP(dir, name), name, dir);
}) })),
]);
$('#up').onchange = async e => {
const files = [...e.target.files]; if (!files.length) return;
try {
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const u = new URL(link, cfg.seafile);
for (const f of files) {
toast('Uploading ' + f.name + '…');
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', f);
const r = await fetch('/sf' + u.pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload failed: ' + f.name);
}
toast('Uploaded'); loadDir(repo, dir);
} catch (err) { toast(err.message); }
};
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
$('#items').innerHTML = items.length ? `<div class="list">${items.map((it, i) => it.type === 'dir'
? `<div class="row it"><a class="rmain" href="${hash(joinP(dir, it.name))}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtDate(it.mtime)}</small></span></a><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`
: `<div class="row it"><button class="rmain" data-open="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtSize(it.size)} · ${fmtDate(it.mtime)}</small></span></button><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`).join('')}</div>`
: '<p class="empty">This folder is empty</p>';
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
$('#items').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => itemSheet(items[b.dataset.i]));
$('#items').querySelectorAll('button[data-open]').forEach(b => b.onclick = () => openFile(repo, joinP(dir, items[b.dataset.open].name), items[b.dataset.open].name, dir));
} catch (e) { $('#items').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
// ---- 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 = '') {
const el = openSheet(title, '', `<form><label>${esc(label)}</label><input type="text" id="tv" autocapitalize="off" autocomplete="off" required value="${esc(initial)}">
<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) { 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(); };
}
// ---- 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('/').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 renameItem(repo, path, name, dir, isDir) {
textSheet('Rename', 'New name', 'Rename', async n => {
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'); loadDir(repo, dir); }
catch (e) { toast(e.message); }
}, name);
}
// Folder picker for Move (same library).
function moveItem(repo, name, dir) {
let at = dir;
const el = openSheet('Move to…', name, '<div id="pf"></div>');
const draw = async () => {
const box = el.querySelector('#pf');
box.innerHTML = '<p class="empty">Loading…</p>';
try {
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(at)}`)).filter(x => x.type === 'dir' && joinP(at, x.name) !== joinP(dir, name));
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(at)}</p><div class="list" style="background:var(--panel2);border:0;max-height:40dvh;overflow:auto">
${at !== '/' ? `<button class="row" data-up="1">${icon('back')}<span class="grow"><b>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="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="mv" style="flex:1;justify-content:center"${at === dir ? ' disabled' : ''}>Move here</button></div>`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { at = joinP(at, b.dataset.d); draw(); });
const up = box.querySelector('[data-up]'); if (up) up.onclick = () => { at = at.slice(0, at.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#sx').onclick = closeSheet;
box.querySelector('#mv').onclick = async () => {
try { await sf.req('/api/v2.1/repos/sync-batch-move-item/', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src_repo_id: repo, src_parent_dir: dir, dst_repo_id: repo, dst_parent_dir: at, src_dirents: [name] }) });
closeSheet(); toast('Moved'); loadDir(repo, dir); } catch (e) { toast(e.message); }
};
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
draw();
}
function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = [];
if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
if (cfg.editor && EDIT_EXT.has(ext(name))) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').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: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
acts.push({ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) });
acts.push({ label: 'Download', icon: 'dl', run: async () => {
try { const url = await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}&reuse=1`); window.open(url, '_blank', 'noopener'); }
catch (e) { toast(e.message); } } });
acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () =>
confirmSheet('Delete file?', name, 'Delete', async () => {
try { await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
catch (e) { toast(e.message); } }) });
actionSheet(name, '', acts);
}
function dirSheet(repo, path, name, dir) {
actionSheet(name, 'Folder', [
{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) },
{ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) },
{ label: 'Delete', icon: 'trash', danger: true, run: () => confirmSheet('Delete folder?', name + ' and everything in it', 'Delete', async () => {
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
catch (e) { toast(e.message); } }) },
]);
}
// ---- Boot
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
addEventListener('hashchange', route);
route();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#4f7cff"/><g fill="#fff"><rect x="120" y="120" width="120" height="120" rx="28"/><rect x="272" y="120" width="120" height="120" rx="28" opacity=".7"/><rect x="120" y="272" width="120" height="120" rx="28" opacity=".7"/><rect x="272" y="272" width="120" height="120" rx="28"/></g></svg>

After

Width:  |  Height:  |  Size: 385 B

+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NE Hub">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg">
<link rel="apple-touch-icon" href="/icon.svg">
<link rel="stylesheet" href="/style.css">
<title>NE Hub</title>
</head>
<body>
<div id="shell">
<nav id="nav" aria-label="Main"></nav>
<div id="main"><main id="view"></main><div id="player" hidden></div></div>
</div>
<div id="toast" role="status"></div>
<script src="/webauthn.js"></script>
<script src="/app.js"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<link rel="icon" href="/icon.svg">
<link rel="apple-touch-icon" href="/icon.svg">
<link rel="stylesheet" href="/style.css">
<title>Sign in · NE Hub</title>
<style>
body { display: grid; place-items: center; min-height: 100dvh; padding: 24px; }
.box { width: 100%; max-width: 380px; text-align: center; }
.box img { width: 72px; height: 72px; border-radius: 18px; margin-bottom: 16px; }
.box .btn { width: 100%; justify-content: center; min-height: 52px; font-size: 17px; }
.box form { text-align: left; }
</style>
</head>
<body>
<div class="box">
<img src="/icon.svg" alt="">
<h1>NE Hub</h1>
<p class="sub" id="sub">&nbsp;</p>
<div id="ui"></div>
<div class="err" id="err" role="alert"></div>
</div>
<script src="/webauthn.js"></script>
<script src="/login.js"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
const $ = s => document.querySelector(s);
const fail = e => { $('#err').textContent = e && e.name === 'NotAllowedError' ? 'Cancelled. Try again.' : (e.message || String(e)); };
async function signIn() {
$('#err').textContent = '';
try { await passkeyLogin(); location.replace('/'); } catch (e) { fail(e); }
}
async function init() {
if (!window.PublicKeyCredential) { $('#sub').textContent = ''; return fail(new Error('This browser does not support passkeys.')); }
const st = await (await fetch('/api/auth/state')).json();
if (st.authed) return location.replace('/');
if (st.registered) {
$('#sub').textContent = 'Sign in with your passkey';
$('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>';
$('#go').onclick = signIn;
signIn(); // prompt straight away; the button is there if it's dismissed
} else {
$('#sub').textContent = 'First-time setup: create a passkey for this device';
$('#ui').innerHTML = `<form id="f"><label for="sc">Setup code (from the server log or HUB_SETUP_CODE)</label>
<input type="text" id="sc" autocomplete="off" autocapitalize="off" 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">
<div class="bar"><button class="btn">Create passkey</button></div></form>`;
$('#f').onsubmit = async e => {
e.preventDefault(); $('#err').textContent = '';
try { await passkeyRegister($('#sc').value.trim(), $('#nm').value.trim() || 'Passkey'); location.replace('/'); } catch (err) { fail(err); }
};
}
}
init().catch(fail);
+11
View File
@@ -0,0 +1,11 @@
{
"name": "NE Hub",
"short_name": "NE Hub",
"start_url": "/",
"display": "standalone",
"background_color": "#0f1115",
"theme_color": "#0f1115",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+193
View File
@@ -0,0 +1,193 @@
:root {
--bg: #f5f6f8; --panel: #fff; --panel2: #eef0f4; --text: #14171c; --muted: #6a7280;
--line: #e0e3e9; --accent: #3d6bf0; --accent-t: #fff; --danger: #d4392f;
--nav-h: 58px; --rail-w: 76px;
--sat: env(safe-area-inset-top); --sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left); --sar: env(safe-area-inset-right);
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0f1115; --panel: #181b21; --panel2: #20242c; --text: #eceef2; --muted: #8b93a3; --line: #272c35; --accent: #6a8cff; --accent-t: #0b1020; --danger: #ff6b60; }
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }
/* Mobile first: content on top, tab bar at the bottom */
#shell { display: flex; flex-direction: column; height: 100dvh; }
#view { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
padding: calc(var(--sat) + 16px) calc(var(--sar) + 16px) 24px calc(var(--sal) + 16px); }
#nav { order: 2; display: flex; background: var(--panel); border-top: 1px solid var(--line);
padding: 0 var(--sar) var(--sab) var(--sal); }
#nav a { flex: 1; min-height: var(--nav-h); display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; font-size: 11px; color: var(--muted); }
#nav a.on { color: var(--accent); }
.brand { display: none; }
h1 { font-size: 28px; margin: 0 0 4px; letter-spacing: -.02em; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 28px 0 10px; }
.sub { color: var(--muted); margin: 0 0 8px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, 1fr); }
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px;
display: flex; flex-direction: column; gap: 10px; min-height: 108px; text-align: left; }
.tile:active, .row:active { background: var(--panel2); }
.tile .ic { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; color: #fff; }
.tile b { font-weight: 600; }
.tile small { color: var(--muted); font-size: 13px; word-break: break-all; }
.list { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
.row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; min-height: 56px; width: 100%;
background: none; border: 0; border-bottom: 1px solid var(--line); text-align: left; }
.row:last-child { border-bottom: 0; }
.row .grow { flex: 1; min-width: 0; }
.row .grow b, .row .grow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row small { color: var(--muted); }
.row .ic { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; color: #fff; flex: none; }
.chev { color: var(--muted); width: 18px; height: 18px; }
.btn { background: var(--accent); color: var(--accent-t); border: 0; border-radius: 12px; padding: 0 16px;
min-height: 44px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
.btn.ghost { background: var(--panel2); color: var(--text); }
.btn.danger { background: transparent; color: var(--danger); border: 1px solid var(--line); }
.bar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
[hidden] { display: none !important; }
input[type=text], input[type=password], input[type=url], input[type=email] { width: 100%; min-height: 48px; padding: 0 14px;
border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; }
label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 18px; max-width: 460px; }
.err { color: var(--danger); margin-top: 10px; }
.crumbs { display: flex; gap: 6px; align-items: center; overflow-x: auto; white-space: nowrap; margin: 8px 0 12px; scrollbar-width: none; }
.crumbs button { background: none; border: 0; padding: 6px 4px; color: var(--accent); min-height: 36px; }
.crumbs span { color: var(--muted); }
.empty { color: var(--muted); padding: 32px 16px; text-align: center; }
.frame { width: 100%; height: calc(100dvh - var(--nav-h) - var(--sat) - var(--sab) - 150px); min-height: 360px;
border: 1px solid var(--line); border-radius: 16px; background: var(--panel); }
/* Files: master/detail */
.files { display: block; }
.files .libs { display: block; }
.files.open .libs { display: none; }
.files .main { display: none; }
.files.open .main { display: block; }
.backbtn { display: inline-flex; }
#toast { position: fixed; left: 50%; bottom: calc(var(--nav-h) + var(--sab) + 16px); transform: translate(-50%, 20px);
background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 12px; opacity: 0; pointer-events: none;
transition: .2s; max-width: 90vw; z-index: 10; }
#toast.on { opacity: 1; transform: translate(-50%, 0); }
/* Tablet / iPad (incl. 12.9" portrait 1024 + landscape 1366) and desktop: side rail */
@media (min-width: 768px) {
#shell { flex-direction: row; }
#nav { order: 0; flex-direction: column; width: var(--rail-w); border-top: 0; border-right: 1px solid var(--line);
padding: calc(var(--sat) + 12px) 0 12px var(--sal); gap: 4px; }
#nav a { flex: none; min-height: 64px; }
.brand { display: block; width: 36px; height: 36px; margin: 4px auto 12px; }
.brand img { width: 36px; height: 36px; border-radius: 10px; display: block; }
.brand span { display: none; }
#nav .spacer { flex: 1; }
#view { padding: calc(var(--sat) + 28px) 32px 40px; }
.grid { grid-template-columns: repeat(3, 1fr); gap: 16px; }
#toast { bottom: 24px; }
.frame { height: calc(100dvh - 190px); }
}
@media (min-width: 1024px) {
:root { --rail-w: 220px; }
#nav { padding: calc(var(--sat) + 16px) 12px 16px; }
#nav a { flex-direction: row; justify-content: flex-start; gap: 12px; padding: 0 14px; border-radius: 12px; font-size: 15px; min-height: 48px; }
#nav a.on { background: var(--panel2); }
.brand { display: flex; align-items: center; gap: 10px; width: auto; height: auto; margin: 0 0 16px 6px; font-weight: 700; font-size: 18px; }
.brand span { display: inline; }
.brand img { width: 32px; height: 32px; border-radius: 9px; }
.grid { grid-template-columns: repeat(4, 1fr); }
.files { display: grid; grid-template-columns: 300px 1fr; gap: 24px; align-items: start; }
.files .libs, .files.open .libs, .files .main, .files.open .main { display: block; }
.backbtn { display: none; }
}
@media (hover: hover) { .tile:hover, .row:hover { background: var(--panel2); } }
/* Bottom sheet (mobile) / centered dialog (tablet+) */
#sheet { position: fixed; inset: 0; z-index: 20; }
#sheet .scrim { position: absolute; inset: 0; background: rgba(0,0,0,.5); opacity: 0; transition: opacity .2s; }
#sheet .sheet { position: absolute; left: 0; right: 0; bottom: 0; background: var(--panel); border-radius: 20px 20px 0 0;
padding: 8px 16px calc(var(--sab) + 16px); transform: translateY(100%); transition: transform .22s ease; max-height: 85dvh; overflow-y: auto; }
#sheet.on .scrim { opacity: 1; }
#sheet.on .sheet { transform: none; }
#sheet .grip { width: 40px; height: 4px; border-radius: 2px; background: var(--line); margin: 4px auto 12px; }
#sheet h3 { margin: 0 0 4px; font-size: 18px; word-break: break-all; }
#sheet .list { background: var(--panel2); border: 0; margin-top: 8px; }
#sheet .row.dng { color: var(--danger); }
@media (min-width: 768px) {
#sheet .sheet { left: 50%; right: auto; bottom: auto; top: 50%; width: 420px; border-radius: 20px;
padding: 20px; transform: translate(-50%, -46%); opacity: 0; transition: .18s; }
#sheet.on .sheet { transform: translate(-50%, -50%); opacity: 1; }
#sheet .grip { display: none; }
}
/* Full-bleed editor: hide the tab bar/rail so the editor gets the screen */
body.editing #nav { display: none; }
body.editing #view { padding: 0; display: flex; flex-direction: column; }
.edbar { display: flex; align-items: center; gap: 12px; padding: calc(var(--sat) + 8px) calc(var(--sar) + 12px) 8px calc(var(--sal) + 12px); background: var(--panel); border-bottom: 1px solid var(--line); }
.edbar b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.edwrap { flex: 1; min-height: 0; position: relative; }
.edwrap iframe, .edwrap #oo { width: 100%; height: 100%; border: 0; }
/* Music */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.seg { display: flex; gap: 6px; margin: 12px 0; overflow-x: auto; }
.seg a { padding: 8px 16px; border-radius: 999px; background: var(--panel2); color: var(--muted); white-space: nowrap; min-height: 40px; display: flex; align-items: center; }
.seg a.on { background: var(--accent); color: var(--accent-t); font-weight: 600; }
.agrid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.acard { display: block; min-width: 0; }
.acard .art { width: 100%; aspect-ratio: 1; border-radius: 12px; background: var(--panel2); object-fit: cover; display: block; }
.acard.round .art { border-radius: 50%; }
.acard b, .acard small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 6px; }
.acard small { color: var(--muted); margin-top: 0; font-size: 13px; }
.mhead { display: flex; gap: 16px; align-items: flex-end; margin-bottom: 8px; }
.mhead .art { width: 38vw; max-width: 200px; aspect-ratio: 1; border-radius: 14px; background: var(--panel2); object-fit: cover; flex: none; }
.mhead h1 { font-size: 24px; word-break: break-word; }
.num { width: 28px; text-align: right; color: var(--muted); flex: none; font-variant-numeric: tabular-nums; }
.row.playing b { color: var(--accent); }
input[type=search] { width: 100%; min-height: 44px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; }
#player { background: var(--panel); border-top: 1px solid var(--line); }
#player[hidden] { display: none; }
.mini { display: flex; align-items: center; gap: 10px; padding: 8px 12px 8px calc(var(--sal) + 12px); position: relative; }
.mini .art { width: 44px; height: 44px; border-radius: 8px; background: var(--panel2); object-fit: cover; flex: none; }
.mini .meta { flex: 1; min-width: 0; text-align: left; background: none; border: 0; padding: 0; }
.mini .meta b, .mini .meta small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mini .meta small { color: var(--muted); }
.ibtn { background: none; border: 0; width: 44px; height: 44px; display: grid; place-items: center; border-radius: 50%; }
.ibtn:active { background: var(--panel2); }
.ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); }
.ibtn.big svg { width: 30px; height: 30px; }
.mini .bar2 { position: absolute; left: 0; right: 0; top: 0; height: 2px; background: var(--line); }
.mini .bar2 i { display: block; height: 100%; background: var(--accent); width: 0; }
.np { text-align: center; }
.np .art { width: min(72vw, 300px); aspect-ratio: 1; border-radius: 16px; object-fit: cover; background: var(--panel2); margin: 8px auto 16px; display: block; }
.np input[type=range] { width: 100%; accent-color: var(--accent); }
.np .times { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; }
.np .ctl { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 8px; }
/* File rows with an actions button */
.row.it { padding: 4px 4px 4px 16px; }
.row.it .rmain { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; background: none; border: 0; padding: 8px 0; text-align: left; min-height: 48px; }
.row.it .rmain .grow b, .row.it .rmain .grow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#sheet .sheet.wide { max-height: 92dvh; }
@media (min-width: 768px) { #sheet .sheet.wide { width: min(720px, 92vw); } }
#sheet input[readonly] { margin-top: 8px; }
.searchbar { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 14px; margin: 12px 0 4px; border-radius: 14px; background: var(--panel); border: 1px solid var(--line); color: var(--muted); }
.card2 { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; }
.card2 .art { object-fit: cover; }
.row .art { object-fit: cover; flex: none; background: var(--panel2); }
.dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
+20
View File
@@ -0,0 +1,20 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v3';
const SHELL = ['/', '/style.css', '/app.js', '/icon.svg', '/manifest.webmanifest'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
self.clients.claim();
});
self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
return r;
}).catch(() => caches.match(e.request)));
});
+36
View File
@@ -0,0 +1,36 @@
// Shared passkey helpers (sign-in page + Settings).
const b64u = {
enc: buf => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
dec: s => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4)), c => c.charCodeAt(0)),
};
async function authPost(path, body, method = 'POST') {
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || 'Request failed (' + r.status + ')');
return j;
}
async function passkeyRegister(setupCode, name) {
const { cid, options } = await authPost('/api/auth/register/options', { setupCode });
options.challenge = b64u.dec(options.challenge);
options.user.id = b64u.dec(options.user.id);
(options.excludeCredentials || []).forEach(c => { c.id = b64u.dec(c.id); });
const cred = await navigator.credentials.create({ publicKey: options });
await authPost('/api/auth/register/verify', { cid, name, response: {
id: cred.id, rawId: b64u.enc(cred.rawId), type: cred.type,
response: { clientDataJSON: b64u.enc(cred.response.clientDataJSON), attestationObject: b64u.enc(cred.response.attestationObject),
transports: cred.response.getTransports ? cred.response.getTransports() : [] },
clientExtensionResults: cred.getClientExtensionResults(), authenticatorAttachment: cred.authenticatorAttachment,
} });
}
async function passkeyLogin() {
const { cid, options } = await authPost('/api/auth/login/options');
options.challenge = b64u.dec(options.challenge);
(options.allowCredentials || []).forEach(c => { c.id = b64u.dec(c.id); });
const a = await navigator.credentials.get({ publicKey: options });
await authPost('/api/auth/login/verify', { cid, response: {
id: a.id, rawId: b64u.enc(a.rawId), type: a.type,
response: { clientDataJSON: b64u.enc(a.response.clientDataJSON), authenticatorData: b64u.enc(a.response.authenticatorData),
signature: b64u.enc(a.response.signature), userHandle: a.response.userHandle ? b64u.enc(a.response.userHandle) : undefined },
clientExtensionResults: a.getClientExtensionResults(),
} });
}
BIN
View File
Binary file not shown.
+257
View File
@@ -0,0 +1,257 @@
// Tiny dependency-free server: serves the PWA, exposes config, and proxies Seafile
// (Seafile sends no CORS headers, so the browser talks to /sf/* instead).
const http = require('http');
const fs = require('fs');
const path = require('path');
const { Readable } = require('stream');
const PORT = process.env.PORT || 8080;
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const auth = require('./auth');
const cal = require('./cal');
const PUBLIC = path.join(__dirname, 'public');
const TYPES = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.webmanifest': 'application/manifest+json',
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
};
async function proxySeafile(req, res) {
const tu = new URL(SEAFILE_URL + req.url.slice('/sf'.length));
const inline = tu.searchParams.has('hubinline'); // preview: show in the page instead of forcing a download
tu.searchParams.delete('hubinline');
const target = tu.toString();
const headers = {};
for (const h of ['authorization', 'content-type', 'content-length', 'accept', 'range']) {
if (req.headers[h]) headers[h] = req.headers[h];
}
const hasBody = !['GET', 'HEAD'].includes(req.method);
try {
const upstream = await fetch(target, {
method: req.method, headers, redirect: 'manual',
body: hasBody ? Readable.toWeb(req) : undefined,
duplex: hasBody ? 'half' : undefined,
});
const out = {};
for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges']) {
if (inline && h === 'content-disposition') continue;
const v = upstream.headers.get(h);
if (v) out[h] = v;
}
// Rewrite redirects so they stay on the proxy and on the Seafile host.
const loc = upstream.headers.get('location');
if (loc) {
const u = new URL(loc, SEAFILE_URL);
if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search;
}
res.writeHead(upstream.status, out);
if (upstream.body) Readable.fromWeb(upstream.body).pipe(res); else res.end();
} catch (e) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Seafile unreachable: ' + e.message }));
}
}
function serveStatic(req, res) {
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (p === '/') p = '/index.html';
if (p === '/login') p = '/login.html';
const file = path.join(PUBLIC, path.normalize(p));
if (!file.startsWith(PUBLIC)) { res.writeHead(403); return res.end(); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404); return res.end('Not found'); }
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
if (p === '/sw.js') headers['cache-control'] = 'no-cache';
res.writeHead(200, headers);
res.end(data);
});
}
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
const crypto = require('crypto');
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
const b64u = b => Buffer.from(b).toString('base64url');
const jwtSign = p => {
const d = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + '.' + b64u(JSON.stringify(p));
return d + '.' + crypto.createHmac('sha256', JWT_SECRET).update(d).digest('base64url');
};
const jwtVerify = t => {
const [h, p, s] = String(t || '').split('.');
if (!s) return null;
const want = crypto.createHmac('sha256', JWT_SECRET).update(h + '.' + p).digest();
const got = Buffer.from(s, 'base64url');
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
try { return JSON.parse(Buffer.from(p, 'base64url').toString()); } catch { return null; }
};
const sessions = new Map(); // callback id -> { token, repo, path, exp }; keeps the user's Seafile token out of URLs
setInterval(() => { for (const [k, v] of sessions) if (v.exp < Date.now()) sessions.delete(k); }, 3600e3).unref();
const DOC_TYPE = { word: 'doc docx odt rtf txt', cell: 'xls xlsx ods csv', slide: 'ppt pptx odp' };
const docType = e => Object.keys(DOC_TYPE).find(k => DOC_TYPE[k].split(' ').includes(e));
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 sfGet = async (p, token) => {
const r = await fetch(SEAFILE_URL + p, { headers: { Authorization: 'Token ' + token } });
if (!r.ok) throw new Error('Seafile ' + r.status);
return r.json();
};
async function ooConfig(req, res) {
const token = (req.headers.authorization || '').replace(/^Token /, '');
try {
const { repo, path: p, mobile } = JSON.parse(await readBody(req));
const [url, detail, me] = await Promise.all([
sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(p)}&reuse=1`, token),
sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/detail/?p=${encodeURIComponent(p)}`, token),
sfGet('/api2/account/info/', token),
]);
const name = p.split('/').pop(), fileExt = name.split('.').pop().toLowerCase();
if (!docType(fileExt)) return json(res, 400, { error: 'Unsupported file type' });
const id = crypto.randomUUID();
sessions.set(id, { token, repo, path: p, exp: Date.now() + 12 * 3600e3 });
const origin = PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}`;
const config = {
document: { fileType: fileExt, title: name, url, key: crypto.createHash('sha1').update(repo + p + detail.mtime).digest('hex').slice(0, 20) },
documentType: docType(fileExt),
editorConfig: { mode: 'edit', callbackUrl: `${origin}/api/oo/callback?id=${id}`, user: { id: me.email, name: me.name || me.email }, lang: 'en' },
type: mobile ? 'mobile' : 'desktop', height: '100%', width: '100%',
};
if (JWT_SECRET) config.token = jwtSign(config); // unsigned only works when the Document Server has JWT disabled
json(res, 200, config);
} catch (e) { json(res, 502, { error: e.message }); }
}
async function ooCallback(req, res) {
const id = new URL(req.url, 'http://x').searchParams.get('id');
try {
const raw = JSON.parse(await readBody(req));
const auth = (req.headers.authorization || '').replace(/^Bearer /, '');
const body = JWT_SECRET ? jwtVerify(raw.token || auth) : raw;
if (!body) return json(res, 403, { error: 1 });
const s = sessions.get(id);
// status 2 = ready to save, 6 = force-saved
if (s && (body.status === 2 || body.status === 6)) {
const dir = s.path.slice(0, s.path.lastIndexOf('/')) || '/';
const link = await sfGet(`/api2/repos/${encodeURIComponent(s.repo)}/update-link/?p=${encodeURIComponent(dir)}`, s.token);
const file = await (await fetch(body.url)).blob();
const fd = new FormData();
fd.append('target_file', s.path); fd.append('parent_dir', dir); fd.append('file', file, s.path.split('/').pop());
const up = await fetch(new URL(link, SEAFILE_URL), { method: 'POST', body: fd });
if (!up.ok) throw new Error('Seafile save failed ' + up.status);
}
if (body.status === 4 || body.status === 2) sessions.delete(id);
json(res, 200, { error: 0 });
} catch (e) { console.error('callback:', e.message); json(res, 200, { error: 1 }); }
}
// ---- Plex Music: sign in via plex.tv PIN (no password handled here). Tokens live in HttpOnly cookies, and
// /plex/* is proxied to the chosen server with the token added server-side, so <audio>/<img> work without exposing it.
const COOKIE_SECRET = auth.secret;
const PLEX_HEADERS = {
Accept: 'application/json', 'X-Plex-Client-Identifier': process.env.PLEX_CLIENT_ID || 'ne-hub-web',
'X-Plex-Product': 'NE Hub', 'X-Plex-Version': '1.0', 'X-Plex-Device-Name': 'NE Hub', 'X-Plex-Platform': 'Web',
};
const mac = v => crypto.createHmac('sha256', COOKIE_SECRET).update(v).digest('base64url');
const signed = v => v + '.' + mac(v);
const unsigned = v => { const i = String(v || '').lastIndexOf('.'); return i > 0 && mac(v.slice(0, i)) === v.slice(i + 1) ? v.slice(0, i) : null; };
const parseCookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; }));
const setCookies = (req, res, obj, maxAge = 31536000) => {
const secure = (req.headers['x-forwarded-proto'] || '') === 'https' ? '; Secure' : '';
res.setHeader('Set-Cookie', Object.entries(obj).map(([k, v]) => `${k}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${v === '' ? 0 : maxAge}${secure}`));
};
const plexTv = async (path, token, opts = {}) => {
const r = await fetch('https://plex.tv' + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
if (!r.ok) throw new Error('plex.tv ' + r.status);
return r.json();
};
async function plexApi(req, res) {
const u = new URL(req.url, 'http://x'), ck = parseCookies(req), token = ck.plex_token;
try {
if (u.pathname === '/api/plex/status') {
return json(res, 200, { signedIn: !!token, server: unsigned(ck.plex_srv) ? ck.plex_name || 'Plex' : null });
}
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_HEADERS['X-Plex-Client-Identifier'])}&code=${pin.code}&context%5Bdevice%5D%5Bproduct%5D=NE%20Hub`;
return json(res, 200, { id: pin.id, code: pin.code, authUrl });
}
if (u.pathname === '/api/plex/pin' && req.method === 'GET') {
const pin = await plexTv(`/api/v2/pins/${encodeURIComponent(u.searchParams.get('id'))}?code=${encodeURIComponent(u.searchParams.get('code'))}`);
if (!pin.authToken) return json(res, 200, { ok: false });
setCookies(req, res, { plex_token: pin.authToken });
return json(res, 200, { ok: true });
}
if (!token) return json(res, 401, { error: 'Not signed in to Plex' });
if (u.pathname === '/api/plex/servers') {
const rs = await plexTv('/api/v2/resources?includeHttps=1&includeRelay=1', token);
return json(res, 200, rs.filter(r => (r.provides || '').includes('server')).map(r => ({ id: r.clientIdentifier, name: r.name, owned: r.owned })));
}
if (u.pathname === '/api/plex/server' && req.method === 'POST') {
const { id } = JSON.parse(await readBody(req));
const r = (await plexTv('/api/v2/resources?includeHttps=1&includeRelay=1', token)).find(x => x.clientIdentifier === id);
if (!r) return json(res, 404, { error: 'Server not found' });
// Prefer direct remote connections, then relay, then local; first one that answers wins.
const order = c => (c.local ? 2 : c.relay ? 1 : 0);
for (const c of [...r.connections].sort((a, b) => order(a) - order(b))) {
try {
const t = await fetch(c.uri + '/identity', { headers: { ...PLEX_HEADERS, 'X-Plex-Token': r.accessToken }, signal: AbortSignal.timeout(4000) });
if (!t.ok) continue;
setCookies(req, res, { plex_srv: signed(c.uri), plex_stk: r.accessToken, plex_name: r.name });
return json(res, 200, { ok: true, name: r.name });
} catch {}
}
return json(res, 502, { error: 'Could not reach this server. Check that Remote Access is enabled in Plex.' });
}
if (u.pathname === '/api/plex/logout' && req.method === 'POST') {
setCookies(req, res, { plex_token: '', plex_srv: '', plex_stk: '', plex_name: '' });
return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
} catch (e) { json(res, 502, { error: e.message }); }
}
async function plexProxy(req, res) {
const ck = parseCookies(req), base = unsigned(ck.plex_srv), tok = ck.plex_stk;
if (!base || !tok) { res.writeHead(401); return res.end(); }
const headers = { ...PLEX_HEADERS, 'X-Plex-Token': tok, Accept: req.headers.accept || 'application/json' };
if (req.headers.range) headers.range = req.headers.range;
const ac = new AbortController();
res.on('close', () => ac.abort());
try {
const up = await fetch(base + req.url.slice('/plex'.length), { headers, signal: ac.signal });
const out = {};
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { const v = up.headers.get(h); if (v) out[h] = v; }
if (req.url.startsWith('/plex/photo/')) out['cache-control'] = 'private, max-age=86400';
res.writeHead(up.status, out);
if (up.body) Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res); else res.end();
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
}
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.');
http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://x').pathname;
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
// 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).
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
if (!open.includes(pathname) && !auth.isAuthed(req)) {
if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); }
res.writeHead(302, { location: '/login' }); return res.end();
}
if (req.url === '/api/config') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, editor: true, jwt: !!JWT_SECRET }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
serveStatic(req, res);
}).listen(PORT, () => console.log(`Hub on :${PORT} (Seafile ${SEAFILE_URL})`));