mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-05-23 04:28:34 +00:00
266 lines
9.8 KiB
JavaScript
266 lines
9.8 KiB
JavaScript
'use strict';
|
|
|
|
const http = require('http');
|
|
const bcrypt = require('bcryptjs');
|
|
|
|
// ---------- password strength ----------
|
|
const isStrongPassword = password => {
|
|
if (!password || password.length < 12) return false;
|
|
const hasLower = /[a-z]/.test(password);
|
|
const hasUpper = /[A-Z]/.test(password);
|
|
const hasDigit = /[0-9]/.test(password);
|
|
const hasSpecial = /[^a-zA-Z0-9]/.test(password);
|
|
const categories = [hasLower, hasUpper, hasDigit, hasSpecial].filter(Boolean).length;
|
|
return categories >= 3;
|
|
};
|
|
|
|
// ---------- HTTP helper to Joplin internal API ----------
|
|
const requestJoplin = (origin, method, path, body, sessionId, publicUrl) => new Promise((resolve, reject) => {
|
|
const url = new URL(origin);
|
|
const pub = publicUrl ? new URL(publicUrl) : url;
|
|
const payload = body ? JSON.stringify(body) : null;
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
Host: pub.host,
|
|
Origin: pub.origin,
|
|
'X-Forwarded-Host': pub.host,
|
|
'X-Forwarded-Proto': pub.protocol.replace(':', ''),
|
|
};
|
|
if (sessionId) headers['x-api-auth'] = sessionId;
|
|
if (payload) headers['Content-Length'] = Buffer.byteLength(payload);
|
|
|
|
const req = http.request({
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path,
|
|
method,
|
|
headers,
|
|
}, res => {
|
|
const chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
let json = null;
|
|
try { json = JSON.parse(text); } catch {}
|
|
resolve({ statusCode: res.statusCode, body: json, raw: text });
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
|
|
const createAdminService = ({ database, joplinServerOrigin, joplinServerPublicUrl, adminEmail, adminPassword }) => {
|
|
// token cache
|
|
let _token = null;
|
|
let _tokenExpiry = 0;
|
|
const TOKEN_TTL_MS = 11 * 60 * 60 * 1000; // 11h
|
|
|
|
const getAdminToken = async () => {
|
|
if (_token && Date.now() < _tokenExpiry) return _token;
|
|
const res = await requestJoplin(joplinServerOrigin, 'POST', '/api/sessions', {
|
|
email: adminEmail,
|
|
password: adminPassword,
|
|
}, null, joplinServerPublicUrl);
|
|
if (!res.body || !res.body.id) {
|
|
throw new Error(`Admin login failed (${res.statusCode}): ${res.raw}`);
|
|
}
|
|
_token = res.body.id;
|
|
_tokenExpiry = Date.now() + TOKEN_TTL_MS;
|
|
return _token;
|
|
};
|
|
|
|
// ---------- Bootstrap: ensure admin user exists ----------
|
|
const ensureAdminUser = async () => {
|
|
// Validate password strength first
|
|
if (!isStrongPassword(adminPassword)) {
|
|
process.stderr.write('[joplock] FATAL: JOPLOCK_ADMIN_PASSWORD is too weak. Must be ≥12 chars with letters, numbers, and at least 3 character categories.\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check if admin user exists in Joplin DB
|
|
let rows;
|
|
try {
|
|
const result = await database.query(
|
|
'SELECT id, email, is_admin, enabled FROM users WHERE email = $1 LIMIT 1',
|
|
[adminEmail],
|
|
);
|
|
rows = result.rows;
|
|
} catch (err) {
|
|
process.stderr.write(`[joplock] WARNING: Could not query users table for admin bootstrap: ${err.message}\n`);
|
|
return;
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
// Fresh install — create user directly in DB
|
|
process.stdout.write(`[joplock] Creating admin user ${adminEmail}...\n`);
|
|
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
|
const now = Date.now();
|
|
const id = require('crypto').randomBytes(16).toString('hex');
|
|
try {
|
|
await database.query(`
|
|
INSERT INTO users (id, email, password, is_admin, enabled, email_confirmed, created_time, updated_time, must_set_password, account_type, max_item_size, can_share_folder, can_share_note, max_total_item_size)
|
|
VALUES ($1, $2, $3, 1, 1, 1, $4, $4, 0, 0, 0, 1, 1, 0)
|
|
`, [id, adminEmail, passwordHash, now]);
|
|
process.stdout.write(`[joplock] Admin user created.\n`);
|
|
} catch (err) {
|
|
// Fallback: minimal insert if some columns don't exist
|
|
try {
|
|
await database.query(`
|
|
INSERT INTO users (id, email, password, is_admin, enabled, email_confirmed, created_time, updated_time)
|
|
VALUES ($1, $2, $3, 1, 1, 1, $4, $4)
|
|
`, [id, adminEmail, passwordHash, now]);
|
|
process.stdout.write(`[joplock] Admin user created (minimal).\n`);
|
|
} catch (err2) {
|
|
process.stderr.write(`[joplock] ERROR: Could not create admin user: ${err2.message}\n`);
|
|
}
|
|
}
|
|
} else {
|
|
const user = rows[0];
|
|
const needsFix = !Number(user.is_admin) || !Number(user.enabled);
|
|
// Always reset password to match docker-defined JOPLOCK_ADMIN_PASSWORD
|
|
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
|
const now = Date.now();
|
|
try {
|
|
await database.query(
|
|
'UPDATE users SET password=$1, is_admin=1, enabled=1, updated_time=$2 WHERE email=$3',
|
|
[passwordHash, now, adminEmail],
|
|
);
|
|
if (needsFix) {
|
|
process.stdout.write(`[joplock] Admin user fixed (is_admin/enabled) and password reset.\n`);
|
|
}
|
|
} catch (err) {
|
|
process.stderr.write(`[joplock] WARNING: Could not reset admin password: ${err.message}\n`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---------- User operations ----------
|
|
|
|
const listUsers = async () => {
|
|
const token = await getAdminToken();
|
|
const res = await requestJoplin(joplinServerOrigin, 'GET', '/api/users', null, token, joplinServerPublicUrl);
|
|
if (!res.body || !res.body.items) throw Object.assign(new Error('Failed to list users'), { statusCode: res.statusCode });
|
|
|
|
// Augment with enabled flag from DB
|
|
const apiUsers = res.body.items;
|
|
const ids = apiUsers.map(u => u.id);
|
|
let enabledMap = {};
|
|
if (ids.length) {
|
|
try {
|
|
const result = await database.query(
|
|
`SELECT id, enabled FROM users WHERE id = ANY($1)`,
|
|
[ids],
|
|
);
|
|
for (const row of result.rows) {
|
|
enabledMap[row.id] = !!Number(row.enabled);
|
|
}
|
|
} catch {}
|
|
}
|
|
return apiUsers.map(u => ({ ...u, enabled: enabledMap[u.id] !== undefined ? enabledMap[u.id] : true }));
|
|
};
|
|
|
|
const createUser = async (email, fullName, password) => {
|
|
const token = await getAdminToken();
|
|
// Step 1: create user (Joplin sets a random password + must_set_password=1)
|
|
const createRes = await requestJoplin(joplinServerOrigin, 'POST', '/api/users', { email, full_name: fullName }, token, joplinServerPublicUrl);
|
|
if (!createRes.body || !createRes.body.id) {
|
|
throw Object.assign(new Error(createRes.body && createRes.body.error ? createRes.body.error : 'Create user failed'), { statusCode: createRes.statusCode });
|
|
}
|
|
const userId = createRes.body.id;
|
|
// Step 2: set real password + clear must_set_password
|
|
const passwordRes = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
|
|
password,
|
|
must_set_password: 0,
|
|
}, token, joplinServerPublicUrl);
|
|
if (passwordRes.statusCode >= 400) {
|
|
throw Object.assign(new Error(passwordRes.body && passwordRes.body.error ? passwordRes.body.error : 'Set password failed'), { statusCode: passwordRes.statusCode });
|
|
}
|
|
// Step 3: confirm email via direct DB write
|
|
try {
|
|
await database.query('UPDATE users SET email_confirmed=1, enabled=1 WHERE id=$1', [userId]);
|
|
} catch {}
|
|
return { id: userId, email, full_name: fullName };
|
|
};
|
|
|
|
const resetPassword = async (userId, newPassword) => {
|
|
const token = await getAdminToken();
|
|
const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
|
|
password: newPassword,
|
|
must_set_password: 0,
|
|
}, token, joplinServerPublicUrl);
|
|
if (res.statusCode >= 400) {
|
|
throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Reset password failed'), { statusCode: res.statusCode });
|
|
}
|
|
};
|
|
|
|
const setEnabled = async (userId, enabled) => {
|
|
await database.query('UPDATE users SET enabled=$1 WHERE id=$2', [enabled ? 1 : 0, userId]);
|
|
};
|
|
|
|
const deleteUser = async (userId) => {
|
|
// Disable first, then schedule deletion via user_deletions table
|
|
await setEnabled(userId, false);
|
|
const now = Date.now();
|
|
try {
|
|
await database.query(`
|
|
INSERT INTO user_deletions (id, owner_id, process_data, process_account, scheduled_time)
|
|
VALUES ($1, $2, 1, 1, $3)
|
|
ON CONFLICT DO NOTHING
|
|
`, [require('crypto').randomBytes(16).toString('hex'), userId, now]);
|
|
} catch (err) {
|
|
// Try without ON CONFLICT if old schema
|
|
try {
|
|
await database.query(`
|
|
INSERT INTO user_deletions (id, owner_id, process_data, process_account, scheduled_time)
|
|
VALUES ($1, $2, 1, 1, $3)
|
|
`, [require('crypto').randomBytes(16).toString('hex'), userId, now]);
|
|
} catch (err2) {
|
|
process.stderr.write(`[joplock] WARNING: Could not insert user_deletions: ${err2.message}\n`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateProfile = async (sessionToken, userId, { fullName, email }) => {
|
|
const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
|
|
...(fullName !== undefined ? { full_name: fullName } : {}),
|
|
...(email !== undefined ? { email } : {}),
|
|
}, sessionToken, joplinServerPublicUrl);
|
|
if (res.statusCode >= 400) {
|
|
throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Update profile failed'), { statusCode: res.statusCode });
|
|
}
|
|
return res.body;
|
|
};
|
|
|
|
const verifyPassword = async (email, password) => {
|
|
const res = await requestJoplin(joplinServerOrigin, 'POST', '/api/sessions', { email, password }, null, joplinServerPublicUrl);
|
|
if (res.statusCode < 200 || res.statusCode >= 300) return null;
|
|
return res.body && res.body.id ? res.body.id : null;
|
|
};
|
|
|
|
const changePassword = async (sessionToken, userId, newPassword) => {
|
|
const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
|
|
password: newPassword,
|
|
must_set_password: 0,
|
|
}, sessionToken, joplinServerPublicUrl);
|
|
if (res.statusCode >= 400) {
|
|
throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Change password failed'), { statusCode: res.statusCode });
|
|
}
|
|
};
|
|
|
|
return {
|
|
ensureAdminUser,
|
|
getAdminToken,
|
|
listUsers,
|
|
createUser,
|
|
resetPassword,
|
|
setEnabled,
|
|
deleteUser,
|
|
updateProfile,
|
|
verifyPassword,
|
|
changePassword,
|
|
adminEmail,
|
|
};
|
|
};
|
|
|
|
module.exports = { createAdminService, isStrongPassword };
|