mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-04-28 09:59:30 +00:00
1756 lines
70 KiB
JavaScript
1756 lines
70 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { sessionIdFromHeaders } = require('./auth/cookies');
|
|
const { generateSeed, otpauthUri, qrCodeDataUrl, verifyWithSeed } = require('./auth/mfaService');
|
|
const templates = require('./templates');
|
|
|
|
const contentTypes = {
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.png': 'image/png',
|
|
'.svg': 'image/svg+xml',
|
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
'.woff2': 'font/woff2',
|
|
};
|
|
|
|
const fileExists = filePath => {
|
|
try {
|
|
return fs.statSync(filePath).isFile();
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const send = (response, statusCode, body, headers = {}) => {
|
|
response.writeHead(statusCode, headers);
|
|
response.end(body);
|
|
};
|
|
|
|
const sendHtml = (response, statusCode, html) => {
|
|
send(response, statusCode, html, {
|
|
'Cache-Control': 'no-store',
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
});
|
|
};
|
|
|
|
const sendJson = (response, statusCode, body) => {
|
|
send(response, statusCode, JSON.stringify(body), {
|
|
'Cache-Control': 'no-store',
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
});
|
|
};
|
|
|
|
const expiredSessionCookie = () => 'sessionId=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
|
|
const readBody = request => {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
request.setEncoding('utf8');
|
|
request.on('data', chunk => {
|
|
body += chunk;
|
|
});
|
|
request.on('end', () => resolve(body));
|
|
request.on('error', reject);
|
|
});
|
|
};
|
|
|
|
const readRawBody = request => {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
request.on('data', chunk => chunks.push(chunk));
|
|
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
request.on('error', reject);
|
|
});
|
|
};
|
|
|
|
// Minimal multipart parser — extracts the first file field
|
|
const parseMultipart = (buffer, contentType) => {
|
|
const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
|
|
if (!match) return null;
|
|
const boundary = match[1] || match[2];
|
|
const boundaryBuf = Buffer.from(`--${boundary}`);
|
|
|
|
// Find first occurrence after the boundary
|
|
let start = buffer.indexOf(boundaryBuf);
|
|
if (start === -1) return null;
|
|
start += boundaryBuf.length;
|
|
|
|
// Find the header/body separator (\r\n\r\n)
|
|
const headerEnd = buffer.indexOf('\r\n\r\n', start);
|
|
if (headerEnd === -1) return null;
|
|
const headerStr = buffer.slice(start, headerEnd).toString('utf8');
|
|
|
|
// Extract filename and content-type from headers
|
|
const fnMatch = headerStr.match(/filename="([^"]+)"/);
|
|
const ctMatch = headerStr.match(/Content-Type:\s*(.+)/i);
|
|
const filename = fnMatch ? fnMatch[1] : 'upload';
|
|
const fileMime = ctMatch ? ctMatch[1].trim() : 'application/octet-stream';
|
|
|
|
const bodyStart = headerEnd + 4;
|
|
// Find ending boundary
|
|
const endBoundary = buffer.indexOf(boundaryBuf, bodyStart);
|
|
// The body ends 2 bytes before the next boundary (\r\n)
|
|
const bodyEnd = endBoundary !== -1 ? endBoundary - 2 : buffer.length;
|
|
|
|
return {
|
|
filename,
|
|
mime: fileMime,
|
|
data: buffer.slice(bodyStart, bodyEnd),
|
|
};
|
|
};
|
|
|
|
const parseBody = async request => {
|
|
const raw = await readBody(request);
|
|
if (!raw) return {};
|
|
const contentType = request.headers['content-type'] || '';
|
|
if (contentType.includes('application/json')) {
|
|
return JSON.parse(raw);
|
|
}
|
|
// Parse URL-encoded form data (htmx default)
|
|
const params = new URLSearchParams(raw);
|
|
const result = {};
|
|
for (const [key, value] of params) {
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const nextConflictCopyTitle = (title, existingTitles) => {
|
|
const source = `${title || 'Untitled note'}`.trim() || 'Untitled note';
|
|
const base = source.replace(/-\d+$/, '');
|
|
const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`^${escapedBase}-(\\d+)$`);
|
|
let maxSuffix = 0;
|
|
for (const existingTitle of existingTitles) {
|
|
const match = `${existingTitle || ''}`.match(re);
|
|
if (match) maxSuffix = Math.max(maxSuffix, Number(match[1] || 0));
|
|
}
|
|
return `${base}-${maxSuffix + 1}`;
|
|
};
|
|
|
|
const TRASH_FOLDER_ID = 'de1e7ede1e7ede1e7ede1e7ede1e7ede';
|
|
const ALL_NOTES_FOLDER_ID = '__all_notes__';
|
|
|
|
const allNotesFolder = notes => ({
|
|
id: ALL_NOTES_FOLDER_ID,
|
|
parentId: '',
|
|
title: 'All Notes',
|
|
noteCount: notes.filter(note => !note.deletedTime).length,
|
|
createdTime: 0,
|
|
updatedTime: 0,
|
|
isVirtualAllNotes: true,
|
|
});
|
|
|
|
const selectedFolderForNav = currentFolderId => currentFolderId === ALL_NOTES_FOLDER_ID ? ALL_NOTES_FOLDER_ID : currentFolderId;
|
|
|
|
const normalizeStoredFolderId = folderId => folderId === '__all__' ? ALL_NOTES_FOLDER_ID : `${folderId || ''}`;
|
|
|
|
const plainNoteTitle = title => templates.stripMarkdownForTitle(`${title || ''}`) || 'Untitled note';
|
|
|
|
const saveLastNoteState = async (settingsService, userId, currentSettings, noteId, folderId) => {
|
|
if (!settingsService) return currentSettings;
|
|
return settingsService.saveSettings(userId, {
|
|
...currentSettings,
|
|
lastNoteId: `${noteId || ''}`,
|
|
lastNoteFolderId: normalizeStoredFolderId(folderId),
|
|
});
|
|
};
|
|
|
|
const notesForFolder = async (itemService, userId, folderId) => {
|
|
if (!folderId || folderId === ALL_NOTES_FOLDER_ID) return itemService.notesByUserId(userId);
|
|
if (folderId === TRASH_FOLDER_ID) return itemService.notesByUserId(userId, { deleted: 'only' });
|
|
return itemService.notesByUserId(userId, { folderId });
|
|
};
|
|
|
|
const contentDispositionFilename = value => `${value || 'attachment'}`.replace(/[\r\n"]/g, '_');
|
|
|
|
const shouldInlineResource = mime => /^(image\/.+|application\/pdf|text\/plain)$/i.test(`${mime || ''}`);
|
|
|
|
const trashFolder = notes => ({
|
|
id: TRASH_FOLDER_ID,
|
|
parentId: '',
|
|
title: 'Trash',
|
|
noteCount: notes.length,
|
|
createdTime: 0,
|
|
updatedTime: 0,
|
|
});
|
|
|
|
const mapNavNotes = notes => notes.map(note => note.deletedTime ? { ...note, parentId: TRASH_FOLDER_ID } : note);
|
|
|
|
const serveFile = (response, filePath) => {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
const contentType = contentTypes[extension] || 'application/octet-stream';
|
|
const stat = fs.statSync(filePath);
|
|
|
|
response.writeHead(200, {
|
|
'Cache-Control': extension === '.html' ? 'no-store' : (extension === '.woff2' ? 'public, max-age=31536000, immutable' : 'public, max-age=300'),
|
|
'Content-Length': stat.size,
|
|
'Content-Type': contentType,
|
|
});
|
|
|
|
fs.createReadStream(filePath).pipe(response);
|
|
};
|
|
|
|
const createServer = options => {
|
|
const {
|
|
publicDir,
|
|
joplinPublicBasePath,
|
|
joplinPublicBaseUrl,
|
|
joplinServerPublicUrl,
|
|
joplinServerOrigin,
|
|
sessionService,
|
|
itemService,
|
|
settingsService,
|
|
historyService,
|
|
itemWriteService,
|
|
adminService = null,
|
|
adminEmail = '',
|
|
ignoreAdminMfa = false,
|
|
database = 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`) : () => {};
|
|
|
|
const configuredPublicUrl = new URL(joplinPublicBaseUrl);
|
|
const configuredServerPublicUrl = new URL(joplinServerPublicUrl);
|
|
|
|
const authenticatedUser = async request => {
|
|
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 };
|
|
return { error: null, user };
|
|
};
|
|
|
|
const navData = async userId => {
|
|
const [folders, notes, trashedNotes] = await Promise.all([
|
|
itemService.foldersByUserId(userId),
|
|
itemService.noteHeadersByUserId(userId),
|
|
itemService.noteHeadersByUserId(userId, { deleted: 'only' }),
|
|
]);
|
|
const allNotes = mapNavNotes(notes.concat(trashedNotes));
|
|
const allFolders = [allNotesFolder(notes)].concat(folders, [trashFolder(trashedNotes)]);
|
|
return { folders: allFolders, notes: allNotes };
|
|
};
|
|
|
|
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 **+ Folder** 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 userSettings = async userId => settingsService ? settingsService.settingsByUserId(userId) : null;
|
|
|
|
const upstreamRequestContext = _request => ({
|
|
host: configuredServerPublicUrl.host,
|
|
protocol: configuredServerPublicUrl.protocol.replace(':', ''),
|
|
});
|
|
|
|
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);
|
|
};
|
|
|
|
// --- Health check ---
|
|
if (url.pathname === '/health') {
|
|
send(response, 200, 'ok', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/settings' && request.method === 'GET') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const settings = await userSettings(auth.user.id);
|
|
const isAdmin = isJoplockAdmin(auth.user);
|
|
let adminUsers = null;
|
|
if (isAdmin) {
|
|
try {
|
|
const users = await adminService.listUsers();
|
|
adminUsers = await Promise.all(users.map(async u => {
|
|
const totpSeed = await settingsService.getTotpSeed(u.id);
|
|
return {
|
|
...u,
|
|
totpEnabled: !!totpSeed,
|
|
totpSeed: totpSeed || null,
|
|
totpQr: totpSeed ? qrCodeDataUrl(otpauthUri(totpSeed, u.email, 'Joplock')) : null,
|
|
};
|
|
}));
|
|
} catch {}
|
|
}
|
|
// Per-user TOTP
|
|
const userTotpSeed = await settingsService.getTotpSeed(auth.user.id);
|
|
const userTotpEnabled = !!userTotpSeed;
|
|
// Check if in setup mode (seed in query param)
|
|
const setupSeed = url.searchParams.get('mfaSetup') || '';
|
|
const userTotpSetupSeed = setupSeed && !userTotpEnabled ? setupSeed : '';
|
|
const userTotpSetupQr = userTotpSetupSeed ? qrCodeDataUrl(otpauthUri(userTotpSetupSeed, auth.user.email, 'Joplock')) : '';
|
|
|
|
sendHtml(response, 200, templates.settingsPage({
|
|
user: auth.user,
|
|
settings,
|
|
userTotpEnabled,
|
|
userTotpSetupSeed,
|
|
userTotpSetupQr,
|
|
isAdmin,
|
|
isDockerAdmin: isAdmin,
|
|
adminUsers,
|
|
flash: url.searchParams.get('saved') === '1' ? 'Settings saved.' : (url.searchParams.get('mfaEnabled') === '1' ? 'MFA enabled successfully.' : ''),
|
|
flashError: url.searchParams.get('error') || '',
|
|
activeTab: url.searchParams.get('tab') || 'appearance',
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// --- POST /settings/security (session settings) ---
|
|
if (url.pathname === '/settings/security' && request.method === 'POST') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const body = await parseBody(request);
|
|
const current = await settingsService.settingsByUserId(auth.user.id);
|
|
await settingsService.saveSettings(auth.user.id, {
|
|
...current,
|
|
autoLogout: body.autoLogout,
|
|
autoLogoutMinutes: body.autoLogoutMinutes,
|
|
});
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
// --- POST /settings/password ---
|
|
if (url.pathname === '/settings/password' && request.method === 'POST') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
// Block password change for docker-defined admin
|
|
if (isJoplockAdmin(auth.user)) {
|
|
response.writeHead(302, { Location: '/settings?error=Password+is+managed+via+deployment+configuration&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const body = await parseBody(request);
|
|
try {
|
|
if (!body.currentPassword) {
|
|
response.writeHead(302, { Location: '/settings?error=Current+password+required&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
if (!body.newPassword) {
|
|
response.writeHead(302, { Location: '/settings?error=New+password+required&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
if (body.newPassword !== body.confirmPassword) {
|
|
response.writeHead(302, { Location: '/settings?error=Passwords+do+not+match&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
if (adminService) {
|
|
const verifyToken = await adminService.verifyPassword(auth.user.email, body.currentPassword);
|
|
if (!verifyToken) {
|
|
response.writeHead(302, { Location: '/settings?error=Current+password+is+incorrect&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
await adminService.changePassword(auth.user.sessionId, auth.user.id, body.newPassword);
|
|
}
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=appearance' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Password change failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=security` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- POST /settings/profile ---
|
|
if (url.pathname === '/settings/profile' && request.method === 'POST') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const body = await parseBody(request);
|
|
try {
|
|
// Update full_name directly in Postgres — Joplin Server's PATCH API
|
|
// silently ignores full_name for non-admin sessions.
|
|
if (body.fullName !== undefined) {
|
|
await database.query(
|
|
'UPDATE users SET full_name = $1, updated_time = $2 WHERE id = $3',
|
|
[body.fullName, Date.now(), auth.user.id]
|
|
);
|
|
}
|
|
if (adminService && body.email && body.email !== auth.user.email) {
|
|
await adminService.updateProfile(auth.user.sessionId, auth.user.id, {
|
|
email: body.email,
|
|
});
|
|
}
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=profile' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Update failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=profile` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- MFA routes ---
|
|
if (url.pathname === '/settings/mfa/setup' && request.method === 'POST') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
// Check if already has TOTP
|
|
const existingSeed = await settingsService.getTotpSeed(auth.user.id);
|
|
if (existingSeed) {
|
|
response.writeHead(302, { Location: '/settings?error=MFA+already+enabled&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
// Generate new seed and redirect to setup page
|
|
const newSeed = generateSeed();
|
|
response.writeHead(302, { Location: `/settings?mfaSetup=${encodeURIComponent(newSeed)}&tab=security` });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/settings/mfa/verify' && request.method === 'POST') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const body = await parseBody(request);
|
|
const seed = body.seed || '';
|
|
const code = body.totp || '';
|
|
if (!seed || !verifyWithSeed(seed, code)) {
|
|
response.writeHead(302, { Location: `/settings?mfaSetup=${encodeURIComponent(seed)}&error=Invalid+code.+Try+again.&tab=security` });
|
|
response.end();
|
|
return;
|
|
}
|
|
// Save seed
|
|
await settingsService.setTotpSeed(auth.user.id, seed);
|
|
response.writeHead(302, { Location: '/settings?mfaEnabled=1&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/settings/mfa/cancel' && request.method === 'POST') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
response.writeHead(302, { Location: '/settings?tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/settings/mfa/disable' && request.method === 'POST') {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const body = await parseBody(request);
|
|
const code = body.totp || '';
|
|
const existingSeed = await settingsService.getTotpSeed(auth.user.id);
|
|
if (!existingSeed) {
|
|
response.writeHead(302, { Location: '/settings?error=MFA+not+enabled&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
if (!verifyWithSeed(existingSeed, code)) {
|
|
response.writeHead(302, { Location: '/settings?error=Invalid+code&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
await settingsService.clearTotpSeed(auth.user.id);
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=security' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
// --- Admin routes ---
|
|
if (url.pathname.startsWith('/admin')) {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user || !isJoplockAdmin(auth.user)) {
|
|
response.writeHead(302, { Location: '/' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
// POST /admin/users — create user
|
|
if (url.pathname === '/admin/users' && request.method === 'POST') {
|
|
const body = await parseBody(request);
|
|
try {
|
|
await adminService.createUser(body.email, body.fullName || '', body.password || '');
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Create user failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// POST /admin/users/:id/password — reset password
|
|
const resetMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/password$/);
|
|
if (resetMatch && request.method === 'POST') {
|
|
const userId = decodeURIComponent(resetMatch[1]);
|
|
const body = await parseBody(request);
|
|
try {
|
|
await adminService.resetPassword(userId, body.password || '');
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Reset password failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// POST /admin/users/:id/disable
|
|
const disableMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/(disable|enable)$/);
|
|
if (disableMatch && request.method === 'POST') {
|
|
const userId = decodeURIComponent(disableMatch[1]);
|
|
const enabled = disableMatch[2] === 'enable';
|
|
try {
|
|
await adminService.setEnabled(userId, enabled);
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Operation failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// POST /admin/users/:id/delete
|
|
const deleteMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/delete$/);
|
|
if (deleteMatch && request.method === 'POST') {
|
|
const userId = decodeURIComponent(deleteMatch[1]);
|
|
try {
|
|
await adminService.deleteUser(userId);
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Delete failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// POST /admin/users/:id/mfa/enable
|
|
const mfaEnableMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/mfa\/enable$/);
|
|
if (mfaEnableMatch && request.method === 'POST') {
|
|
const userId = decodeURIComponent(mfaEnableMatch[1]);
|
|
try {
|
|
const newSeed = generateSeed();
|
|
await settingsService.setTotpSeed(userId, newSeed);
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Enable MFA failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// POST /admin/users/:id/mfa/disable
|
|
const mfaDisableMatch = url.pathname.match(/^\/admin\/users\/([^/]+)\/mfa\/disable$/);
|
|
if (mfaDisableMatch && request.method === 'POST') {
|
|
const userId = decodeURIComponent(mfaDisableMatch[1]);
|
|
try {
|
|
await settingsService.clearTotpSeed(userId);
|
|
response.writeHead(302, { Location: '/settings?saved=1&tab=admin' });
|
|
response.end();
|
|
} catch (error) {
|
|
const msg = encodeURIComponent(error.message || 'Disable MFA failed');
|
|
response.writeHead(302, { Location: `/settings?error=${msg}&tab=admin` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Unknown admin route
|
|
response.writeHead(302, { Location: '/settings?tab=admin' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
// --- save individual setting (fire-and-forget from client) ---
|
|
if (url.pathname === '/api/web/settings' && request.method === 'PUT') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) { response.writeHead(401); response.end(); return; }
|
|
const body = await parseBody(request);
|
|
const current = await settingsService.settingsByUserId(auth.user.id);
|
|
const updates = {};
|
|
// Only allow specific keys
|
|
const allowedKeys = ['theme', 'noteFontSize', 'mobileNoteFontSize', 'codeFontSize', 'noteMonospace', 'noteOpenMode', 'resumeLastNote', 'dateFormat', 'datetimeFormat', 'liveSearch'];
|
|
for (const key of allowedKeys) {
|
|
if (body[key] !== undefined) updates[key] = body[key];
|
|
}
|
|
if (Object.keys(updates).length > 0) {
|
|
await settingsService.saveSettings(auth.user.id, { ...current, ...updates });
|
|
}
|
|
response.writeHead(204);
|
|
response.end();
|
|
} catch {
|
|
response.writeHead(500);
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- save theme (fire-and-forget from client) - legacy ---
|
|
if (url.pathname === '/api/web/theme' && request.method === 'PUT') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) { response.writeHead(401); response.end(); return; }
|
|
const body = await parseBody(request);
|
|
const current = await settingsService.settingsByUserId(auth.user.id);
|
|
await settingsService.saveSettings(auth.user.id, { ...current, theme: body.theme });
|
|
response.writeHead(204);
|
|
response.end();
|
|
} catch {
|
|
response.writeHead(500);
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- history: list snapshots ---
|
|
if (url.pathname.startsWith('/fragments/history/') && !url.pathname.includes('/restore/') && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const noteId = decodeURIComponent(url.pathname.slice('/fragments/history/'.length));
|
|
const snapshots = historyService ? await historyService.listSnapshots(noteId) : [];
|
|
sendHtml(response, 200, templates.historyModalFragment(noteId, snapshots));
|
|
} catch (error) {
|
|
sendHtml(response, 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- history: get snapshot body preview ---
|
|
if (url.pathname.startsWith('/fragments/history-snapshot/') && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const snapshotId = decodeURIComponent(url.pathname.slice('/fragments/history-snapshot/'.length));
|
|
const snapshot = historyService ? await historyService.getSnapshot(snapshotId) : null;
|
|
if (!snapshot) { sendHtml(response, 404, '<div class="empty-hint">Snapshot not found.</div>'); return; }
|
|
sendHtml(response, 200, templates.historySnapshotPreviewFragment(snapshot));
|
|
} catch (error) {
|
|
sendHtml(response, 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- history: restore snapshot ---
|
|
if (url.pathname.startsWith('/fragments/history/') && url.pathname.includes('/restore/') && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<span class="autosave-error">Session expired</span>'); return; }
|
|
const parts = url.pathname.slice('/fragments/history/'.length).split('/restore/');
|
|
const noteId = decodeURIComponent(parts[0]);
|
|
const snapshotId = decodeURIComponent(parts[1] || '');
|
|
const snapshot = historyService ? await historyService.getSnapshot(snapshotId) : null;
|
|
if (!snapshot || snapshot.noteId !== noteId) { sendHtml(response, 404, '<span class="autosave-error">Snapshot not found</span>'); return; }
|
|
const body = await parseBody(request);
|
|
const currentFolderId = `${body.currentFolderId || ''}`;
|
|
const existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
|
|
if (!existing) { sendHtml(response, 404, '<span class="autosave-error">Note not found</span>'); return; }
|
|
await itemWriteService.updateNote(auth.user.sessionId, existing, {
|
|
title: snapshot.title,
|
|
body: snapshot.body,
|
|
parentId: existing.parentId,
|
|
}, upstreamRequestContext(request));
|
|
const refreshed = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
sendHtml(response, 200, `${templates.autosaveStatusFragment()}<div id="nav-panel" hx-swap-oob="innerHTML">${templates.navigationFragment(folders, notes, selectedFolderForNav(currentFolderId || existing.parentId), noteId, '', selectedFolderForNav(currentFolderId || existing.parentId))}</div><div id="editor-panel" hx-swap-oob="innerHTML">${templates.editorFragment(refreshed || existing, folders.filter(f => f.id !== TRASH_FOLDER_ID), selectedFolderForNav(currentFolderId || existing.parentId))}</div>`);
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<span class="autosave-error">Restore failed: ${templates.escapeHtml(error.message || `${error}`)}</span>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- htmx fragment: create folder ---
|
|
if (url.pathname === '/fragments/folders' && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
const body = await parseBody(request);
|
|
const title = `${body.title || ''}`.trim();
|
|
if (!title) { sendHtml(response, 400, '<div class="empty-hint">Folder title is required.</div>'); return; }
|
|
|
|
await itemWriteService.createFolder(auth.user.sessionId, { title, parentId: body.parentId || '' }, upstreamRequestContext(request));
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
sendHtml(response, 200, templates.navigationFragment(folders, notes, '', ''));
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/fragments/folders/') && request.method === 'DELETE') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
const folderId = decodeURIComponent(url.pathname.slice('/fragments/folders/'.length));
|
|
await itemWriteService.deleteFolder(auth.user.sessionId, folderId, upstreamRequestContext(request));
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
sendHtml(response, 200, templates.navigationFragment(folders, notes, '', ''));
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/fragments/folders/') && request.method === 'PUT') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
const folderId = decodeURIComponent(url.pathname.slice('/fragments/folders/'.length));
|
|
const body = await parseBody(request);
|
|
const title = `${body.title || ''}`.trim();
|
|
if (!folderId) { sendHtml(response, 404, '<div class="empty-hint">Folder not found.</div>'); return; }
|
|
if (!title) { sendHtml(response, 400, '<div class="empty-hint">Folder title is required.</div>'); return; }
|
|
const existingFolder = await itemService.folderByUserIdAndJopId(auth.user.id, folderId);
|
|
if (!existingFolder) { sendHtml(response, 404, '<div class="empty-hint">Folder not found.</div>'); return; }
|
|
|
|
await itemWriteService.updateFolder(auth.user.sessionId, existingFolder, { title }, upstreamRequestContext(request));
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
sendHtml(response, 200, templates.navigationFragment(folders, notes, folderId, ''));
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- htmx fragment: navigation tree ---
|
|
if (url.pathname === '/fragments/nav' && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
const rawQuery = url.searchParams.get('q') || '';
|
|
const query = rawQuery.trim();
|
|
const data = await navData(auth.user.id);
|
|
const notes = query ? mapNavNotes(await itemService.searchNotes(auth.user.id, query)) : data.notes;
|
|
const navFolders = data.folders;
|
|
sendHtml(response, 200, templates.navigationFragment(navFolders, notes, '', '', rawQuery));
|
|
} catch (error) {
|
|
sendHtml(response, 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/fragments/notes' && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
const body = await parseBody(request);
|
|
const parentId = `${body.parentId || ''}`;
|
|
const currentFolderId = `${body.currentFolderId || parentId || ''}`;
|
|
if (!parentId) { sendHtml(response, 400, '<div class="empty-hint">Select a folder first.</div>'); return; }
|
|
|
|
const created = await itemWriteService.createNote(auth.user.sessionId, {
|
|
title: `${body.title || ''}`.trim() || 'Untitled note',
|
|
body: '',
|
|
parentId,
|
|
}, upstreamRequestContext(request));
|
|
|
|
const [{ folders, notes }, note] = await Promise.all([
|
|
navData(auth.user.id),
|
|
itemService.noteByUserIdAndJopId(auth.user.id, created.id),
|
|
]);
|
|
sendHtml(response, 200, `${templates.navigationFragment(folders, notes, selectedFolderForNav(currentFolderId), created.id, '', selectedFolderForNav(currentFolderId))}<div id="editor-panel" hx-swap-oob="innerHTML">${templates.editorFragment(note, folders.filter(folder => folder.id !== TRASH_FOLDER_ID), selectedFolderForNav(currentFolderId))}</div>`);
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/fragments/notes/in-general' && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
// Find or create the 'General' folder
|
|
const folders = await itemService.foldersByUserId(auth.user.id);
|
|
let general = folders.find(f => !f.deletedTime && f.title === 'General');
|
|
if (!general) {
|
|
const created = await itemWriteService.createFolder(auth.user.sessionId, { title: 'General', parentId: '' }, upstreamRequestContext(request));
|
|
general = { id: created.id, title: 'General' };
|
|
}
|
|
|
|
const created = await itemWriteService.createNote(auth.user.sessionId, {
|
|
title: 'Untitled note',
|
|
body: '',
|
|
parentId: general.id,
|
|
}, upstreamRequestContext(request));
|
|
|
|
const [{ folders: navFolders, notes }, note] = await Promise.all([
|
|
navData(auth.user.id),
|
|
itemService.noteByUserIdAndJopId(auth.user.id, created.id),
|
|
]);
|
|
sendHtml(response, 200, `${templates.navigationFragment(navFolders, notes, selectedFolderForNav(general.id), created.id, '', selectedFolderForNav(general.id))}<div id="editor-panel" hx-swap-oob="innerHTML">${templates.editorFragment(note, navFolders.filter(f => f.id !== TRASH_FOLDER_ID), selectedFolderForNav(general.id))}</div>`);
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/fragments/notes/') && !url.pathname.startsWith('/fragments/editor/') && request.method === 'DELETE') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
const noteId = decodeURIComponent(url.pathname.slice('/fragments/notes/'.length));
|
|
let existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
|
|
if (!existing) existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'only' });
|
|
if (!existing) { sendHtml(response, 404, '<div class="empty-hint">Note not found.</div>'); return; }
|
|
if (existing.deletedTime) {
|
|
await itemWriteService.deleteNote(auth.user.sessionId, noteId, upstreamRequestContext(request));
|
|
} else {
|
|
await itemWriteService.trashNote(auth.user.sessionId, existing, upstreamRequestContext(request));
|
|
}
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
sendHtml(response, 200, `${templates.navigationFragment(folders, notes, TRASH_FOLDER_ID, '')}<div id="editor-panel" hx-swap-oob="innerHTML"><div class="editor-empty">Select a note</div></div>`);
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/fragments/notes/') && url.pathname.endsWith('/restore') && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const noteId = decodeURIComponent(url.pathname.slice('/fragments/notes/'.length, -'/restore'.length));
|
|
const [existing, folders] = await Promise.all([
|
|
itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'only' }),
|
|
itemService.foldersByUserId(auth.user.id),
|
|
]);
|
|
if (!existing) { sendHtml(response, 404, '<div class="empty-hint">Note not found.</div>'); return; }
|
|
let restoreParentId = existing.parentId;
|
|
if (!folders.find(folder => folder.id === restoreParentId)) {
|
|
if (folders.length) {
|
|
restoreParentId = folders[0].id;
|
|
} else {
|
|
const createdFolder = await itemWriteService.createFolder(auth.user.sessionId, { title: 'Restored items', parentId: '' }, upstreamRequestContext(request));
|
|
restoreParentId = createdFolder.id;
|
|
}
|
|
}
|
|
await itemWriteService.restoreNote(auth.user.sessionId, existing, restoreParentId, upstreamRequestContext(request));
|
|
const [{ folders: navFolders, notes }, restoredNote] = await Promise.all([
|
|
navData(auth.user.id),
|
|
itemService.noteByUserIdAndJopId(auth.user.id, noteId),
|
|
]);
|
|
sendHtml(response, 200, `${templates.navigationFragment(navFolders, notes, restoreParentId, noteId, '', restoreParentId)}<div id="editor-panel" hx-swap-oob="innerHTML">${templates.editorFragment(restoredNote, navFolders.filter(folder => folder.id !== TRASH_FOLDER_ID))}</div>`);
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/fragments/trash/empty' && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const trashedNotes = await itemService.noteHeadersByUserId(auth.user.id, { deleted: 'only' });
|
|
for (const note of trashedNotes) {
|
|
await itemWriteService.deleteNote(auth.user.sessionId, note.id, upstreamRequestContext(request));
|
|
}
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
sendHtml(response, 200, `${templates.navigationFragment(folders, notes, '', '')}<div id="editor-panel" hx-swap-oob="innerHTML"><div class="editor-empty">Select a note</div></div>`);
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error.message || `${error}`)}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Resource binary serving ---
|
|
if (url.pathname.startsWith('/resources/') && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { send(response, 401, 'Unauthorized', { 'Content-Type': 'text/plain' }); return; }
|
|
|
|
const resourceId = decodeURIComponent(url.pathname.slice('/resources/'.length));
|
|
if (!resourceId || !/^[0-9a-zA-Z]{32}$/.test(resourceId)) {
|
|
send(response, 400, 'Invalid resource ID', { 'Content-Type': 'text/plain' });
|
|
return;
|
|
}
|
|
|
|
const [meta, blob] = await Promise.all([
|
|
itemService.resourceMetaByUserId(auth.user.id, resourceId),
|
|
itemService.resourceBlobByUserId(auth.user.id, resourceId),
|
|
]);
|
|
|
|
if (!blob) { send(response, 404, 'Resource not found', { 'Content-Type': 'text/plain' }); return; }
|
|
|
|
const mime = (meta && meta.mime) || 'application/octet-stream';
|
|
const filename = contentDispositionFilename((meta && (meta.filename || meta.title)) || `${resourceId}`);
|
|
const download = url.searchParams.get('download') === '1';
|
|
const disposition = `${download || !shouldInlineResource(mime) ? 'attachment' : 'inline'}; filename="${filename}"`;
|
|
response.writeHead(200, {
|
|
'Content-Type': mime,
|
|
'Content-Length': blob.length,
|
|
'Cache-Control': 'no-store',
|
|
'Content-Disposition': disposition,
|
|
});
|
|
response.end(blob);
|
|
} catch (error) {
|
|
send(response, 500, 'Error loading resource', { 'Content-Type': 'text/plain' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- File upload (multipart) ---
|
|
if (url.pathname === '/fragments/upload' && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: 'Session expired' }); return; }
|
|
|
|
const contentType = request.headers['content-type'] || '';
|
|
if (!contentType.includes('multipart/form-data')) {
|
|
sendJson(response, 400, { error: 'Expected multipart/form-data' });
|
|
return;
|
|
}
|
|
|
|
const rawBody = await readRawBody(request);
|
|
const file = parseMultipart(rawBody, contentType);
|
|
if (!file || !file.data.length) {
|
|
sendJson(response, 400, { error: 'No file uploaded' });
|
|
return;
|
|
}
|
|
|
|
const extMatch = file.filename.match(/\.([^.]+)$/);
|
|
const fileExtension = extMatch ? extMatch[1].toLowerCase() : '';
|
|
|
|
const created = await itemWriteService.createResource(auth.user.sessionId, {
|
|
title: file.filename,
|
|
mime: file.mime,
|
|
filename: file.filename,
|
|
fileExtension,
|
|
size: file.data.length,
|
|
}, file.data, upstreamRequestContext(request));
|
|
|
|
const isImage = file.mime.startsWith('image/');
|
|
const markdown = isImage
|
|
? ``
|
|
: `[${file.filename}](:/${created.id})`;
|
|
|
|
sendJson(response, 200, { resourceId: created.id, markdown });
|
|
} catch (error) {
|
|
sendJson(response, error.statusCode || 500, { error: error.message || 'Upload failed' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Markdown preview ---
|
|
if (url.pathname === '/fragments/preview' && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div>Session expired</div>'); return; }
|
|
|
|
const body = await parseBody(request);
|
|
const html = templates.renderMarkdown(body.body || '');
|
|
sendHtml(response, 200, html);
|
|
} catch (error) {
|
|
sendHtml(response, 500, '<div>Preview error</div>');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- htmx fragment: search ---
|
|
if (url.pathname === '/fragments/search' && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
|
|
const query = url.searchParams.get('q') || '';
|
|
if (!query.trim()) { sendHtml(response, 200, ''); return; }
|
|
const notes = await itemService.searchNotes(auth.user.id, query);
|
|
sendHtml(response, 200, templates.searchResultsFragment(notes));
|
|
} catch (error) {
|
|
sendHtml(response, 500, '<div class="empty-hint">Search error</div>');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- mobile fragment: folders list ---
|
|
if (url.pathname === '/fragments/mobile/folders' && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const [folders, notes] = await Promise.all([
|
|
itemService.foldersByUserId(auth.user.id),
|
|
itemService.noteHeadersByUserId(auth.user.id),
|
|
]);
|
|
sendHtml(response, 200, templates.mobileFoldersFragment(folders, notes));
|
|
} catch (error) {
|
|
sendHtml(response, 500, '<div class="empty-hint">Error</div>');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- mobile fragment: notes list ---
|
|
if (url.pathname === '/fragments/mobile/notes' && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const folderId = url.searchParams.get('folderId') || '';
|
|
const notes = folderId === '__all__'
|
|
? await itemService.noteHeadersByUserId(auth.user.id)
|
|
: (await itemService.noteHeadersByUserId(auth.user.id)).filter(n => n.parentId === folderId);
|
|
const filtered = notes.filter(n => !n.deletedTime);
|
|
sendHtml(response, 200, templates.mobileNotesFragment(filtered, folderId));
|
|
} catch (error) {
|
|
sendHtml(response, 500, '<div class="empty-hint">Error</div>');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- mobile fragment: new note ---
|
|
if (url.pathname === '/fragments/mobile/notes/new' && request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const body = await parseBody(request);
|
|
let folderId = body.folderId || '';
|
|
// If "all notes" or no folder, find/create General folder
|
|
if (!folderId || folderId === '__all__') {
|
|
const folders = await itemService.foldersByUserId(auth.user.id);
|
|
const real = folders.filter(f => !f.isVirtualAllNotes && f.id !== TRASH_FOLDER_ID);
|
|
let general = real.find(f => (f.title || '').toLowerCase() === 'general');
|
|
if (!general) general = real[0];
|
|
if (general) folderId = general.id;
|
|
}
|
|
const note = await itemWriteService.createNote(auth.user.sessionId, { title: 'Untitled note', body: '', parentId: folderId }, upstreamRequestContext(request));
|
|
const notes = folderId && folderId !== '__all__'
|
|
? (await itemService.noteHeadersByUserId(auth.user.id)).filter(n => !n.deletedTime && n.parentId === folderId)
|
|
: (await itemService.noteHeadersByUserId(auth.user.id)).filter(n => !n.deletedTime);
|
|
response.writeHead(200, {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'X-Mobile-Note-Id': note.id || '',
|
|
});
|
|
response.end(templates.mobileNotesFragment(notes, folderId));
|
|
} catch (error) {
|
|
console.error('[mobile] notes/new error:', error);
|
|
sendHtml(response, error.statusCode || 500, `<div class="empty-hint">Error: ${templates.escapeHtml(error && (error.message || `${error}`) || 'creating note')}</div>`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- mobile fragment: search ---
|
|
if (url.pathname === '/fragments/mobile/search' && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="empty-hint">Session expired.</div>'); return; }
|
|
const query = url.searchParams.get('q') || '';
|
|
const notes = query ? (await itemService.searchNotes(auth.user.id, query)).filter(n => !n.deletedTime) : [];
|
|
sendHtml(response, 200, templates.mobileSearchFragment(notes));
|
|
} catch (error) {
|
|
sendHtml(response, 500, '<div class="empty-hint">Search error</div>');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- htmx fragment: note editor ---
|
|
if (url.pathname.startsWith('/fragments/editor/') && request.method === 'GET') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<div class="editor-empty">Session expired.</div>'); return; }
|
|
|
|
const noteId = decodeURIComponent(url.pathname.slice('/fragments/editor/'.length));
|
|
const currentFolderId = url.searchParams.get('currentFolderId') || '';
|
|
const currentSettings = await userSettings(auth.user.id);
|
|
const [note, folders] = await Promise.all([
|
|
itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'all' }),
|
|
itemService.foldersByUserId(auth.user.id),
|
|
]);
|
|
if (!note) { sendHtml(response, 404, '<div class="editor-empty">Note not found.</div>'); return; }
|
|
await saveLastNoteState(settingsService, auth.user.id, currentSettings, note.id, currentFolderId || note.parentId);
|
|
sendHtml(response, 200, templates.editorFragment(note, folders, currentFolderId || note.parentId));
|
|
} catch (error) {
|
|
sendHtml(response, 500, '<div class="editor-empty">Error</div>');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/fragments/editor/') && request.method === 'PUT') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendHtml(response, 401, '<span class="autosave-error">Session expired</span>'); return; }
|
|
|
|
const noteId = decodeURIComponent(url.pathname.slice('/fragments/editor/'.length));
|
|
const body = await parseBody(request);
|
|
const currentSettings = await userSettings(auth.user.id);
|
|
const baseUpdatedTime = Number(body.baseUpdatedTime || 0);
|
|
const forceSave = `${body.forceSave || ''}` === '1';
|
|
const createCopy = `${body.createCopy || ''}` === '1';
|
|
let existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
|
|
if (!existing) existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId, { deleted: 'only' });
|
|
if (!existing) { sendHtml(response, 404, '<span class="autosave-error">Note not found</span>'); return; }
|
|
const currentFolderId = `${body.currentFolderId || body.parentId || existing.parentId || ''}`;
|
|
if (createCopy) {
|
|
const [{ folders, notes }] = await Promise.all([
|
|
navData(auth.user.id),
|
|
]);
|
|
const copyTitle = nextConflictCopyTitle(plainNoteTitle(body.title), notes.map(note => note.title));
|
|
const created = await itemWriteService.createNote(auth.user.sessionId, {
|
|
title: copyTitle,
|
|
body: body.body,
|
|
parentId: body.parentId || existing.parentId,
|
|
}, upstreamRequestContext(request));
|
|
const createdNote = await itemService.noteByUserIdAndJopId(auth.user.id, created.id);
|
|
await saveLastNoteState(settingsService, auth.user.id, currentSettings, created.id, currentFolderId || (createdNote && createdNote.parentId) || body.parentId || existing.parentId);
|
|
sendHtml(response, 200, `${templates.autosaveStatusFragment()}<div id="nav-panel" hx-swap-oob="innerHTML">${templates.navigationFragment(folders, notes.concat([{ id: created.id, title: copyTitle, parentId: body.parentId || existing.parentId, updatedTime: createdNote ? createdNote.updatedTime : 0, deletedTime: 0 }]), selectedFolderForNav(currentFolderId), created.id, '', selectedFolderForNav(currentFolderId))}</div><div id="editor-panel" hx-swap-oob="innerHTML">${templates.editorFragment(createdNote, folders.filter(folder => folder.id !== TRASH_FOLDER_ID), selectedFolderForNav(currentFolderId))}</div>`);
|
|
return;
|
|
}
|
|
if (!forceSave && baseUpdatedTime && Number(existing.updatedTime || 0) !== baseUpdatedTime) {
|
|
sendHtml(response, 200, templates.autosaveConflictFragment(noteId));
|
|
return;
|
|
}
|
|
await itemWriteService.updateNote(auth.user.sessionId, existing, {
|
|
title: plainNoteTitle(body.title),
|
|
body: body.body,
|
|
parentId: body.parentId,
|
|
}, upstreamRequestContext(request));
|
|
// save history snapshot (best-effort, fire-and-forget)
|
|
if (historyService) {
|
|
historyService.saveSnapshot(auth.user.id, noteId, existing.title, existing.body).catch(() => {});
|
|
}
|
|
const refreshed = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
|
|
await saveLastNoteState(settingsService, auth.user.id, currentSettings, noteId, currentFolderId || (refreshed && refreshed.parentId) || body.parentId || existing.parentId);
|
|
|
|
const titleChanged = plainNoteTitle(body.title) !== `${existing.title || ''}`;
|
|
const folderChanged = `${body.parentId || ''}` !== `${existing.parentId || ''}`;
|
|
const needsNav = titleChanged || folderChanged;
|
|
let navOob = '';
|
|
if (needsNav) {
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
navOob = `<div id="nav-panel" hx-swap-oob="innerHTML">${templates.navigationFragment(folders, notes, selectedFolderForNav(currentFolderId), noteId, '', selectedFolderForNav(currentFolderId))}</div>`;
|
|
}
|
|
sendHtml(response, 200, `${templates.autosaveStatusFragment()}${navOob}${templates.noteSyncStateFragment(refreshed || existing).replace('<span id="editor-sync-state">', '<span id="editor-sync-state" hx-swap-oob="outerHTML">')}${templates.noteMetaFragment(refreshed || existing).replace('<span id="note-meta"', '<span id="note-meta" hx-swap-oob="outerHTML"')}`);
|
|
} catch (error) {
|
|
sendHtml(response, error.statusCode || 500, '<span class="autosave-error">Save failed</span>');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Logout (htmx) ---
|
|
if (url.pathname === '/logout' && (request.method === 'POST' || request.method === 'GET')) {
|
|
const sendLoggedOutPage = () => {
|
|
send(response, 200, templates.loggedOutPage(joplinPublicBasePath), {
|
|
'Cache-Control': 'no-store',
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Set-Cookie': expiredSessionCookie(),
|
|
});
|
|
};
|
|
// Return the logout page immediately; upstream logout is best-effort.
|
|
sendLoggedOutPage();
|
|
|
|
const logoutUrl = new URL(joplinServerOrigin);
|
|
const headers = { ...request.headers };
|
|
headers.host = request.headers.host || configuredPublicUrl.host;
|
|
headers['x-forwarded-host'] = headers.host;
|
|
headers['x-forwarded-proto'] = (request.headers['x-forwarded-proto'] || configuredPublicUrl.protocol.replace(':', ''));
|
|
delete headers.origin;
|
|
delete headers.referer;
|
|
|
|
const upstreamReq = http.request({
|
|
hostname: logoutUrl.hostname,
|
|
port: logoutUrl.port,
|
|
path: '/logout',
|
|
method: 'POST',
|
|
headers,
|
|
timeout: 3000,
|
|
}, upstreamRes => {
|
|
upstreamRes.resume();
|
|
});
|
|
upstreamReq.on('timeout', () => upstreamReq.destroy());
|
|
upstreamReq.on('error', () => {});
|
|
if (request.method === 'POST') {
|
|
request.pipe(upstreamReq);
|
|
} else {
|
|
upstreamReq.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- JSON API (kept for potential programmatic use) ---
|
|
if (url.pathname === '/api/web/me') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
sendJson(response, 200, { user: auth.user });
|
|
} catch (error) {
|
|
sendJson(response, 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/web/folders') {
|
|
if (request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
const body = await parseBody(request);
|
|
const title = `${body.title || ''}`.trim();
|
|
if (!title) { sendJson(response, 400, { error: 'Folder title is required' }); return; }
|
|
const created = await itemWriteService.createFolder(auth.user.sessionId, { title, parentId: body.parentId || '' }, upstreamRequestContext(request));
|
|
const folder = await itemService.folderByUserIdAndJopId(auth.user.id, created.id);
|
|
sendJson(response, 201, { item: folder });
|
|
} catch (error) {
|
|
sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
const folders = await itemService.foldersByUserId(auth.user.id);
|
|
sendJson(response, 200, { items: folders });
|
|
} catch (error) {
|
|
sendJson(response, 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/web/folders/') && request.method === 'DELETE') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
const folderId = decodeURIComponent(url.pathname.slice('/api/web/folders/'.length));
|
|
if (!folderId) { sendJson(response, 404, { error: 'Folder not found' }); return; }
|
|
await itemWriteService.deleteFolder(auth.user.sessionId, folderId, upstreamRequestContext(request));
|
|
sendJson(response, 204, {});
|
|
} catch (error) {
|
|
sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/web/notes') {
|
|
if (request.method === 'POST') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
const body = await parseBody(request);
|
|
const parentId = `${body.parentId || ''}`;
|
|
if (!parentId) { sendJson(response, 400, { error: 'Note parentId is required' }); return; }
|
|
const created = await itemWriteService.createNote(auth.user.sessionId, {
|
|
title: plainNoteTitle(body.title),
|
|
body: `${body.body || ''}`,
|
|
parentId,
|
|
}, upstreamRequestContext(request));
|
|
const note = await itemService.noteByUserIdAndJopId(auth.user.id, created.id);
|
|
sendJson(response, 201, { item: note });
|
|
} catch (error) {
|
|
sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
const folderId = url.searchParams.get('folderId') || '';
|
|
const notes = await notesForFolder(itemService, auth.user.id, folderId);
|
|
sendJson(response, 200, { items: notes });
|
|
} catch (error) {
|
|
sendJson(response, 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/web/notes/')) {
|
|
const noteId = decodeURIComponent(url.pathname.slice('/api/web/notes/'.length));
|
|
if (request.method === 'PUT') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
if (!noteId) { sendJson(response, 404, { error: 'Note not found' }); return; }
|
|
const existing = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
|
|
if (!existing) { sendJson(response, 404, { error: 'Note not found' }); return; }
|
|
const body = await parseBody(request);
|
|
const updated = await itemWriteService.updateNote(auth.user.sessionId, existing, {
|
|
title: plainNoteTitle(body.title), body: body.body, parentId: body.parentId,
|
|
}, upstreamRequestContext(request));
|
|
const note = await itemService.noteByUserIdAndJopId(auth.user.id, updated.id);
|
|
sendJson(response, 200, { item: note });
|
|
} catch (error) {
|
|
sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
if (request.method === 'DELETE') {
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
if (!noteId) { sendJson(response, 404, { error: 'Note not found' }); return; }
|
|
await itemWriteService.deleteNote(auth.user.sessionId, noteId, upstreamRequestContext(request));
|
|
sendJson(response, 204, {});
|
|
} catch (error) {
|
|
sendJson(response, error.statusCode || 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error) { sendJson(response, 401, { error: auth.error }); return; }
|
|
if (!noteId) { sendJson(response, 404, { error: 'Note not found' }); return; }
|
|
const note = await itemService.noteByUserIdAndJopId(auth.user.id, noteId);
|
|
if (!note) { sendJson(response, 404, { error: 'Note not found' }); return; }
|
|
sendJson(response, 200, { item: note });
|
|
} catch (error) {
|
|
sendJson(response, 500, { error: error.message || `${error}` });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- POST /login — authenticate via Joplin Server API ---
|
|
if (url.pathname === '/login' && request.method === 'POST') {
|
|
try {
|
|
const body = await parseBody(request);
|
|
const email = body.email || '';
|
|
const password = body.password || '';
|
|
const totp = body.totp || '';
|
|
if (!email || !password) {
|
|
response.writeHead(302, { Location: `/login?error=${encodeURIComponent('Email and password are required')}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
const apiUrl = new URL('/api/sessions', joplinServerOrigin);
|
|
const requestContext = upstreamRequestContext(request);
|
|
const origin = `${requestContext.protocol}://${requestContext.host}`;
|
|
const payload = JSON.stringify({ email, password });
|
|
const loginResult = await new Promise((resolve, reject) => {
|
|
const upstreamRequest = http.request({
|
|
hostname: apiUrl.hostname,
|
|
port: apiUrl.port,
|
|
path: apiUrl.pathname + apiUrl.search,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(payload),
|
|
Host: requestContext.host,
|
|
Origin: origin,
|
|
Referer: `${origin}/login`,
|
|
'X-Forwarded-Host': requestContext.host,
|
|
'X-Forwarded-Proto': requestContext.protocol,
|
|
},
|
|
}, upstreamResponse => {
|
|
const chunks = [];
|
|
upstreamResponse.on('data', chunk => chunks.push(chunk));
|
|
upstreamResponse.on('end', () => {
|
|
resolve({
|
|
statusCode: upstreamResponse.statusCode || 500,
|
|
body: Buffer.concat(chunks).toString('utf8'),
|
|
});
|
|
});
|
|
});
|
|
|
|
upstreamRequest.on('error', reject);
|
|
upstreamRequest.write(payload);
|
|
upstreamRequest.end();
|
|
});
|
|
|
|
if (loginResult.statusCode < 200 || loginResult.statusCode >= 300) {
|
|
response.writeHead(302, { Location: `/login?error=${encodeURIComponent('Invalid email or password')}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
const session = JSON.parse(loginResult.body);
|
|
|
|
// Check per-user MFA (skip for docker-defined admin when IGNORE_ADMIN_MFA is set)
|
|
const user = await sessionService.userBySessionId(session.id);
|
|
const isDockerAdmin = ignoreAdminMfa && adminEmail && user && user.email === adminEmail;
|
|
if (user && !isDockerAdmin) {
|
|
const userTotpSeed = await settingsService.getTotpSeed(user.id);
|
|
if (userTotpSeed) {
|
|
// User has MFA - check if code provided
|
|
if (!totp) {
|
|
// No code yet - show MFA page with pending session
|
|
response.writeHead(302, {
|
|
'Cache-Control': 'no-store',
|
|
'Set-Cookie': `pendingSession=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=300`,
|
|
Location: '/login/mfa',
|
|
});
|
|
response.end();
|
|
return;
|
|
}
|
|
if (!verifyWithSeed(userTotpSeed, totp)) {
|
|
// Code invalid
|
|
response.writeHead(302, { Location: `/login?error=${encodeURIComponent('Invalid authentication code')}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear any pending session, set real session
|
|
if (user) {
|
|
try {
|
|
await ensureStarterContent({ ...user, sessionId: session.id }, request);
|
|
} catch {}
|
|
}
|
|
response.writeHead(302, {
|
|
'Cache-Control': 'no-store',
|
|
'Set-Cookie': [
|
|
`sessionId=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`,
|
|
'pendingSession=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
|
|
],
|
|
Location: '/',
|
|
});
|
|
response.end();
|
|
} catch (error) {
|
|
response.writeHead(302, { Location: `/login?error=${encodeURIComponent(`Login failed: ${error.message || error}`)}` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- GET /login/mfa — MFA code entry page ---
|
|
if (url.pathname === '/login/mfa' && request.method === 'GET') {
|
|
const pendingSession = sessionIdFromHeaders(request.headers, 'pendingSession');
|
|
if (!pendingSession) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
sendHtml(response, 200, templates.mfaPage({
|
|
error: url.searchParams.get('error') || '',
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// --- POST /login/mfa — verify MFA code ---
|
|
if (url.pathname === '/login/mfa' && request.method === 'POST') {
|
|
try {
|
|
const pendingSession = sessionIdFromHeaders(request.headers, 'pendingSession');
|
|
if (!pendingSession) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const body = await parseBody(request);
|
|
const totp = body.totp || '';
|
|
|
|
const user = await sessionService.userBySessionId(pendingSession);
|
|
if (!user) {
|
|
response.writeHead(302, {
|
|
'Set-Cookie': 'pendingSession=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
|
|
Location: '/login?error=Session+expired',
|
|
});
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
const userTotpSeed = await settingsService.getTotpSeed(user.id);
|
|
if (!userTotpSeed || !verifyWithSeed(userTotpSeed, totp)) {
|
|
response.writeHead(302, { Location: '/login/mfa?error=Invalid+code' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
// MFA verified - set real session
|
|
try {
|
|
await ensureStarterContent({ ...user, sessionId: pendingSession }, request);
|
|
} catch {}
|
|
response.writeHead(302, {
|
|
'Cache-Control': 'no-store',
|
|
'Set-Cookie': [
|
|
`sessionId=${pendingSession}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`,
|
|
'pendingSession=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
|
|
],
|
|
Location: '/',
|
|
});
|
|
response.end();
|
|
} catch (error) {
|
|
response.writeHead(302, { Location: '/login/mfa?error=Verification+failed' });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/login' && request.method === 'GET') {
|
|
if (url.searchParams.get('loggedOut') === '1') {
|
|
sendHtml(response, 200, templates.layoutPage({ debug,
|
|
user: null,
|
|
joplinBasePath: joplinPublicBasePath,
|
|
settings: null,
|
|
mfaEnabled: false,
|
|
loginError: url.searchParams.get('error') || '',
|
|
}));
|
|
return;
|
|
}
|
|
const auth = await authenticatedUser(request);
|
|
if (!auth.error && auth.user) {
|
|
response.writeHead(302, { Location: '/' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
sendHtml(response, 200, templates.layoutPage({ debug,
|
|
user: null,
|
|
joplinBasePath: joplinPublicBasePath,
|
|
settings: null,
|
|
mfaEnabled: false,
|
|
loginError: url.searchParams.get('error') || '',
|
|
}));
|
|
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) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
const settings = await userSettings(auth.user.id);
|
|
try {
|
|
await ensureStarterContent(auth.user, request);
|
|
} catch {}
|
|
let { folders, notes } = 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' });
|
|
if (resumed && !resumed.deletedTime) {
|
|
const resumeFolderId = normalizeStoredFolderId(settings.lastNoteFolderId || resumed.parentId || '');
|
|
selectedFolderId = selectedFolderForNav(resumeFolderId || resumed.parentId || '');
|
|
selectedNoteId = resumed.id;
|
|
selectedNoteContextFolderId = selectedFolderId || null;
|
|
editorContent = templates.editorFragment(resumed, folders.filter(folder => folder.id !== TRASH_FOLDER_ID), selectedFolderId || resumed.parentId);
|
|
mobileEditorContent = editorContent;
|
|
mobileStartup = {
|
|
folderId: selectedFolderId || resumed.parentId || '',
|
|
folderTitle: selectedFolderId === ALL_NOTES_FOLDER_ID ? 'All Notes' : ((folders.find(folder => folder.id === (selectedFolderId || resumed.parentId || '')) || {}).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, notes, selectedFolderId, selectedNoteId, '', selectedNoteContextFolderId),
|
|
editorContent,
|
|
joplinBasePath: joplinPublicBasePath,
|
|
}));
|
|
} catch (error) {
|
|
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: serve SSR page for any unknown path (SPA-like)
|
|
try {
|
|
const auth = await authenticatedUser(request);
|
|
if (auth.error || !auth.user) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const settings = await userSettings(auth.user.id);
|
|
const { folders, notes } = await navData(auth.user.id);
|
|
sendHtml(response, 200, templates.layoutPage({ debug,
|
|
user: auth.user,
|
|
settings,
|
|
navContent: templates.navigationFragment(folders, notes, '', ''),
|
|
joplinBasePath: joplinPublicBasePath,
|
|
}));
|
|
} catch (error) {
|
|
response.writeHead(302, { Location: '/login' });
|
|
response.end();
|
|
}
|
|
});
|
|
};
|
|
|
|
module.exports = {
|
|
createServer,
|
|
};
|