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.
- +Start syncing in a minute
Free to use and open source. Run it here, or on your own server with one Docker image.
- +read on every device
@@ -262,28 +263,28 @@ const LANDING = shell(Services you can link
more on the way, and it's all open source
@@ -305,7 +306,7 @@ const LANDING = shell(
Reading progress
- +Linked services
Link
Set up with the CrossPoint Kindle Link extension (recommended):
' + '- '
- + '
- Download crosspoint-kindle-link.zip and unzip it. ' + + '
- Download crosspoint-kindle-link.zip and unzip it. ' + '
- Open
chrome://extensionsand turn on Developer mode. '
+ ' - Choose Load unpacked and select the unzipped folder. ' + '
- 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. ' @@ -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', - ` + `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.
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
Which book each of your synced titles maps to. Fix anything that matched wrong, or pick a match for the ones that didn't.
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 ? '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 = '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', `Connect your Kindle
@@ -996,7 +1003,7 @@ const KINDLE = shell(Download the Kindle Link extension
-Download crosspoint-kindle-link.zip and unzip it.
Download crosspoint-kindle-link.zip and unzip it.
Load it in Chrome
Open chrome://extensions, turn on Developer mode, choose Load unpacked and select the unzipped folder.
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(/

CrossPoint Sync
CrossPoint Sync