diff --git a/Dockerfile b/Dockerfile index 8ab6d11..6631930 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,5 +37,5 @@ COPY package.json ./ RUN mkdir -p /data && chown node:node /data USER node EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:${PORT}/healthz || exit 1 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- "http://127.0.0.1:${PORT}$(printf '%s' "$BASE_PATH" | sed 's:/*$::')/healthz" || exit 1 CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 816cde6..d307fce 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ For local image builds from the checkout, use: docker compose -f docker-compose.dev.yml up -d --build ``` +To serve under a subpath, pass `-e BASE_PATH=/crosspoint-sync` to `docker run`, +or run `BASE_PATH=/crosspoint-sync docker compose up -d`. The same image works +at any prefix; no frontend rebuild is needed. + ### Railway (hosted-style deploy) 1. New project → Deploy from this repo (Railway detects the Dockerfile). @@ -70,13 +74,15 @@ services.crosspoint-sync = { enable = true; port = 8080; registration = true; + # Optional: serve at /crosspoint-sync/ instead of /. + basePath = "/crosspoint-sync"; }; ``` ### Bare Node (≥ 22.13) ```sh -npm ci && npm run build +npm ci && npm run build && npm run build:frontend DATABASE_PATH=./data/crosspoint.db PORT=8080 node dist/index.js ``` @@ -86,6 +92,7 @@ DATABASE_PATH=./data/crosspoint.db PORT=8080 node dist/index.js |---------|---------|---------| | `PORT` | `8080` | Listen port | | `LISTEN_ADDRESS` | `localhost` (`::` in the Docker image) | Interface to listen on. `::` listens on all of them, which a container needs to be reachable. | +| `BASE_PATH` | _(empty)_ | URL prefix, e.g. `/crosspoint-sync`. Empty or `/` serves at the domain root; trailing slashes are normalized. Applies to the website, API, and health endpoint (`/crosspoint-sync/healthz` in this example). Set at runtime; no frontend rebuild needed. Reverse proxies must preserve the prefix, not strip it. | | `DATABASE_PATH` | `/data/crosspoint.db` | SQLite file (parent dirs auto-created) | | `REGISTRATION_DISABLED` | `false` | Set `true` to lock down a private instance | | `AUTH_RATE_LIMIT_PER_MINUTE` | `30` | Per-IP limit on registration (0 disables) | @@ -98,6 +105,11 @@ DATABASE_PATH=./data/crosspoint.db PORT=8080 node dist/index.js | `SEARCHAPI_KEY` | _(unset)_ | Optional, paid ([searchapi.io](https://www.searchapi.io)). Last fallback for print page counts: Amazon search + product "Print length", 2 requests per book, only for books the free sources miss. Cached forever. | | `CORS_ORIGINS` | `*` | Origins allowed to call the sync API from browsers (comma-separated). The default wildcard is safe: the API authenticates with headers, not cookies, and the web UI's cookie routes never get CORS headers. | +With `BASE_PATH=/crosspoint-sync`, open `https://your-host/crosspoint-sync/` +and configure readers with `https://your-host/crosspoint-sync` as their server +URL. OAuth providers must allow the prefixed callback URL shown in Settings. +Native app association files, when enabled, remain at domain-root `/.well-known/`. + ### Link Micro.blog 1. Sign in to [Micro.blog](https://micro.blog/). diff --git a/app/src/Settings.jsx b/app/src/Settings.jsx index 228584e..9b3b46c 100644 --- a/app/src/Settings.jsx +++ b/app/src/Settings.jsx @@ -231,7 +231,8 @@ export function LinkForm({ session, conn, onLinked }) { if (!isApp) return window.location.assign(authorize_url) unlisten.current?.() unlisten.current = await onOpenUrl((urls) => { - const u = urls.map((x) => new URL(x)).find((x) => x.pathname === `/connectors/${conn.id}/callback`) + const callback = new URL(`${session.server}/connectors/${conn.id}/callback`) + const u = urls.map((x) => new URL(x)).find((x) => x.origin === callback.origin && x.pathname === callback.pathname) if (!u || !live.current) return stop() const q = Object.fromEntries(u.searchParams) diff --git a/app/src/Settings.test.jsx b/app/src/Settings.test.jsx index 95042c4..b6ff4d3 100644 --- a/app/src/Settings.test.jsx +++ b/app/src/Settings.test.jsx @@ -90,6 +90,20 @@ describe('LinkForm', () => { expect(onLinked).toHaveBeenCalled() expect(api.oauthComplete).not.toHaveBeenCalled() }) + + it('app: accepts callbacks under the server prefix, only from that server', async () => { + env.isApp = true + api.oauthBegin.mockResolvedValue({ authorize_url: AUTH }) + api.oauthComplete.mockResolvedValue({ linked: true }) + const prefixed = { server: 'https://sync.test/reading' } + render() + fireEvent.click(signIn()) + await waitFor(() => expect(openUrl).toHaveBeenCalledWith(AUTH)) + await act(async () => deliver(['https://other.test/reading/connectors/spotify/callback?state=s1&code=c1'])) + expect(api.oauthComplete).not.toHaveBeenCalled() + await act(async () => deliver(['https://sync.test/reading/connectors/spotify/callback?state=s1&code=c1'])) + expect(api.oauthComplete).toHaveBeenCalledWith(prefixed, 'spotify', { state: 's1', code: 'c1', error: undefined }) + }) }) describe('Spotify Client ID setup', () => { diff --git a/app/src/api.js b/app/src/api.js index 6766fd5..b73b0e1 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -5,7 +5,11 @@ import { fetch as tauriFetch } from '@tauri-apps/plugin-http' // in a plain browser, the webview fetch. export const isApp = '__TAURI_INTERNALS__' in window // Served by a sync server at /app/: that server is the only one to sign in to. -export const hostedServer = !isApp && /^https?:$/.test(location.protocol) && location.pathname.startsWith('/app') ? location.origin : null +export function hostedServerUrl(url) { + const match = url.pathname.match(/^(.*)\/app\/(?:index\.html)?$/) + return /^https?:$/.test(url.protocol) && match ? url.origin + match[1] : null +} +export const hostedServer = !isApp ? hostedServerUrl(location) : null // File bytes for raw-body commands (send_bytes, save_file). Android's WebView can't // pass request bodies to Tauri's IPC protocol, so there the message is JSON-encoded diff --git a/app/src/api.test.js b/app/src/api.test.js index 7c0055e..757a119 100644 --- a/app/src/api.test.js +++ b/app/src/api.test.js @@ -7,7 +7,7 @@ const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) // Node's experimental localStorage can shadow jsdom's storage in Vitest. vi.stubGlobal('localStorage', new JSDOM('', { url: 'https://app.test' }).window.localStorage) -const { api, cached, loadSession, login, logout, saveSession } = await import('./api.js') +const { api, cached, loadSession, login, logout, saveSession, hostedServerUrl } = await import('./api.js') const session = (username, server = 'https://sync.test') => ({ server, username, key: 'key' }) const prefix = (s) => `crosspoint-offline:v2:${s.username}@${s.server}` const progress = '/api/v1/progress?limit=500' @@ -19,6 +19,29 @@ beforeEach(() => { fetchMock.mockReset() }) +describe('prefixed server URLs', () => { + it.each(['', '/reading', '/services/reading'])('detects the hosted server at %j', (basePath) => { + expect(hostedServerUrl(new URL(`https://sync.test${basePath}/app/`))).toBe(`https://sync.test${basePath}`) + }) + it.each(['', '/reading', '/services/reading'])('detects the hosted server from the explicit index URL at %j', (basePath) => { + expect(hostedServerUrl(new URL(`https://sync.test${basePath}/app/index.html`))).toBe(`https://sync.test${basePath}`) + }) + it.each(['https://sync.test/application/', 'https://sync.test/app/assets/', 'tauri://localhost/app/'])('does not mistake %s for a hosted app', (url) => { + expect(hostedServerUrl(new URL(url))).toBeNull() + }) + it('preserves the prefix during server discovery, authentication and API calls', async () => { + fetchMock.mockResolvedValueOnce(response({ status: 'ok' })) + .mockResolvedValueOnce(response({ authorized: true })) + .mockResolvedValueOnce(response({ items: [] })) + const s = await login('https://sync.test/reading/', 'reader', 'password') + await api.books(s) + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://sync.test/reading/healthz', 'https://sync.test/reading/users/auth', + `https://sync.test/reading${progress}`, + ]) + }) +}) + describe('account data isolation', () => { it('ignores legacy copies that may contain a different account after upgrading', () => { localStorage.setItem(`crosspoint-offline:uxj3@https://sync.test${progress}`, JSON.stringify({ items: [{ title: 'uxj4 book' }] })) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5afde2e..c924189 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,6 +6,7 @@ services: volumes: - crosspoint-data:/data environment: + BASE_PATH: "${BASE_PATH:-}" # Set to "true" on private instances once your accounts exist. REGISTRATION_DISABLED: "false" # Set Listen Address so the service is accessible from outside the container diff --git a/docker-compose.yml b/docker-compose.yml index f67413c..a7238b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: volumes: - crosspoint-data:/data environment: + BASE_PATH: "${BASE_PATH:-}" # Set to "true" on private instances once your accounts exist. REGISTRATION_DISABLED: "false" # Set Listen Address so the service is accessible from outside the container diff --git a/nix/module.nix b/nix/module.nix index 11e4dde..b55c6c0 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -33,6 +33,12 @@ in default = 8080; }; + basePath = mkOption { + type = types.str; + description = "URL prefix, for example /crosspoint-sync; empty serves at the domain root. The reverse proxy must preserve this prefix."; + default = ""; + }; + databaseFile = mkOption { type = filename; description = "Filename of the SQLite database in the state directory; "; @@ -125,6 +131,7 @@ in }; environment = { + BASE_PATH = cfg.basePath; PORT = builtins.toString cfg.port; DATABASE_PATH = "/var/lib/crosspoint-sync/${cfg.databaseFile}"; REGISTRATION_DISABLED = if !cfg.registration then "true" else "false"; diff --git a/package.json b/package.json index 20315b5..fcbe713 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "tsx watch --env-file-if-exists=.env src/index.ts", "start": "node dist/index.js", "build": "tsc -p tsconfig.json", + "build:frontend": "npm ci --prefix app --ignore-scripts && npm run build --prefix app", "test": "vitest run --dir test", "test:watch": "vitest" }, diff --git a/src/app.ts b/src/app.ts index 726b172..b0429a8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,13 +2,13 @@ import { serveStatic } from '@hono/node-server/serve-static'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import type { DB } from './db/db.js'; -import type { Config } from './config.js'; +import { normalizeBasePath, type Config } from './config.js'; import { sessionOrKeyAuth, type AppEnv } from './auth/middleware.js'; import { kosyncRoutes } from './routes/kosync.js'; import { meRoutes } from './routes/v1/me.js'; import { authRoutes } from './routes/auth.js'; import { accountRoutes } from './routes/account.js'; -import { webRoutes } from './routes/web.js'; +import { webRoutes, appLinkRoutes } from './routes/web.js'; import { progressRoutes } from './routes/v1/progress.js'; import { bookmarkRoutes } from './routes/v1/bookmarks.js'; import { clippingRoutes } from './routes/v1/clippings.js'; @@ -28,6 +28,8 @@ export interface AppOptions { } export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono { + config = { ...config, basePath: normalizeBasePath(config.basePath) }; + const { basePath } = config; const app = new Hono(); const refreshProgress = createProgressRefresh(db, opts.connectorTransport); @@ -51,12 +53,12 @@ export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono c.redirect('/app/')); + app.get('/app', (c) => c.redirect(`${basePath}/app/`)); app.use( '/app/*', serveStatic({ root: webApp, - rewriteRequestPath: (p) => p.replace(/^\/app/, ''), + rewriteRequestPath: (p) => p.slice(`${basePath}/app`.length), onFound: (path, c) => { // Vite fingerprints assets; index.html must always be fresh. c.header('Cache-Control', path.endsWith('.html') ? 'no-cache' : 'public, max-age=31536000, immutable'); @@ -65,8 +67,8 @@ export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono(); + // OS app-link discovery always happens at the domain root. + root.route('/', appLinkRoutes(basePath)); + if (basePath) root.get(basePath, (c) => c.redirect(`${basePath}/${new URL(c.req.url).search}`, 308)); + root.route(`${basePath}/`, app); + return root; } diff --git a/src/config.ts b/src/config.ts index cfc0e0e..fcd9f0f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,6 @@ export interface Config { + /** URL prefix, empty for a domain-root deployment. */ + basePath: string; registrationDisabled: boolean; /** Max requests per IP per minute on sensitive endpoints (0 = disabled). */ authRateLimitPerMinute: number; @@ -20,6 +22,7 @@ export interface Config { export function fromEnv(env: NodeJS.ProcessEnv = process.env): Config { return { + basePath: normalizeBasePath(env.BASE_PATH ?? ''), registrationDisabled: env.REGISTRATION_DISABLED === 'true' || env.REGISTRATION_DISABLED === '1', authRateLimitPerMinute: env.AUTH_RATE_LIMIT_PER_MINUTE ? Number(env.AUTH_RATE_LIMIT_PER_MINUTE) @@ -36,3 +39,13 @@ export function fromEnv(env: NodeJS.ProcessEnv = process.env): Config { env.KINDLE_SERVER_REGISTRATION === 'true' || env.KINDLE_SERVER_REGISTRATION === '1', }; } + +export function normalizeBasePath(value: string): string { + if (value === '' || value === '/') return ''; + const path = value.replace(/\/+$/, ''); + if (!/^\/(?:[A-Za-z0-9._~-]+\/)*[A-Za-z0-9._~-]+$/.test(path) + || path.split('/').some((part) => part === '.' || part === '..')) { + throw new Error('BASE_PATH must be an absolute path of URL-safe segments (for example /reading)'); + } + return path; +} diff --git a/src/routes/account.ts b/src/routes/account.ts index d0d5c71..c625cb0 100644 --- a/src/routes/account.ts +++ b/src/routes/account.ts @@ -155,7 +155,7 @@ export function accountRoutes(db: DB, config: Config): Hono { .get(account.id) as { id: number; username: string } | undefined; if (kosync) deleteKosyncUserData(db, kosync.id, kosync.username); db.prepare('DELETE FROM accounts WHERE id = ?').run(account.id); - deleteCookie(c, SESSION_COOKIE, { path: '/' }); + deleteCookie(c, SESSION_COOKIE, { path: config.basePath || '/' }); return c.json({ deleted: true }); }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 90f782d..9974df0 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -36,14 +36,14 @@ function masterToken(accountId: number): string { return `xp1_${accountId}_${crypto.randomBytes(16).toString('hex')}`; } -function setSessionCookie(c: Context, accountId: number) { +function setSessionCookie(c: Context, accountId: number, basePath: string) { const proto = c.req.header('x-forwarded-proto'); const secure = proto ? proto.split(',')[0].trim() === 'https' : false; setCookie(c, SESSION_COOKIE, signSession(accountId), { httpOnly: true, sameSite: 'Lax', secure, - path: '/', + path: basePath || '/', maxAge: SESSION_TTL_SECONDS, }); } @@ -76,7 +76,7 @@ export function authRoutes(db: DB, config: Config): Hono { const accountId = Number(info.lastInsertRowid); const token = `xp1_${accountId}_${secret}`; db.prepare('UPDATE accounts SET token_hash = ? WHERE id = ?').run(hashKey(md5(token)), accountId); - setSessionCookie(c, accountId); + setSessionCookie(c, accountId, config.basePath); return c.json({ handle, token }); }); @@ -98,7 +98,7 @@ export function authRoutes(db: DB, config: Config): Hono { if (!row || !verifyKey(md5(token), row.token_hash)) { return c.json({ error: 'Invalid token' }, 401); } - setSessionCookie(c, accountId); + setSessionCookie(c, accountId, config.basePath); return c.json({ ok: true }); }); @@ -141,12 +141,12 @@ export function authRoutes(db: DB, config: Config): Hono { db.prepare('UPDATE accounts SET token_hash = ? WHERE id = ?').run(hashKey(md5(token)), accountId); db.prepare('UPDATE users SET account_id = ? WHERE id = ?').run(accountId, row.id); } - setSessionCookie(c, accountId); + setSessionCookie(c, accountId, config.basePath); return c.json({ ok: true }); }); app.post('/logout', (c) => { - deleteCookie(c, SESSION_COOKIE, { path: '/' }); + deleteCookie(c, SESSION_COOKIE, { path: config.basePath || '/' }); return c.json({ ok: true }); }); diff --git a/src/routes/landing.ts b/src/routes/landing.ts index 625b40b..06a907e 100644 --- a/src/routes/landing.ts +++ b/src/routes/landing.ts @@ -28,16 +28,6 @@ export const metaTags = (title: string, description = SITE_DESCRIPTION) => `; -// A screenshot with its dark twin, swapped by the visitor's color scheme. -const shot = (name: string, alt: string, eager = false) => - `` + - `${alt}`; - -const phone = (name: string, alt: string, cls = '', eager = false) => - `
${shot(name, alt, eager)}
`; -const browser = (name: string, alt: string, cls = '', eager = false) => `
${shot(name, alt, eager)}
`; -const tablet = (name: string, alt: string, cls = '') => `
${shot(name, alt)}
`; - const SERVICES: [string, string, string, 'ready' | 'beta'][] = [ ['hardcover', 'Hardcover', 'Your shelf and progress update each time you sync.', 'ready'], ['bookfusion', 'BookFusion', 'Reading positions both ways for books from BookFusion.', 'ready'], @@ -189,21 +179,29 @@ footer a:hover{color:var(--ink)} } `; -export function landingPage(fontFaces: string): string { +export function landingPage(fontFaces: string, basePath = ''): string { + // A screenshot with its dark twin, swapped by the visitor's color scheme. + const shot = (name: string, alt: string, eager = false) => + `` + + `${alt}`; + const phone = (name: string, alt: string, cls = '', eager = false) => + `
${shot(name, alt, eager)}
`; + const browser = (name: string, alt: string, cls = '', eager = false) => `
${shot(name, alt, eager)}
`; + const tablet = (name: string, alt: string, cls = '') => `
${shot(name, alt)}
`; return ` CrossPoint Sync: Your reading, in sync everywhere ${metaTags('CrossPoint Sync: Your reading, in sync everywhere')} - - - + + +
@@ -212,7 +210,7 @@ ${metaTags('CrossPoint Sync: Your reading, in sync everywhere')} read on every device

Your reading, in sync everywhere

Your e-reader's progress, clippings and stats on your phone and the web, and in the services you already use.

- +
${browser('desktop-library', 'The CrossPoint Sync library on the web', '', true)} @@ -226,7 +224,7 @@ ${metaTags('CrossPoint Sync: Your reading, in sync everywhere')}
${SERVICES.map( ([icon, name, desc, state]) => - `
` + + `
` + `
${name}${state === 'beta' ? 'beta' : ''}
${desc}
` ).join('')}
@@ -289,7 +287,7 @@ ${metaTags('CrossPoint Sync: Your reading, in sync everywhere')}

Start syncing in a minute

Free to use and open source. Run it here, or on your own server with one Docker image.

- +
diff --git a/src/routes/v1/connectors.ts b/src/routes/v1/connectors.ts index 598b97e..1e4d7f9 100644 --- a/src/routes/v1/connectors.ts +++ b/src/routes/v1/connectors.ts @@ -102,15 +102,15 @@ const page = (title: string, body: string) => * here, then returns a web sign-in to the app's Settings. Unauthenticated; the * single-use state is what ties it to the user who began. */ -export function oauthCallbackRoutes(db: DB, transport: HttpTransport = fetchTransport): Hono { +export function oauthCallbackRoutes(db: DB, transport: HttpTransport = fetchTransport, basePath = ''): Hono { const app = new Hono(); app.get('/connectors/:id/callback', async (c) => { const { entry, error } = await completeOAuth(db, transport, c.req.query('state') ?? '', c.req.query('code'), c.req.query('error')); const name = escapeHtml(getConnector(c.req.param('id'))?.displayName ?? 'Service'); if (error) { - return c.html(page(`${name} not linked`, `${escapeHtml(error)} Back to Settings`), 400); + return c.html(page(`${name} not linked`, `${escapeHtml(error)} Back to Settings`), 400); } - if (entry!.client === 'web') return c.redirect(`/app/#/settings/${encodeURIComponent(entry!.connectorId)}`); + if (entry!.client === 'web') return c.redirect(`${basePath}/app/#/settings/${encodeURIComponent(entry!.connectorId)}`); return c.html(page(`${name} linked`, 'You can go back to the CrossPoint Sync app.')); }); return app; @@ -147,7 +147,8 @@ export function credentialRequestIsSecure(c: Context, trustProxy: boolea export function connectorRoutes( db: DB, transport: HttpTransport = fetchTransport, - trustProxy = false + trustProxy = false, + basePath = '' ): Hono { const app = new Hono(); @@ -177,7 +178,7 @@ export function connectorRoutes( // Sign-in setup: the redirect to register (this server's own public URL), the // user's own client id, and whether the server has a shared one. ...(conn.oauth ? { oauth: { - redirect_uri: publicOrigin(c, trustProxy) + oauthCallbackPath(conn.id), + redirect_uri: publicOrigin(c, trustProxy) + basePath + oauthCallbackPath(conn.id), client_id: getClientId(db, user.id, conn.id), shared: !!conn.oauth().clientId, } } : {}), @@ -312,7 +313,7 @@ export function connectorRoutes( if (!CLIENT_ID_RE.test(id)) { return c.json({ code: 2003, message: 'A Client ID is 32 letters and numbers. Copy it from your app\'s Settings (step 3).' }, 400); } - const redirectUri = publicOrigin(c, trustProxy) + oauthCallbackPath(conn.id); + const redirectUri = publicOrigin(c, trustProxy) + basePath + oauthCallbackPath(conn.id); if ((await conn.checkClientId?.(id, redirectUri, transport)) === 'rejected') { return c.json({ code: 2003, message: CLIENT_ID_REJECTED }, 400); } @@ -345,7 +346,7 @@ export function connectorRoutes( for (const [k, v] of pendingOAuth) if (v.expires < now) pendingOAuth.delete(k); const verifier = b64url(randomBytes(48)); const state = b64url(randomBytes(16)); - const redirectUri = publicOrigin(c, trustProxy) + oauthCallbackPath(conn.id); + const redirectUri = publicOrigin(c, trustProxy) + basePath + oauthCallbackPath(conn.id); pendingOAuth.set(state, { userId: user.id, connectorId: conn.id, verifier, redirectUri, clientId, client: body?.client === 'app' ? 'app' : 'web', expires: now + OAUTH_TTL_MS, diff --git a/src/routes/web.ts b/src/routes/web.ts index 712bebc..cb7b413 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -38,19 +38,20 @@ const FONTS = new Map(); for (const f of fs.readdirSync(path.join(ASSETS_DIR, 'fonts'))) { if (f.endsWith('.woff2')) FONTS.set(f, fs.readFileSync(path.join(ASSETS_DIR, 'fonts', f))); } -const face = (family: string, file: string, weight: string) => - `@font-face{font-family:"${family}";src:url(/fonts/${file}) format("woff2");font-weight:${weight};font-style:normal;font-display:swap}`; -const FONT_FACES = [ - face('InterVariable', 'inter-latin-wght-normal.woff2', '100 900'), - face('Lora', 'lora-latin-500-normal.woff2', '500'), - face('Lora', 'lora-latin-600-normal.woff2', '600'), - face('Lora', 'lora-latin-700-normal.woff2', '700'), - face('Geist Mono', 'geist-mono-latin-400-normal.woff2', '400'), - face('Geist Mono', 'geist-mono-latin-500-normal.woff2', '500'), - face('Caveat', 'caveat-latin-500-normal.woff2', '500'), - face('Caveat', 'caveat-latin-600-normal.woff2', '600'), -].join(''); -const LANDING_PAGE = landingPage(FONT_FACES); +function fontFaces(basePath: string): string { + const face = (family: string, file: string, weight: string) => + `@font-face{font-family:"${family}";src:url(${basePath}/fonts/${file}) format("woff2");font-weight:${weight};font-style:normal;font-display:swap}`; + return [ + face('InterVariable', 'inter-latin-wght-normal.woff2', '100 900'), + face('Lora', 'lora-latin-500-normal.woff2', '500'), + face('Lora', 'lora-latin-600-normal.woff2', '600'), + face('Lora', 'lora-latin-700-normal.woff2', '700'), + face('Geist Mono', 'geist-mono-latin-400-normal.woff2', '400'), + face('Geist Mono', 'geist-mono-latin-500-normal.woff2', '500'), + face('Caveat', 'caveat-latin-500-normal.woff2', '500'), + face('Caveat', 'caveat-latin-600-normal.woff2', '600'), + ].join(''); +} // Landing page screenshots (the real app with a demo account), served at /landing/:file. const LANDING_IMAGES = new Map(); @@ -213,17 +214,17 @@ const STYLE = ` .progress-fill { height:100%; border-radius:inherit; background:var(--brand-500); } `; -function shell(title: string, body: string, wide = false): string { +function shell(basePath: string, title: string, body: string, wide = false): string { return ` ${title} · CrossPoint Sync ${metaTags(`${title} · CrossPoint Sync`)} - - - + + +
- CrossPoint Sync + CrossPoint Sync ${title === 'Account' ? '' : ''}
${body}
@@ -232,7 +233,7 @@ ${metaTags(`${title} · CrossPoint Sync`)} const SECTION = 'font-size:13px;text-transform:uppercase;letter-spacing:0.05em;color:var(--stone-500);margin:32px 0 12px;'; -const LANDING = shell( +const LANDING = (basePath: string) => shell(basePath, 'Sync your reading', `

read on every device

@@ -262,28 +263,28 @@ const LANDING = shell(

Services you can link

-
BookFusion
+
BookFusion
Sync reading positions both ways for books downloaded from BookFusion.
ready
-
Another KOSync server
+
Another KOSync server
Mirror your progress to sync.koreader.rocks or your own server, so your other KOReader devices stay in sync too.
ready
-
Hardcover
+
Hardcover
Keep your Hardcover shelf and reading progress up to date automatically.
ready
-
Micro.blog
+
Micro.blog
Keep your Currently reading and Finished reading bookshelves up to date automatically.
ready
-
Readwise
+
Readwise
Send the passages you highlight on your reader to Readwise.
ready
-
Readwise Reader
+
Readwise Reader
Archive books in Reader when you finish them, and bring your Reader progress back.
ready
-
Audiobookshelf
+
Audiobookshelf
Keep your place between the ebook and the audiobook, both ways. Read some, then pick up listening right where you left off.
ready
-
BookOrbit
+
BookOrbit
Keep your place in sync, both ways, with your own BookOrbit library. Clippings show up as highlights, and finished books are marked Read.
beta

more on the way, and it's all open source

@@ -305,7 +306,7 @@ const LANDING = shell(
- Continue → + Continue →
@@ -340,7 +341,7 @@ async function post(url, body) { } $('signup').onclick = async () => { $('suErr').textContent = ''; - const { ok, data } = await post('/auth/signup', { handle: $('su').value.trim() }); + const { ok, data } = await post('${basePath}/auth/signup', { handle: $('su').value.trim() }); if (!ok) { $('suErr').textContent = data.error || 'Something went wrong'; return; } $('tokenVal').textContent = data.token; $('tokenBox').hidden = false; @@ -362,18 +363,24 @@ async function copyWithFeedback(btn, text) { setTimeout(() => { btn.textContent = original; btn.classList.remove('copied'); btn.disabled = false; }, 1600); } // Where to go after signing in: ?next=/kindle (same-site paths only), else the account page. -const NEXT = (() => { const n = new URLSearchParams(location.search).get('next') || ''; return /^\/[a-z]/i.test(n) ? n : '/account'; })(); +const NEXT = (() => { + const n = new URLSearchParams(location.search).get('next') || ''; + if (!n.startsWith('${basePath}/') || n.startsWith('//') || n.includes('\\\\')) return '${basePath}/account'; + const target = new URL(n, location.origin); + return target.origin === location.origin && target.pathname.startsWith('${basePath}/') + ? target.pathname + target.search + target.hash : '${basePath}/account'; +})(); $('copyTok').onclick = () => copyWithFeedback($('copyTok'), $('tokenVal').textContent); $('ksLogin').onclick = async () => { $('ksLoginErr').textContent = ''; - const { ok, data } = await post('/auth/login-kosync', { username: $('ksu').value.trim(), password: $('ksp').value }); + const { ok, data } = await post('${basePath}/auth/login-kosync', { username: $('ksu').value.trim(), password: $('ksp').value }); if (!ok) { $('ksLoginErr').textContent = data.error || 'Invalid credentials'; return; } location.href = NEXT; }; $('tokToggle').onclick = (e) => { e.preventDefault(); $('tokBox').hidden = !$('tokBox').hidden; }; $('login').onclick = async () => { $('liErr').textContent = ''; - const { ok, data } = await post('/auth/login', { token: $('li').value.trim() }); + const { ok, data } = await post('${basePath}/auth/login', { token: $('li').value.trim() }); if (!ok) { $('liErr').textContent = data.error || 'Invalid token'; return; } location.href = NEXT; }; @@ -381,7 +388,7 @@ $('login').onclick = async () => { true ); -const ACCOUNT = shell( +const ACCOUNT = (basePath: string) => shell(basePath, 'Account', `
Account @@ -393,7 +400,7 @@ const ACCOUNT = shell(

Reading progress

-
Synced books
View titles, percentages, devices and sync times, or remove a book.
+
Synced books
View titles, percentages, devices and sync times, or remove a book.

Linked services

@@ -418,7 +425,7 @@ const ACCOUNT = shell(

Want to self host this?

`, true ); -const LINK = shell( +const LINK = (basePath: string) => shell(basePath, 'Link service', - ` + `
Link service

Link

@@ -595,7 +602,7 @@ async function linkCredential(body) { if (!secureCredentialConnection()) { return { ok:false, data:{ message:'Use HTTPS to link a service.' } }; } - return jsend('/api/v1/connectors/' + ID, 'PUT', { credential: body }); + return jsend('${basePath}/api/v1/connectors/' + ID, 'PUT', { credential: body }); } const HINTS = { @@ -626,7 +633,7 @@ const TOKEN_HELP = { kindle: '
' + '

Set up with the CrossPoint Kindle Link extension (recommended):

' + '
    ' - + '
  1. Download crosspoint-kindle-link.zip and unzip it.
  2. ' + + '
  3. Download crosspoint-kindle-link.zip and unzip it.
  4. ' + '
  5. Open chrome://extensions and turn on Developer mode.
  6. ' + '
  7. Choose Load unpacked and select the unzipped folder.
  8. ' + '
  9. Click the extension icon: connect it to this server (the same username and password as this dashboard), then register with Amazon — Amazon emails a one-time code.
  10. ' @@ -637,11 +644,11 @@ const TOKEN_HELP = { }; (async () => { - const { ok, status, data } = await jget('/api/v1/connectors'); - if (status === 409) { location.href = '/account'; return; } // no sync account yet + const { ok, status, data } = await jget('${basePath}/api/v1/connectors'); + if (status === 409) { location.href = '${basePath}/account'; return; } // no sync account yet if (!ok) { $('form').innerHTML = '

    Could not load.

    '; return; } const conn = (data.connectors || []).find(c => c.id === ID); - if (!conn) { location.href = '/account'; return; } + if (!conn) { location.href = '${basePath}/account'; return; } $('title').textContent = 'Link ' + conn.name; CONN_NAME = conn.name; $('desc').textContent = HINTS[ID] || ''; @@ -650,7 +657,7 @@ const TOKEN_HELP = { })(); let CONN_NAME = 'the service'; -function done() { location.href = '/account'; } +function done() { location.href = '${basePath}/account'; } function render(conn) { const f = $('form'); @@ -704,7 +711,7 @@ function render(conn) { async function startDeviceFlow() { $('e').textContent = ''; $('start').disabled = true; - const r = await jsend('/api/v1/connectors/' + ID + '/link/begin'); + const r = await jsend('${basePath}/api/v1/connectors/' + ID + '/link/begin'); if (!r.ok) { $('e').textContent = r.data.message || 'Could not start'; $('start').disabled = false; return; } const { device_code, user_code, verification_uri, verification_uri_complete, interval } = r.data; // With a pre-filled link, one tap opens the approval page with the code already entered. @@ -718,7 +725,7 @@ async function startDeviceFlow() { const deadline = Date.now() + 15 * 60 * 1000; const tick = async () => { if (Date.now() > deadline) { $('poll').textContent = 'Timed out. Start again.'; return; } - const p = await jsend('/api/v1/connectors/' + ID + '/link/poll', 'POST', { device_code }); + const p = await jsend('${basePath}/api/v1/connectors/' + ID + '/link/poll', 'POST', { device_code }); if (p.data.status === 'ok') { done(); return; } if (p.data.status === 'pending') { setTimeout(tick, Math.max(2, interval || 5) * 1000); return; } $('poll').textContent = p.data.error || 'Linking failed. Start again.'; @@ -729,9 +736,9 @@ async function startDeviceFlow() { ` ); -const PROGRESS = shell( +const PROGRESS = (basePath: string) => shell(basePath, 'Reading progress', - ` + `
    Reading progress

    Synced books

    All synced books with their latest progress. Removing a book deletes its synced progress and everything else stored here for it. If the same book shows up twice (two devices can identify one book differently), merge the listings and they will sync as one.

    @@ -796,7 +803,7 @@ function openMerge(doc) { $('err').textContent = ''; btn.disabled = true; btn.textContent = 'Merging…'; let r; - try { r = await jsend('/api/v1/documents/merge', 'POST', { document: doc, into: btn.dataset.into, stats: box.querySelector('[data-stats]').checked }); } + try { r = await jsend('${basePath}/api/v1/documents/merge', 'POST', { document: doc, into: btn.dataset.into, stats: box.querySelector('[data-stats]').checked }); } catch { r = { ok:false, data:{} }; } if (!r.ok) { $('err').textContent = r.data.message || 'Could not merge.'; @@ -811,7 +818,7 @@ async function unmerge(alias) { if (!confirm('Unmerge this listing? The old copy starts syncing separately again and takes its reading stats back; progress already merged stays here.')) return; $('err').textContent = ''; let r; - try { r = await jsend('/api/v1/documents/merge/' + encodeURIComponent(alias), 'DELETE'); } + try { r = await jsend('${basePath}/api/v1/documents/merge/' + encodeURIComponent(alias), 'DELETE'); } catch { r = { ok:false, data:{} }; } if (!r.ok) { $('err').textContent = r.data.message || 'Could not unmerge.'; return; } load(); @@ -829,7 +836,7 @@ async function removeBook(btn) { // fetch rejects outright when the network is down; treat that as an ordinary // failed response so the one error path below re-enables the button. let r; - try { r = await jsend('/api/v1/progress/' + encodeURIComponent(doc), 'DELETE'); } + try { r = await jsend('${basePath}/api/v1/progress/' + encodeURIComponent(doc), 'DELETE'); } catch { r = { ok:false, status:0, data:{} }; } if (!r.ok && r.status !== 404) { $('err').textContent = r.data.message || 'Could not remove this book.'; @@ -841,8 +848,8 @@ async function removeBook(btn) { } async function load() { - const r = await jget('/api/v1/progress?limit=500'); - if (r.status === 409) { location.href = '/account'; return; } + const r = await jget('${basePath}/api/v1/progress?limit=500'); + if (r.status === 409) { location.href = '${basePath}/account'; return; } if (!r.ok) { $('list').innerHTML = '

    Could not load synced books.

    '; return; } BOOKS = r.data.items || []; render(); @@ -852,9 +859,9 @@ load(); ); -const REVIEW = shell( +const REVIEW = (basePath: string) => shell(basePath, 'Matches', - ` + `
    Matches

    Matches

    Which book each of your synced titles maps to. Fix anything that matched wrong, or pick a match for the ones that didn't.

    @@ -871,33 +878,33 @@ async function jsend(u, m='PUT', body){ const r = await fetch(u,{method:m,header let CONN = null, CANDIDATES = [], BOOKS = []; (async () => { - const list = await jget('/api/v1/connectors'); - if (list.status === 409) { location.href = '/account'; return; } + const list = await jget('${basePath}/api/v1/connectors'); + if (list.status === 409) { location.href = '${basePath}/account'; return; } CONN = (list.data.connectors || []).find(c => c.id === ID); - if (!CONN) { location.href = '/account'; return; } + if (!CONN) { location.href = '${basePath}/account'; return; } $('title').textContent = CONN.name + ' matches'; if (CONN.library_refresh) { $('libtools').innerHTML = ' '; $('refr').onclick = async () => { $('refr').disabled = true; $('refrNote').textContent = 'refreshing…'; - const r = await jsend('/api/v1/connectors/' + ID + '/library/refresh', 'POST'); + const r = await jsend('${basePath}/api/v1/connectors/' + ID + '/library/refresh', 'POST'); $('refrNote').textContent = r.ok && r.data.count != null ? r.data.count + ' book(s). Send-to-Kindle docs refresh from the extension.' : 'failed: ' + (r.data.message || r.status); - const cand = await jget('/api/v1/connectors/' + ID + '/candidates'); + const cand = await jget('${basePath}/api/v1/connectors/' + ID + '/candidates'); CANDIDATES = cand.data.books || []; $('refr').disabled = false; }; } // Preload the "currently reading" candidates for quick picking. - const cand = await jget('/api/v1/connectors/' + ID + '/candidates'); + const cand = await jget('${basePath}/api/v1/connectors/' + ID + '/candidates'); CANDIDATES = cand.data.books || []; renderList(); })(); async function renderList() { - const { ok, data } = await jget('/api/v1/connectors/' + ID + '/review'); + const { ok, data } = await jget('${basePath}/api/v1/connectors/' + ID + '/review'); const el = $('list'); if (!ok) { el.innerHTML = '

    Could not load.

    '; return; } BOOKS = data.books || []; @@ -943,7 +950,7 @@ function openPicker(doc) { const asin = $('asin-' + doc).value.trim(); if (!asin) return; res.innerHTML = '

    Looking up ' + esc(asin) + '…

    '; - const r = await jsend('/api/v1/connectors/' + ID + '/lookup', 'POST', { external_id: asin }); + const r = await jsend('${basePath}/api/v1/connectors/' + ID + '/lookup', 'POST', { external_id: asin }); const b = r.data && r.data.book; res.innerHTML = r.ok && r.data.found && b ? '
    Found in your Kindle account
    ' + optionRow(doc, b) @@ -951,7 +958,7 @@ function openPicker(doc) { res.querySelectorAll('[data-choose]').forEach(bindChoose); }); box.querySelector('[data-nomatch]').onclick = async () => { - await jsend('/api/v1/connectors/' + ID + '/matches/' + doc, 'PUT', { external_id: null }); + await jsend('${basePath}/api/v1/connectors/' + ID + '/matches/' + doc, 'PUT', { external_id: null }); renderList(); }; // Auto-suggest from the whole library using the book's own title. @@ -963,7 +970,7 @@ async function runSearch(doc, q, isAuto) { if (!q) return; const res = $('results-' + doc); res.innerHTML = '
    ' + (isAuto ? 'Suggestions' : 'Results') + '

    Searching…

    '; - const r = await jget('/api/v1/connectors/' + ID + '/search?q=' + encodeURIComponent(q)); + const r = await jget('${basePath}/api/v1/connectors/' + ID + '/search?q=' + encodeURIComponent(q)); const rows = (r.data.books || []).map(c => optionRow(doc, c)).join(''); res.innerHTML = '
    ' + (isAuto ? 'Suggestions' : 'Results') + '
    ' + (rows || '

    No matches found.

    '); res.querySelectorAll('[data-choose]').forEach(bindChoose); @@ -976,14 +983,14 @@ function optionRow(doc, c) { function bindChoose(btn) { btn.onclick = async () => { const p = JSON.parse(btn.dataset.choose); - await jsend('/api/v1/connectors/' + ID + '/matches/' + p.doc, 'PUT', { external_id: p.id, external_edition: p.edition || null, title: p.title, author: p.author }); + await jsend('${basePath}/api/v1/connectors/' + ID + '/matches/' + p.doc, 'PUT', { external_id: p.id, external_edition: p.edition || null, title: p.title, author: p.author }); renderList(); }; } ` ); -const KINDLE = shell( +const KINDLE = (basePath: string) => shell(basePath, 'Connect your Kindle', `
    Beta

    Connect your Kindle

    @@ -996,7 +1003,7 @@ const KINDLE = shell(
    1

    Download the Kindle Link extension

    -

    Download crosspoint-kindle-link.zip and unzip it.

    +

    Download crosspoint-kindle-link.zip and unzip it.

    2

    Load it in Chrome

    Open chrome://extensions, turn on Developer mode, choose Load unpacked and select the unzipped folder.

    @@ -1009,10 +1016,10 @@ const KINDLE = shell(

Enabling the Kindle connector on your account…

` ); -const SPOTIFY = shell( +const SPOTIFY = (basePath: string) => shell(basePath, 'Audio progress', `

Enabling…

'); + writeFileSync(join(webDir, 'assets/app.js'), '/* app */'); +}); +afterEach(() => { + for (const { db } of servers.splice(0)) db.close(); + vi.unstubAllEnvs(); +}); +afterAll(() => rmSync(webDir, { recursive: true })); + +describe('BASE_PATH configuration', () => { + it.each(['', '/'])('treats %j as a root deployment', (basePath) => { + expect(fromEnv({ BASE_PATH: basePath }).basePath).toBe(''); + }); + it('defaults to root and normalizes trailing slashes', () => { + expect(fromEnv({}).basePath).toBe(''); + expect(fromEnv({ BASE_PATH: '/services/reading/' }).basePath).toBe('/services/reading'); + }); + it.each(['reading', '//', '//example.com/reading', '/a//b', '/a/../b', '/a/./b', '/a?b', '/a#b', '/a%2fb', "/a'b", '/a\\b'])('rejects %j', (basePath) => { + expect(() => fromEnv({ BASE_PATH: basePath })).toThrow('BASE_PATH'); + }); +}); + +describe.each(['', '/reading', '/services/reading'])('deployment at %j', (basePath) => { + function setup(legacy = false) { + vi.stubEnv('WEB_APP_DIR', webDir); + vi.stubEnv('LEGACY_WEB', legacy ? '1' : ''); + const server = makeTestApp({ basePath }); + servers.push(server); + return server.app; + } + + it('serves pages, redirects and static app assets without escaping the prefix', async () => { + const app = setup(); + if (basePath) { + const redirect = await app.request(`${basePath}?hello=world`); + expect(redirect.status).toBe(308); + expect(redirect.headers.get('location')).toBe(`${basePath}/?hello=world`); + expect((await app.request('/healthz')).status).toBe(404); + expect((await app.request(`${basePath}-other/healthz`)).status).toBe(404); + } + const html = await (await app.request(`${basePath}/`)).text(); + expect(html).toContain(`href="${basePath}/app/"`); + expect(html).toContain(`srcset="${basePath}/landing/`); + expect(html).toContain(`url(${basePath}/fonts/`); + for (const [path, target] of [ + ['/app', '/app/'], ['/account', '/app/#/settings'], + ['/review/hardcover', '/app/#/settings/hardcover'], ['/progress', '/app/'], + ]) { + expect((await app.request(basePath + path)).headers.get('location')).toBe(basePath + target); + } + expect((await app.request(`${basePath}/kindle`)).headers.get('location')) + .toBe(`${basePath}/signin?next=${basePath}/kindle#get-started`); + const index = await app.request(`${basePath}/app/`); + expect(index.status).toBe(200); + expect(await index.text()).toContain('./assets/app.js'); + const asset = await app.request(`${basePath}/app/assets/app.js`); + expect(await asset.text()).toBe('/* app */'); + expect((await app.request(`${basePath}/logo.png`)).status).toBe(200); + expect((await app.request(`${basePath}/kindle-link.zip`)).status).toBe(200); + }); + + it('serves the app at the explicit index URL', async () => { + const app = setup(); + const index = await app.request(`${basePath}/app/index.html`); + expect(index.status).toBe(200); + expect(await index.text()).toContain('./assets/app.js'); + }); + + it('supports KOSync, the extended API and API CORS under the prefix', async () => { + const app = setup(); + const headers = { 'content-type': 'application/json', 'x-auth-user': 'reader', 'x-auth-key': md5('password') }; + expect((await app.request(`${basePath}/users/create`, { + method: 'POST', headers, body: JSON.stringify({ username: 'reader', password: md5('password') }), + })).status).toBe(201); + expect((await app.request(`${basePath}/users/auth`, { headers })).status).toBe(200); + expect((await app.request(`${basePath}/api/v1/progress`, { headers })).status).toBe(200); + const login = await app.request(`${basePath}/auth/login-kosync`, { + method: 'POST', headers, body: JSON.stringify({ username: 'reader', password: 'password' }), + }); + expect(login.status).toBe(200); + expect(login.headers.get('set-cookie')).toContain(`Path=${basePath || '/'};`); + const health = await app.request(`${basePath}/healthz`, { headers: { origin: 'https://reader.test' } }); + expect((await health.json()).status).toBe('ok'); + expect(health.headers.get('access-control-allow-origin')).toBe('*'); + const preflight = await app.request(`${basePath}/api/v1/progress`, { + method: 'OPTIONS', headers: { origin: 'https://reader.test', 'access-control-request-method': 'GET' }, + }); + expect(preflight.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('scopes session cookies consistently for signup, login, logout and deletion', async () => { + const app = setup(); + const post = (path: string, body: unknown) => app.request(basePath + path, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }); + const signup = await post('/auth/signup', { handle: 'reader' }); + const cookie = signup.headers.get('set-cookie')!; + expect(cookie).toContain(`Path=${basePath || '/'};`); + const { token } = await signup.json(); + const login = await post('/auth/login', { token }); + expect(login.headers.get('set-cookie')).toContain(`Path=${basePath || '/'};`); + const headers = { cookie: cookie.split(';')[0] }; + expect((await app.request(`${basePath}/auth/me`, { headers })).status).toBe(200); + const logout = await post('/auth/logout', {}); + expect(logout.headers.get('set-cookie')!.split(';').map((s) => s.trim())).toContain(`Path=${basePath || '/'}`); + const deleted = await app.request(`${basePath}/account`, { method: 'DELETE', headers }); + expect(deleted.status).toBe(200); + expect(deleted.headers.get('set-cookie')!.split(';').map((s) => s.trim())).toContain(`Path=${basePath || '/'}`); + }); + + it('prefixes legacy links, inline scripts and reader setup URLs', async () => { + const app = setup(true); + const signup = await app.request(`${basePath}/auth/signup`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ handle: 'reader' }), + }); + const headers = { cookie: signup.headers.get('set-cookie')!.split(';')[0] }; + for (const path of ['/', '/account', '/progress', '/link/kindle', '/review/kindle', '/kindle', '/spotify']) { + const html = await (await app.request(basePath + path, { headers: path === '/' ? {} : headers })).text(); + expect(html).toContain(`href="${basePath}/favicon.png"`); + if (basePath) { + expect(html).not.toMatch(/(?:href|src|srcset)="\/(?!services\/reading\/|reading\/)/); + expect(html).not.toMatch(/(?:fetch|jget|jsend|post)\('\/(?!services\/reading\/|reading\/)/); + } + for (const [, script] of html.matchAll(/