mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 18:24:32 +00:00
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.
593 lines
21 KiB
JavaScript
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 };
|