'use strict'; const http = require('http'); const path = require('path'); const { sessionIdFromHeaders } = require('./auth/cookies'); const templates = require('./templates'); const { send, makeSendHtml, redirect, fileExists, serveFile, ALL_NOTES_FOLDER_ID, TRASH_FOLDER_ID, allNotesFolder, trashFolder, selectedFolderForNav, normalizeStoredFolderId, } = require('./routes/_helpers'); 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 routeApi = require('./routes/api'); 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, debug = false, } = options; const isJoplockAdmin = user => !!( adminService && adminEmail && user && user.email === adminEmail && user.isAdmin ); const log = debug ? (...args) => 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 }; }; const navData = async userId => { const [folders, counts, vaultIds] = await Promise.all([ itemService.foldersByUserId(userId), itemService.folderNoteCountsByUserId(userId), vaultService ? vaultService.getVaultFolderIdSet(userId) : Promise.resolve(new Set()), ]); // 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 }; }; 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); }; 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); }; 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, userSettings, saveLastNoteState, plainNoteTitle, ensureStarterContent, upstreamRequestContext, isJoplockAdmin, // services sessionService, itemService, settingsService, historyService, itemWriteService, adminService, vaultService, backupService, recoveryService, maintenance, database, // config joplinPublicBasePath, joplinServerOrigin, configuredPublicUrl, ignoreAdminMfa, adminEmail, debug, }; // 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, routeFragments, routeApi, ]; for (const route of routes) { if (await route.handle(url, request, response, ctx)) return; } // Joplin Server proxy if (joplinPublicBasePath && (url.pathname === joplinPublicBasePath || url.pathname.startsWith(`${joplinPublicBasePath}/`))) { 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); try { await ensureStarterContent(auth.user, request); } catch {} let { folders, counts } = await navData(auth.user.id); let selectedFolderId = ''; let selectedNoteId = ''; let selectedNoteContextFolderId = null; let editorContent = '