joplock/app/createServer.js
igor cf2c39ce90 perf: compress static assets, add ETags, make versioned assets immutable
The shipped JS and CSS were served uncompressed (2.5 MB raw; the Nginx
Proxy Manager in front only compresses text/html), with max-age=300 and
no validator, so after five idle minutes every launch re-downloaded all
of it: 2.3s on 9 Mbps and 7.8s on 1.6 Mbps to a usable screen.

serveFile now negotiates brotli/gzip (computed once, async, cached per
file+mtime and warmed at startup), sends Vary and a per-encoding content
hash ETag with 304 support, and marks ?v=<asset version> URLs immutable.
The script tags that had no version (htmx, turndown, prism, TinyMCE, hljs,
html-docx) now carry one. Service worker and manifest stay no-cache.

Measured after: ~0.9 MB over the wire, 1.2s and 2.6s on the same two
links. Unit tests run a real HTTP server around serveFile.
2026-10-13 14:49:38 +13:00

593 lines
21 KiB
JavaScript

'use strict';
const http = require('http');
const path = require('path');
const { sessionIdFromHeaders } = require('./auth/cookies');
const { createRateLimitService } = require('./auth/rateLimitService');
const templates = require('./templates');
const { assetVersion } = require('./assetVersion');
const {
send, makeSendHtml, redirect, fileExists, serveFile, serveServiceWorker, warmStaticCache,
ALL_NOTES_FOLDER_ID, TRASH_FOLDER_ID,
allNotesFolder, trashFolder, selectedFolderForNav, normalizeStoredFolderId,
} = require('./routes/_helpers');
const { inspectAndGuard } = require('./proxy/vaultProxyGuard');
const { inspectAndGuard: shareInspectAndGuard } = require('./proxy/shareProxyGuard');
const routeAuth = require('./routes/auth');
const routeRecovery = require('./routes/recovery');
const routeSettings = require('./routes/settings');
const routeAdmin = require('./routes/admin');
const routeHistory = require('./routes/history');
const routeResources = require('./routes/resources');
const routeFragments = require('./routes/fragments');
const routeMobile = require('./routes/mobile');
const routeShares = require('./routes/shares');
const routeDiag = require('./routes/diag');
const { createFolderOps } = require('./items/folderOps');
const { sortFoldersByRecent, applyPinnedFirst } = require('./items/folderTree');
const routeApi = require('./routes/api');
const { handleExportDocx, handleExportPdf, handleExportHtml } = routeApi;
const createServer = options => {
const {
publicDir,
joplinPublicBasePath,
joplinPublicBaseUrl,
joplinServerPublicUrl,
joplinServerOrigin,
sessionService,
itemService,
settingsService,
historyService,
itemWriteService,
adminService = null,
adminEmail = '',
ignoreAdminMfa = false,
database = null,
vaultService = null,
backupService = null,
recoveryService = null,
rateLimitService = createRateLimitService(),
sessionCookieMaxAge = 31536000,
debug = false,
joplockVersion = '',
} = options;
const isJoplockAdmin = user => !!(
adminService && adminEmail && user && user.email === adminEmail && user.isAdmin
);
// Effective debug logging state. Starts from the env DEBUG default, but can be
// overridden at runtime by the admin setting appSettings.debugLogging
// (null = inherit env default, true/false = explicit override). Kept in memory
// and refreshed on startup + whenever the admin saves security settings.
const debugDefault = !!debug;
let effectiveDebug = debugDefault;
const isDebug = () => effectiveDebug;
const refreshDebugLogging = async () => {
try {
if (settingsService && settingsService.appSettings) {
const app = await settingsService.appSettings();
effectiveDebug = (app && app.debugLogging !== null && app.debugLogging !== undefined)
? !!app.debugLogging
: debugDefault;
}
} catch { effectiveDebug = debugDefault; }
return effectiveDebug;
};
// Initialize from DB (fire-and-forget; env default applies until it resolves).
void refreshDebugLogging();
const log = (...args) => { if (effectiveDebug) process.stdout.write(`[joplock] ${args.join(' ')}\n`); };
let maintenanceReason = '';
const maintenance = {
isEnabled: () => !!maintenanceReason,
reason: () => maintenanceReason,
enable: reason => { maintenanceReason = `${reason || 'maintenance'}`; },
disable: () => { maintenanceReason = ''; },
};
const configuredPublicUrl = new URL(joplinPublicBaseUrl);
const configuredServerPublicUrl = new URL(joplinServerPublicUrl);
const authenticatedUser = async (request, options = {}) => {
const sessionId = sessionIdFromHeaders(request.headers);
if (!sessionId) return { error: 'Missing session', user: null };
const user = await sessionService.userBySessionId(sessionId);
if (!user) return { error: 'Invalid or expired session', user: null };
// Enforce activity-based session timeout if enabled
if (settingsService) {
const settings = await settingsService.settingsByUserId(user.id);
if (settings.autoLogout && settings.autoLogoutMinutes > 0) {
const lastSeen = await sessionService.getLastSeen(sessionId);
const timeoutMs = settings.autoLogoutMinutes * 60 * 1000;
const graceMs = 10000; // 10s grace for latency
const age = lastSeen !== null ? Date.now() - lastSeen : null;
log(`session check: lastSeen=${lastSeen} age=${age}ms timeout=${timeoutMs}ms session=${sessionId.slice(0,8)}`);
if (lastSeen !== null && age > timeoutMs + graceMs) {
log(`session timeout: expiring session ${sessionId.slice(0,8)}`);
await sessionService.deleteSession(sessionId);
return { error: 'Session expired due to inactivity', user: null };
}
}
}
// Touch session on real user activity (not heartbeat/connectivity pings)
if (!options.isHeartbeat) {
await sessionService.touchSession(sessionId);
}
return { error: null, user };
};
// Notebook order for the lists people browse (sidebar, mobile folders screen). "Most
// recent" and pinned notebooks are per-user settings; the default is the alphabetical order the database
// returns. Pickers (editor notebook select, move / new-notebook dialogs) always use
// the alphabetical order, so reordering here never affects them (see inAlphabeticalOrder).
const orderFoldersForUser = async (userId, folders, knownSettings) => {
if (!folders || !folders.length) return folders;
let settings = knownSettings;
if (!settings && settingsService) settings = await settingsService.settingsByUserId(userId).catch(() => null);
if (!settings) return folders;
let ordered = folders;
if (settings.folderSort === 'recent') {
const activity = typeof itemService.folderActivityByUserId === 'function'
? await itemService.folderActivityByUserId(userId).catch(() => new Map())
: new Map();
ordered = sortFoldersByRecent(folders, activity);
}
// pinned notebooks come first, whichever order is active
if (Array.isArray(settings.pinnedFolders) && settings.pinnedFolders.length) {
ordered = applyPinnedFirst(ordered, settings.pinnedFolders);
}
return ordered;
};
const navData = async userId => {
const [rawFolders, counts, vaultIds] = await Promise.all([
itemService.foldersByUserId(userId),
itemService.folderNoteCountsByUserId(userId),
vaultService ? vaultService.getVaultFolderIdSet(userId) : Promise.resolve(new Set()),
]);
const folders = await orderFoldersForUser(userId, rawFolders);
// Mark vault folders
const markedFolders = folders.map(f => ({ ...f, isVault: !!(f.isVault || vaultIds.has(f.id)) }));
const allFolders = [allNotesFolder(counts.get('__all__') || 0)].concat(markedFolders, [trashFolder(counts.get('__trash__') || 0)]);
return { folders: allFolders, counts, vaultIds };
};
// Notebook create/move/delete rules shared by the fragment and JSON routes.
const folderOps = createFolderOps({
itemService,
itemWriteService,
vaultService,
shareSync: database ? routeShares.createShareSync({ itemService, itemWriteService, database }) : null,
});
const upstreamRequestContext = _request => ({
host: configuredServerPublicUrl.host,
protocol: configuredServerPublicUrl.protocol.replace(':', ''),
});
const userSettings = async userId => settingsService ? settingsService.settingsByUserId(userId) : null;
const saveLastNoteState = async (userId, currentSettings, noteId, folderId) => {
if (!settingsService) return currentSettings;
return settingsService.saveSettings(userId, {
...currentSettings,
lastNoteId: `${noteId || ''}`,
lastNoteFolderId: normalizeStoredFolderId(folderId),
});
};
const plainNoteTitle = title => templates.stripMarkdownForTitle(`${title || ''}`) || 'Untitled note';
const ensureStarterContent = async (user, request) => {
const folders = await itemService.foldersByUserId(user.id);
if (folders.length > 0) return;
const ctx = upstreamRequestContext(request);
const examplesFolder = await itemWriteService.createFolder(user.sessionId, { title: 'Examples' }, ctx);
await itemWriteService.createNote(user.sessionId, {
title: 'Start Here',
body: `# Welcome to Joplock
This notebook is here so a fresh install has something to open and edit right away.
## What Joplock is
- Open source: [abort-retry-ignore/joplock](https://github.com/abort-retry-ignore/joplock)
- Thin web UI for Joplin Server
- Mobile friendly and installable as PWA
- Light on memory and system resources
- Sync is automatic and usually near instant
## Security and logout
- Browser stays thin and untrusted
- Notes and attachments are not cached for offline use
- Logout clears client-visible state and cached shell data as much as browser allows
## Editing notes
- Click this note to open it.
- Use the toolbar for headings, bold, lists, links, code, and clear formatting.
- Switch between Markdown and Preview mode with the editor buttons.
- Preview mode is editable too.
## Saving changes
- Joplock autosaves after you stop typing for a moment.
- The status near the editor shows when a note is edited, saved, or offline.
## Creating notes and notebooks
- Use **+ Notebook** to create a new notebook.
- Use the **+** button on a notebook row to create a note inside it.
- Search from the left panel to find notes quickly.
## Admin and users
- If this deployment defines \`JOPLOCK_ADMIN_EMAIL\` and \`JOPLOCK_ADMIN_PASSWORD\`, that user gets the Admin tab in Settings.
- The Admin tab can create users and enable or disable MFA for users.
## MFA
- Each user manages their own MFA in **Settings -> Security**.
- Admins can also manage MFA for users from the Admin tab.
- If \`IGNORE_ADMIN_MFA=true\`, the configured deployment admin can sign in without MFA.
## Markdown examples
- **Bold**
- *Italic*
- \`Inline code\`
- [Link to Joplin](https://joplinapp.org)
- [ ] Checkbox item
\`\`\`
Code block example
\`\`\`
`,
parentId: examplesFolder.id,
}, ctx);
await itemWriteService.createNote(user.sessionId, {
title: 'AI Features',
body: `# AI Features
Joplock can call an AI provider (OpenRouter, or any OpenAI-compatible API) to help you write. All AI features are opt-in and configured per user.
## Setup
- Open **Settings -> AI**.
- Create a provider profile with your API URL, model, and API key.
- Mark one profile **Active**.
- No AI features work until at least one profile is configured and active.
## Prose autocomplete
- **Ctrl-Space** (or **Cmd-Space** on Mac) inside a note asks the active AI profile to continue your text based on the surrounding context.
- Works in both Markdown and Preview (rendered) mode.
- A popup shows the suggestion; **Enter** or **Tab** accepts, **Esc** or any other key dismisses.
## Expander triggers
- Configure short trigger strings in **Settings -> Expander**.
- Two kinds:
- **Text**: expands the trigger into a static snippet.
- **AI**: removes the trigger and runs a prose completion, optionally with a specific AI profile.
- Type the trigger anywhere in a note and it fires automatically.
## /ask slash command
- On a line by itself, type \`/ask <your question>\` and press **Enter**.
- The line is replaced with a \`⏳ Asking…\` placeholder while the AI answers.
- The answer replaces the placeholder inline. There is no popup — press **Ctrl+Z** to reject.
- Example: \`/ask what is the capital of Turkey?\`
## Vault / encrypted notes
- All AI features are **disabled inside vault/encrypted notes**.
- Plaintext content is never sent to a third-party AI provider from an encrypted note.
## Privacy note
- Prose autocomplete and \`/ask\` send the surrounding note context (capped) to whichever provider you configured. Only use providers you trust with your data.
`,
parentId: examplesFolder.id,
}, ctx);
};
const proxyToJoplinServer = (request, response, url) => {
const targetPath = joplinPublicBasePath ? (url.pathname.replace(joplinPublicBasePath, '') || '/') : url.pathname;
const targetUrl = new URL(joplinServerOrigin);
const headers = { ...request.headers };
headers.host = configuredServerPublicUrl.host;
delete headers.origin;
delete headers.referer;
headers['x-forwarded-host'] = configuredServerPublicUrl.host;
headers['x-forwarded-proto'] = configuredServerPublicUrl.protocol.replace(':', '');
const upstreamRequest = http.request({
hostname: targetUrl.hostname,
port: targetUrl.port,
path: targetPath + url.search,
method: request.method,
headers,
}, upstreamResponse => {
const responseHeaders = { ...upstreamResponse.headers };
if (responseHeaders.location) {
const location = responseHeaders.location;
if (location === '/' || (joplinPublicBasePath && (location === `${joplinPublicBasePath}` || location === `${joplinPublicBasePath}/`))) {
responseHeaders.location = '/';
} else if (joplinPublicBasePath && location.startsWith('/')) {
responseHeaders.location = `${joplinPublicBasePath}${location}`;
}
}
response.writeHead(upstreamResponse.statusCode || 502, responseHeaders);
upstreamResponse.pipe(response);
});
upstreamRequest.on('error', error => {
send(response, 502, `Upstream Joplin Server proxy error: ${error.message}`, {
'Content-Type': 'text/plain; charset=utf-8',
});
});
request.pipe(upstreamRequest);
};
// gzip/brotli the app's own assets once, off the event loop (see serveFile)
warmStaticCache(publicDir);
return http.createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
const reqStart = Date.now();
log(`${request.method} ${url.pathname}${url.search}`);
const origEnd = response.end.bind(response);
response.end = function (...args) {
log(`${request.method} ${url.pathname} -> ${response.statusCode} (${Date.now() - reqStart}ms)`);
return origEnd(...args);
};
const sendHtml = makeSendHtml(request, log);
if (maintenance.isEnabled() && backupService) {
const job = backupService.currentStatus();
if (job && job.type === 'restore' && job.state === 'completed') {
maintenance.disable();
}
}
const allowDuringMaintenance = url.pathname.startsWith('/recovery') || url.pathname === '/health';
if (maintenance.isEnabled() && !allowDuringMaintenance) {
sendHtml(response, 503, templates.recoveryPage({
isAuthenticated: false,
recoveryEnabled: !!(recoveryService && recoveryService.isEnabled()),
maintenanceMode: true,
activeOperation: maintenance.reason(),
error: 'Joplock is in maintenance mode. Use /recovery for backup or restore operations.',
}));
return;
}
// Shared context passed to all route handlers
const ctx = {
sendHtml,
templates,
authenticatedUser,
navData,
orderFoldersForUser,
folderOps,
userSettings,
saveLastNoteState,
plainNoteTitle,
ensureStarterContent,
upstreamRequestContext,
isJoplockAdmin,
// services
sessionService,
itemService,
settingsService,
historyService,
itemWriteService,
adminService,
vaultService,
backupService,
recoveryService,
rateLimitService,
maintenance,
database,
// config
joplinPublicBasePath,
joplinServerOrigin,
joplinServerPublicUrl,
configuredPublicUrl,
ignoreAdminMfa,
adminEmail,
sessionCookieMaxAge,
joplockVersion,
debug: effectiveDebug,
isDebug,
refreshDebugLogging,
};
// Health check
if (url.pathname === '/health') {
send(response, 200, 'ok', { 'Content-Type': 'text/plain; charset=utf-8' });
return;
}
// Route handlers (order matters — first match wins)
const routes = [
routeRecovery,
routeAuth,
routeSettings,
routeAdmin,
routeHistory,
routeResources,
routeMobile,
routeDiag,
routeFragments,
routeShares,
routeApi,
];
for (const route of routes) {
if (await route.handle(url, request, response, ctx)) return;
}
// Export routes (separate from main API)
if (await handleExportDocx(url, request, response, ctx)) return;
if (await handleExportPdf(url, request, response, ctx)) return;
if (await handleExportHtml(url, request, response, ctx)) return;
// Joplin Server proxy
if (joplinPublicBasePath && (url.pathname === joplinPublicBasePath || url.pathname.startsWith(`${joplinPublicBasePath}/`))) {
// Vault enforcement: inspect note writes and deletes before proxying.
const strippedPathname = joplinPublicBasePath
? (url.pathname.replace(joplinPublicBasePath, '') || '/')
: url.pathname;
const guardResult = await inspectAndGuard(request, strippedPathname, {
vaultService,
itemService,
authenticatedUser,
log,
});
if (guardResult.action === 'reject') {
send(response, guardResult.status, guardResult.message, {
'Content-Type': 'text/plain; charset=utf-8',
});
return;
}
// Share enforcement: block non-owner writes to shared items
const shareResult = await shareInspectAndGuard(request, strippedPathname, {
itemService, authenticatedUser, log,
});
if (shareResult.action === 'reject') {
send(response, shareResult.status, shareResult.message, {
'Content-Type': 'text/plain; charset=utf-8',
});
return;
}
if (guardResult.action === 'allow' && guardResult.buffer !== null) {
// Body was buffered — replay it as a Readable so the proxy can
// pipe it to the upstream request.
const { Readable } = require('stream');
const fakeRequest = Object.assign(Readable.from(guardResult.buffer), {
headers: request.headers,
method: request.method,
});
proxyToJoplinServer(fakeRequest, response, url);
return;
}
if (guardResult.action === 'allow' && guardResult.buffer === null) {
// DELETE with no body — stream original request (no body consumed)
proxyToJoplinServer(request, response, url);
return;
}
// action === 'stream' (not inspected, or body over cap) — stream as-is
proxyToJoplinServer(request, response, url);
return;
}
// SSR full page (GET /)
const relativePath = url.pathname === '/' ? '/index.html' : url.pathname;
if (relativePath === '/index.html') {
try {
const auth = await authenticatedUser(request);
if (auth.error || !auth.user) {
redirect(response, '/login');
return;
}
const settings = await userSettings(auth.user.id);
let maxUploadMb = 200;
try {
if (settingsService && settingsService.appSettings) {
const app = await settingsService.appSettings();
if (app && Number.isFinite(app.maxUploadMb)) maxUploadMb = app.maxUploadMb;
}
} catch { /* keep default */ }
try { await ensureStarterContent(auth.user, request); } catch {}
let { folders, counts } = await navData(auth.user.id);
let selectedFolderId = '';
let selectedNoteId = '';
let selectedNoteContextFolderId = null;
let editorContent = '<div class="editor-empty">Select a note</div>';
let mobileStartup = null;
let mobileEditorContent = '';
if (settings && settings.resumeLastNote && settings.lastNoteId) {
const resumed = await itemService.noteByUserIdAndJopId(auth.user.id, settings.lastNoteId, { deleted: 'all' });
const resumedFolder = resumed ? folders.find(f => f.id === resumed.parentId) : null;
const blockedResume = !!(resumed && (resumed.deletedTime || resumed.isEncrypted || (resumedFolder && resumedFolder.isVault)));
if (resumed && !blockedResume) {
const noteFolderId = resumed.parentId || '';
selectedFolderId = noteFolderId;
selectedNoteId = resumed.id;
selectedNoteContextFolderId = noteFolderId || null;
editorContent = templates.editorFragment(resumed, folders, noteFolderId);
mobileEditorContent = templates.mobileEditorFragment(resumed, folders, noteFolderId);
mobileStartup = {
folderId: noteFolderId,
folderTitle: (folders.find(f => f.id === noteFolderId) || {}).title || 'Notes',
noteId: resumed.id,
noteTitle: plainNoteTitle(resumed.title),
};
} else if (settings.lastNoteId || settings.lastNoteFolderId) {
await settingsService.saveSettings(auth.user.id, { ...settings, lastNoteId: '', lastNoteFolderId: '' });
}
}
sendHtml(response, 200, templates.layoutPage({ debug: effectiveDebug,
user: auth.user,
settings,
maxUploadMb,
mobileStartup,
mobileEditorContent,
navContent: templates.navigationFragment(folders, counts, selectedFolderId, selectedNoteId, '', selectedNoteContextFolderId),
editorContent,
joplinBasePath: joplinPublicBasePath,
joplockVersion,
}));
} catch (err) {
process.stderr.write(`[joplock] GET / SSR error: ${err && err.stack ? err.stack : err}\n`);
sendHtml(response, 200, templates.layoutPage({ debug: effectiveDebug, user: null, joplinBasePath: joplinPublicBasePath, joplockVersion }));
}
return;
}
// Static files
const filePath = path.join(publicDir, relativePath.replace(/^\/+/, ''));
if (filePath.startsWith(publicDir) && fileExists(filePath) && !relativePath.endsWith('/')) {
if (relativePath === '/service-worker.js') serveServiceWorker(response, filePath, assetVersion());
else serveFile(response, filePath, request, { versioned: url.searchParams.has('v') });
return;
}
// Fallback: SSR page for unknown paths
try {
const auth = await authenticatedUser(request);
if (auth.error || !auth.user) {
redirect(response, '/login');
return;
}
const settings = await userSettings(auth.user.id);
const { folders, counts } = await navData(auth.user.id);
sendHtml(response, 200, templates.layoutPage({ debug: effectiveDebug,
user: auth.user,
settings,
navContent: templates.navigationFragment(folders, counts, '', ''),
joplinBasePath: joplinPublicBasePath,
joplockVersion,
}));
} catch {
redirect(response, '/login');
}
});
};
module.exports = { createServer };