mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-05-23 12:58:44 +00:00
404 lines
14 KiB
JavaScript
404 lines
14 KiB
JavaScript
'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 = '<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,
|
|
user: auth.user,
|
|
settings,
|
|
mobileStartup,
|
|
mobileEditorContent,
|
|
navContent: templates.navigationFragment(folders, counts, selectedFolderId, selectedNoteId, '', selectedNoteContextFolderId),
|
|
editorContent,
|
|
joplinBasePath: joplinPublicBasePath,
|
|
}));
|
|
} catch {
|
|
sendHtml(response, 200, templates.layoutPage({ debug, user: null, joplinBasePath: joplinPublicBasePath }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Static files
|
|
const filePath = path.join(publicDir, relativePath.replace(/^\/+/, ''));
|
|
if (filePath.startsWith(publicDir) && fileExists(filePath) && !relativePath.endsWith('/')) {
|
|
serveFile(response, filePath);
|
|
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,
|
|
user: auth.user,
|
|
settings,
|
|
navContent: templates.navigationFragment(folders, counts, '', ''),
|
|
joplinBasePath: joplinPublicBasePath,
|
|
}));
|
|
} catch {
|
|
redirect(response, '/login');
|
|
}
|
|
});
|
|
};
|
|
|
|
module.exports = { createServer };
|