Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
```

Expand All @@ -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) |
Expand All @@ -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/).
Expand Down
3 changes: 2 additions & 1 deletion app/src/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions app/src/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LinkForm session={prefixed} conn={spotify} onLinked={vi.fn()} />)
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', () => {
Expand Down
6 changes: 5 additions & 1 deletion app/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion app/src/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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' }] }))
Expand Down
1 change: 1 addition & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions nix/module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; ";
Expand Down Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
23 changes: 15 additions & 8 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +28,8 @@ export interface AppOptions {
}

export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono<AppEnv> {
config = { ...config, basePath: normalizeBasePath(config.basePath) };
const { basePath } = config;
const app = new Hono<AppEnv>();
const refreshProgress = createProgressRefresh(db, opts.connectorTransport);

Expand All @@ -51,12 +53,12 @@ export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono<A
// The full CrossPoint Sync app (same build as the phone/desktop app) at /app/.
// WEB_APP_DIR is set in the Docker image; locally it falls back to app/dist.
const webApp = process.env.WEB_APP_DIR ?? 'app/dist';
app.get('/app', (c) => 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');
Expand All @@ -65,8 +67,8 @@ export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono<A
);

// Web UI (landing / account pages).
app.route('/', webRoutes());
app.route('/', oauthCallbackRoutes(db, opts.connectorTransport));
app.route('/', webRoutes(undefined, basePath));
app.route('/', oauthCallbackRoutes(db, opts.connectorTransport, basePath));

// kosync-compatible API at the root - stock KOReader and current CrossPoint
// firmware work by changing only the server URL.
Expand All @@ -85,13 +87,18 @@ export function createApp(db: DB, config: Config, opts: AppOptions = {}): Hono<A
v1.route('/', clippingRoutes(db));
v1.route('/', statsRoutes(db, opts.connectorTransport));
v1.route('/', documentRoutes(db, opts.connectorTransport));
v1.route('/', connectorRoutes(db, opts.connectorTransport, config.trustProxy));
v1.route('/', connectorRoutes(db, opts.connectorTransport, config.trustProxy, basePath));
v1.route('/', meRoutes(db));
// Self-host-only Amazon device registration (off unless explicitly enabled).
if (config.kindleServerRegistration) {
v1.route('/', kindleRegisterRoutes(db, opts.connectorTransport, config.trustProxy));
}
app.route('/api/v1', v1);

return app;
const root = new Hono<AppEnv>();
// 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;
}
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion src/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function accountRoutes(db: DB, config: Config): Hono<AppEnv> {
.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 });
});

Expand Down
12 changes: 6 additions & 6 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ function masterToken(accountId: number): string {
return `xp1_${accountId}_${crypto.randomBytes(16).toString('hex')}`;
}

function setSessionCookie(c: Context<AppEnv>, accountId: number) {
function setSessionCookie(c: Context<AppEnv>, 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,
});
}
Expand Down Expand Up @@ -76,7 +76,7 @@ export function authRoutes(db: DB, config: Config): Hono<AppEnv> {
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 });
});

Expand All @@ -98,7 +98,7 @@ export function authRoutes(db: DB, config: Config): Hono<AppEnv> {
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 });
});

Expand Down Expand Up @@ -141,12 +141,12 @@ export function authRoutes(db: DB, config: Config): Hono<AppEnv> {
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 });
});

Expand Down
Loading