Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

## [1.1.0] - 2026-05-23

### Breaking changes

- **Client now uses relative URLs.** A reverse proxy exposing `/api/trpc`, `/ws/collab`, and `/ws/realtime` on the same origin as the client is now required.
- Removed build-time variables `APP_SERVER_URL`, `COLLAB_SERVER_URL`, and `REALTIME_SERVER_URL` from the client Docker image and environment templates. The SPA resolves API and WebSocket endpoints from `window.location` at runtime.

### Added

- `apps/client/src/lib/endpoints.ts` — helpers `apiUrl`, `apiWsUrl`, `collabUrl`, and `realtimeUrl`.
- `ALLOWED_ORIGINS` (comma-separated) on the app-server for additional CORS origins alongside `CLIENT_URL` and `CLIENT_APP_URL`.
- Vite dev-server proxy in `quasar.config.cjs` for local development without a separate reverse proxy.
- `docker-compose.override.yml.example` — optional localhost-only exposure of backend ports for debugging.

### Changed

- Client container nginx routes `/api/trpc`, `/ws/collab`, and `/ws/realtime` to internal backends (no post-build URL injection in `docker-entrypoint.sh`).
86 changes: 85 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,93 @@ Six Docker containers:
| postgres | PostgreSQL 16 |
| keydb | KeyDB (Redis-compatible cache)|

The browser talks only to the **client** origin. Nginx inside the client container (or your external reverse proxy) forwards:

| Public path | Backend |
|--------------------|----------------------|
| `/api/trpc` | app-server `:48922` |
| `/ws/collab` | collab-server `:48923` |
| `/ws/realtime` | realtime-server `:48924` |

Backend ports are **not** published on `0.0.0.0` by default. Use `docker-compose.override.yml.example` if you need loopback-only access for debugging.

## Reverse proxy requirement

From v1.1.0 onward, the SPA builds API and WebSocket URLs from `window.location`. Your public URL must expose these paths on the **same origin** as the web app (HTTPS recommended).

### Nginx example

```nginx
location /api/trpc/ {
proxy_pass http://app-server:48922/trpc/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /ws/collab/ {
proxy_pass http://collab-server:48923/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}

location /ws/realtime {
proxy_pass http://realtime-server:48924;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
```

The included `apps/client/nginx.conf` applies the same routing when using the stock Docker client image.

### Nginx Proxy Manager (Custom Locations)

Create one proxy host for your app domain, forward `/` to the client container (`http://<client-ip>:80`), then add **Custom locations**:

| Location | Forward scheme | Forward host:port | Websockets |
|-----------------|----------------|--------------------------|------------|
| `/api/trpc` | http | `app-server:48922` | off |
| `/ws/collab` | http | `collab-server:48923` | on |
| `/ws/realtime` | http | `realtime-server:48924` | on |

For `/api/trpc`, set the path to strip/replace so upstream receives `/trpc` (NPM “Forward path” `/trpc` or equivalent). Enable “Block common exploits” only if it does not strip WebSocket upgrades on `/ws/*`.

## Configuration

Copy `template.env` to `.env`. All secrets must be replaced with unique values generated via `openssl rand -hex 32` (for hex secrets) or `openssl rand -base64 32` (for base64 keys). `CLIENT_APP_URL` must match your public HTTPS URL.
Copy `template.env` to `.env`. All secrets must be replaced with unique values generated via `openssl rand -hex 32` (for hex secrets) or `openssl rand -base64 32` (for base64 keys).

| Variable | Purpose |
|----------|---------|
| `CLIENT_APP_URL` | Public HTTPS URL of the app (cookies, CORS, emails) |
| `CLIENT_URL` | Origin used during local dev (`pnpm dev`) |
| `ALLOWED_ORIGINS` | Optional comma-separated extra CORS origins |

## Local development

`pnpm dev` runs the Quasar/Vite dev server with a built-in proxy:

- `/api/trpc` → `http://127.0.0.1:48922/trpc`
- `/ws/collab` → collab-server
- `/ws/realtime` → realtime-server

Start backends on ports `48922`, `48923`, and `48924` as before.

## Migration from versions before 1.1.0

1. Pull the new version and rebuild: `docker compose up -d --build`.
2. Remove obsolete variables from `.env`: `APP_SERVER_URL`, `COLLAB_SERVER_URL`, `REALTIME_SERVER_URL` (and any Docker build `ARG` overrides for the client).
3. Configure your reverse proxy (or use the bundled client nginx) to expose `/api/trpc`, `/ws/collab`, and `/ws/realtime` on the same domain as the SPA.
4. Set `CLIENT_APP_URL` to that public URL (e.g. `https://notes.example.com`).
5. If the app is served from an additional origin, add it to `ALLOWED_ORIGINS`.
6. Stop publishing backend ports `48922–48924` on `0.0.0.0` unless you use the optional `docker-compose.override.yml` for localhost debugging.

## Update

Expand Down
5 changes: 2 additions & 3 deletions apps/app-server/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ declare namespace NodeJS {
HOST: string;

APP_SERVER_PORT: string;
APP_SERVER_URL: string;

CLIENT_PORT: string;
CLIENT_URL: string;
CLIENT_APP_URL: string;
ALLOWED_ORIGINS?: string;

REALTIME_SERVER_PORT: string;
REALTIME_SERVER_URL: string;

COLLAB_SERVER_PORT: string;
COLLAB_SERVER_URL: string;

ACCESS_TOKEN_SECRET: string;
REFRESH_TOKEN_SECRET: string;
Expand Down
9 changes: 1 addition & 8 deletions apps/app-server/src/fastify/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,9 @@ export const fastify = once(async () => {

await fastify.register(import('@fastify/cors'), {
origin: (requestOrigin, callback) => {
// CORS origin logged only in dev
if (process.env.DEV) console.log('CORS Origin: %s', requestOrigin);

if (
process.env.DEV ||
requestOrigin === undefined ||
requestOrigin === process.env.CLIENT_URL ||
requestOrigin === 'capacitor://deepnotes.app' ||
requestOrigin === 'http://localhost'
) {
if (isAllowedCorsOrigin(requestOrigin)) {
callback(null, true);
} else {
callback(new Error('Not allowed'), false);
Expand Down
26 changes: 26 additions & 0 deletions apps/app-server/src/utils/allowed-origins.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { isAllowedCorsOrigin } from './allowed-origins';

describe('isAllowedCorsOrigin', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('allows CLIENT_APP_URL and ALLOWED_ORIGINS when not in dev', () => {
vi.stubEnv('DEV', '');
vi.stubEnv('CLIENT_URL', 'http://localhost:60379');
vi.stubEnv('CLIENT_APP_URL', 'https://notes.example.com');
vi.stubEnv('ALLOWED_ORIGINS', 'https://alt.example.com, https://other.example.com');

expect(isAllowedCorsOrigin('https://notes.example.com')).toBe(true);
expect(isAllowedCorsOrigin('https://alt.example.com')).toBe(true);
expect(isAllowedCorsOrigin('https://other.example.com')).toBe(true);
expect(isAllowedCorsOrigin('https://unknown.example.com')).toBe(false);
});

it('allows any origin in dev', () => {
vi.stubEnv('DEV', 'true');
expect(isAllowedCorsOrigin('https://anything.example.com')).toBe(true);
});
});
29 changes: 29 additions & 0 deletions apps/app-server/src/utils/allowed-origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const STATIC_ALLOWED_ORIGINS = new Set([
'capacitor://deepnotes.app',
'http://localhost',
]);

export function isAllowedCorsOrigin(requestOrigin: string | undefined): boolean {
if (process.env.DEV) {
return true;
}

if (requestOrigin === undefined) {
return true;
}

const allowed = new Set(STATIC_ALLOWED_ORIGINS);

for (const value of [
process.env.CLIENT_URL,
process.env.CLIENT_APP_URL,
...(process.env.ALLOWED_ORIGINS?.split(',') ?? []),
]) {
const trimmed = value?.trim();
if (trimmed) {
allowed.add(trimmed);
}
}

return allowed.has(requestOrigin);
}
8 changes: 0 additions & 8 deletions apps/client/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@ RUN corepack enable && corepack prepare pnpm@10.30.1 --activate
WORKDIR /build
COPY ./ ./

# SPA needs these at build time (inlined in bundle). Use same host as client so browser can reach backends.
ARG APP_SERVER_URL=http://localhost:48922/trpc
ARG COLLAB_SERVER_URL=ws://localhost:48923
ARG REALTIME_SERVER_URL=ws://localhost:48924
ENV APP_SERVER_URL=${APP_SERVER_URL}
ENV COLLAB_SERVER_URL=${COLLAB_SERVER_URL}
ENV REALTIME_SERVER_URL=${REALTIME_SERVER_URL}

RUN pnpm install
RUN pnpm run repo:build
RUN pnpm --filter @deepnotes/client run build:spa
Expand Down
16 changes: 1 addition & 15 deletions apps/client/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,22 +1,8 @@
#!/bin/sh
set -e

if ! echo "$CLIENT_APP_URL" | grep -qE '^https?://[a-zA-Z0-9._-]+(:[0-9]+)?$'; then
if [ -n "$CLIENT_APP_URL" ] && ! echo "$CLIENT_APP_URL" | grep -qE '^https?://[a-zA-Z0-9._-]+(:[0-9]+)?$'; then
echo "[warn] CLIENT_APP_URL looks invalid: $CLIENT_APP_URL"
fi

# Build absolute WebSocket URLs from CLIENT_APP_URL
if [ -n "$CLIENT_APP_URL" ]; then
# Convert http(s)://host to ws(s)://host
WS_BASE=$(echo "$CLIENT_APP_URL" | sed 's|^http|ws|')

echo "Injecting URLs: APP=$CLIENT_APP_URL, WS_BASE=$WS_BASE"

find /usr/share/nginx/html/assets -name '*.js' -exec sed -i \
-e "s|http://localhost:48922/trpc|${CLIENT_APP_URL}/trpc|g" \
-e "s|ws://localhost:48923|${WS_BASE}/collab|g" \
-e "s|ws://localhost:48924|${WS_BASE}/realtime|g" \
{} +
fi

exec nginx -g 'daemon off;'
20 changes: 10 additions & 10 deletions apps/client/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ server {
try_files $uri $uri/ /index.html;
}

location = /trpc {
return 301 /trpc/;
location = /api/trpc {
return 301 /api/trpc/;
}

location /trpc/ {
location /api/trpc/ {
proxy_pass http://app-server:48922/trpc/;
proxy_http_version 1.1;
proxy_set_header Host $host;
Expand All @@ -29,27 +29,27 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_read_timeout 86400s;
}

location /realtime {
location /ws/realtime {
rewrite ^/ws/realtime(.*)$ $1 break;
proxy_pass http://realtime-server:48924;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_read_timeout 86400s;
}

location /collab/ {
rewrite ^/collab/(.*)$ /$1 break;
proxy_pass http://collab-server:48923;
location /ws/collab/ {
proxy_pass http://collab-server:48923/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_read_timeout 86400s;
}
}
2 changes: 1 addition & 1 deletion apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@deepnotes/client",
"description": "DeepNotes is an open source, end-to-end encrypted infinite canvas tool with deep page nesting and realtime collaboration. Create mind maps, diagrams, kanban boards, and more.",
"homepage": "https://deepnotes.app",
"version": "1.0.25",
"version": "1.1.0",
"author": "Gustavo Toyota <gustavottoyota@gmail.com>",
"dependencies": {
"@deeplib/data": "workspace:*",
Expand Down
18 changes: 18 additions & 0 deletions apps/client/quasar.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,24 @@ module.exports = configure(function (ctx) {
open: false, // opens browser window automatically
port: port,
host: '0.0.0.0',
proxy: {
'/api/trpc': {
target: 'http://127.0.0.1:48922',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api\/trpc/, '/trpc'),
},
'/ws/collab': {
target: 'http://127.0.0.1:48923',
ws: true,
rewrite: (path) => path.replace(/^\/ws\/collab/, '') || '/',
},
'/ws/realtime': {
target: 'http://127.0.0.1:48924',
ws: true,
rewrite: (path) => path.replace(/^\/ws\/realtime/, '') || '/',
},
},
},

// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { groupMemberNames } from 'src/code/pages/computed/group-member-names';
import { groupNames } from 'src/code/pages/computed/group-names';
import { createNotifications } from 'src/code/pages/utils';
import { createWebsocketRequest } from 'src/code/utils/websocket-requests';
import { apiWsUrl } from 'src/lib/endpoints';

function noopStep3(_input: unknown) {}

Expand All @@ -16,10 +17,7 @@ export async function changeUserRole(input: {
role: GroupRoleID;
}) {
const { promise } = createWebsocketRequest({
url: `${process.env.APP_SERVER_URL.replaceAll(
'http',
'ws',
)}/groups.changeUserRole`,
url: apiWsUrl('/groups.changeUserRole'),

steps: [step1, step2, noopStep3],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createKeyring } from '@stdlib/crypto';
import { textToBytes } from '@stdlib/misc';
import { createNotifications } from 'src/code/pages/utils';
import { createWebsocketRequest } from 'src/code/utils/websocket-requests';
import { apiWsUrl } from 'src/lib/endpoints';

function noopStep3(_input: unknown) {}

Expand All @@ -14,10 +15,7 @@ export async function acceptJoinInvitation(input: {
userName: string;
}) {
const { promise } = createWebsocketRequest({
url: `${process.env.APP_SERVER_URL.replaceAll(
'http',
'ws',
)}/groups.joinInvitations.accept`,
url: apiWsUrl('/groups.joinInvitations.accept'),

steps: [step1, step2, noopStep3],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { groupMemberNames } from 'src/code/pages/computed/group-member-names';
import { groupNames } from 'src/code/pages/computed/group-names';
import { createNotifications } from 'src/code/pages/utils';
import { createWebsocketRequest } from 'src/code/utils/websocket-requests';
import { apiWsUrl } from 'src/lib/endpoints';

function noopStep3(_input: unknown) {}

Expand All @@ -22,10 +23,7 @@ export async function cancelJoinInvitation(input: {
]);

const { promise } = createWebsocketRequest({
url: `${process.env.APP_SERVER_URL.replaceAll(
'http',
'ws',
)}/groups.joinInvitations.cancel`,
url: apiWsUrl('/groups.joinInvitations.cancel'),

steps: [step1, step2, noopStep3],
});
Expand Down
Loading
Loading