Files
NodeBB/src/install.js
Opliko 7f3a9968ef feat: docker improvements (#12031)
* use yarn and debian slim build

* feat: update Dockerfile to use multistage builds

* Create main.yml

* remove some useless things from docker context and assume yarn by default

* remove all dotfiles in docker context

* no need for extra build tools, complain to the module author if there is no alpine build

(cherry picked from commit 90516a3c8399e74c38be7115edb39411ba0d86b9)

* specify the config file location instead of creating it

(cherry picked from commit 38e4295d70682f1049fe671ade96eeccd669d908)

* set explicit config path

(cherry picked from commit 8dcc6f249d099cb8939a95511ec13702491958bc)

* fix docker-compose example to use the exposed volumes

* dockerfile: upgrade alpine to 3.16

* dockerignore: add more ignorable entries

* docker-compose: change the way the docker startup process works

* install: pass config path to child process as well

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* setup: move config file resolution up before setup

This fixes issue with different config file location, which will otherwise default on 'config.json', which means the config save won't save to the file we specified

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-entrypoint: don't fix CONFIG_DIR location but fix default location

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-entrypoint: handle missing config file logic

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* README: add simple notice on how to use it

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* add missing semicolons

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-compose: remove multi override, use one big profile instead

However, Docker Compose doesn't support profile-based dependency and this would probably means we have less guarantee about the liveness of the database. But since this is just a sample configuration it should be fine

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* workflows: remove main.yml, add platforms to buildx matrix in docker.yml

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* workflows: set docker buildx to build for amd64 and arm64 only

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-entrypoint: don't force build everytime before start

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-entrypoint: implement init verb

This would allow you to change between "setup" (automated setup using environmental variables which is the current preferred way to run containerized NodeBB) or "install" (web install that guides user to fill in connection information, which is similar to WordPress)

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* README: mention caveat with MongoDB

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* README: add Docker section placeholder for doc migration

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-entrypoint: add SETUP variable support

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-compose: add force flag to ln on setup

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* docker-compose: fix permission issue; docker-compose: fast exit if still no permission on config dir

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>

* fix: remove redundant FROM

* docs: remove docker stuff (in favour of docs entry, nodebb/docs#78) but add link to cloud install docs

* fix: correctly check if directory is writable

* fix: ignore .docker directory

* fix: multi-arch docker builds and chown performance

* chore: bump database image versions

* fix: move from alpine to slim image

* fix: use omit=dev instead of only=prod

* feat: move entrypoint to install directory

* feat: initialize mongodb user

* feat: use separate rebuild stage

* fix: disable eslint for mongodb script

* fix: remove node_modules bind mount

bind mounts don't save data from container, resulting in a LOONG startup

* feat: prepopulate database defaults for installation

* feat: enable persistence in redis container

* docs: add some comments to the compose file

---------

Signed-off-by: steve <29133953+stevefan1999-personal@users.noreply.github.com>
Co-authored-by: Steve Fan <29133953+stevefan1999-personal@users.noreply.github.com>
Co-authored-by: Steve Fan <19037626d@connect.polyu.hk>
Co-authored-by: Julian Lam <julian@nodebb.org>
2023-11-12 13:38:00 -05:00

633 lines
18 KiB
JavaScript

'use strict';
const fs = require('fs');
const url = require('url');
const path = require('path');
const prompt = require('prompt');
const winston = require('winston');
const nconf = require('nconf');
const _ = require('lodash');
const utils = require('./utils');
const { paths } = require('./constants');
const install = module.exports;
const questions = {};
questions.main = [
{
name: 'url',
description: 'URL used to access this NodeBB',
default:
nconf.get('url') || 'http://localhost:4567',
pattern: /^http(?:s)?:\/\//,
message: 'Base URL must begin with \'http://\' or \'https://\'',
},
{
name: 'secret',
description: 'Please enter a NodeBB secret',
default: nconf.get('secret') || utils.generateUUID(),
},
{
name: 'submitPluginUsage',
description: 'Would you like to submit anonymous plugin usage to nbbpm?',
default: 'yes',
},
{
name: 'database',
description: 'Which database to use',
default: nconf.get('database') || 'mongo',
},
];
questions.optional = [
{
name: 'port',
default: nconf.get('port') || 4567,
},
];
function checkSetupFlagEnv() {
let setupVal = install.values;
const envConfMap = {
CONFIG: 'config',
NODEBB_CONFIG: 'config',
NODEBB_URL: 'url',
NODEBB_PORT: 'port',
NODEBB_ADMIN_USERNAME: 'admin:username',
NODEBB_ADMIN_PASSWORD: 'admin:password',
NODEBB_ADMIN_EMAIL: 'admin:email',
NODEBB_DB: 'database',
NODEBB_DB_HOST: 'host',
NODEBB_DB_PORT: 'port',
NODEBB_DB_USER: 'username',
NODEBB_DB_PASSWORD: 'password',
NODEBB_DB_NAME: 'database',
NODEBB_DB_SSL: 'ssl',
};
// Set setup values from env vars (if set)
const envKeys = Object.keys(process.env);
if (Object.keys(envConfMap).some(key => envKeys.includes(key))) {
winston.info('[install/checkSetupFlagEnv] checking env vars for setup info...');
setupVal = setupVal || {};
Object.entries(process.env).forEach(([evName, evValue]) => { // get setup values from env
if (evName.startsWith('NODEBB_DB_')) {
setupVal[`${process.env.NODEBB_DB}:${envConfMap[evName]}`] = evValue;
} else if (evName.startsWith('NODEBB_')) {
setupVal[envConfMap[evName]] = evValue;
}
});
setupVal['admin:password:confirm'] = setupVal['admin:password'];
}
// try to get setup values from json, if successful this overwrites all values set by env
// TODO: better behaviour would be to support overrides per value, i.e. in order of priority (generic pattern):
// flag, env, config file, default
try {
if (nconf.get('setup')) {
const setupJSON = JSON.parse(nconf.get('setup'));
setupVal = { ...setupVal, ...setupJSON };
}
} catch (err) {
winston.error('[install/checkSetupFlagEnv] invalid json in nconf.get(\'setup\'), ignoring setup values from json');
}
if (setupVal && typeof setupVal === 'object') {
if (setupVal['admin:username'] && setupVal['admin:password'] && setupVal['admin:password:confirm'] && setupVal['admin:email']) {
install.values = setupVal;
} else {
winston.error('[install/checkSetupFlagEnv] required values are missing for automated setup:');
if (!setupVal['admin:username']) {
winston.error(' admin:username');
}
if (!setupVal['admin:password']) {
winston.error(' admin:password');
}
if (!setupVal['admin:password:confirm']) {
winston.error(' admin:password:confirm');
}
if (!setupVal['admin:email']) {
winston.error(' admin:email');
}
process.exit();
}
} else if (nconf.get('database')) {
install.values = install.values || {};
install.values.database = nconf.get('database');
}
}
function checkCIFlag() {
let ciVals;
try {
ciVals = JSON.parse(nconf.get('ci'));
} catch (e) {
ciVals = undefined;
}
if (ciVals && ciVals instanceof Object) {
if (ciVals.hasOwnProperty('host') && ciVals.hasOwnProperty('port') && ciVals.hasOwnProperty('database')) {
install.ciVals = ciVals;
} else {
winston.error('[install/checkCIFlag] required values are missing for automated CI integration:');
if (!ciVals.hasOwnProperty('host')) {
winston.error(' host');
}
if (!ciVals.hasOwnProperty('port')) {
winston.error(' port');
}
if (!ciVals.hasOwnProperty('database')) {
winston.error(' database');
}
process.exit();
}
}
}
async function setupConfig() {
const configureDatabases = require('../install/databases');
// prompt prepends "prompt: " to questions, let's clear that.
prompt.start();
prompt.message = '';
prompt.delimiter = '';
prompt.colors = false;
let config = {};
if (install.values) {
// Use provided values, fall back to defaults
const redisQuestions = require('./database/redis').questions;
const mongoQuestions = require('./database/mongo').questions;
const postgresQuestions = require('./database/postgres').questions;
const allQuestions = [
...questions.main,
...questions.optional,
...redisQuestions,
...mongoQuestions,
...postgresQuestions,
];
allQuestions.forEach((question) => {
if (install.values.hasOwnProperty(question.name)) {
config[question.name] = install.values[question.name];
} else if (question.hasOwnProperty('default')) {
config[question.name] = question.default;
} else {
config[question.name] = undefined;
}
});
} else {
config = await prompt.get(questions.main);
}
await configureDatabases(config);
await completeConfigSetup(config);
}
async function completeConfigSetup(config) {
// Add CI object
if (install.ciVals) {
config.test_database = { ...install.ciVals };
}
// Add package_manager object if set
if (nconf.get('package_manager')) {
config.package_manager = nconf.get('package_manager');
}
nconf.overrides(config);
const db = require('./database');
await db.init();
if (db.hasOwnProperty('createIndices')) {
await db.createIndices();
}
// Sanity-check/fix url/port
if (!/^http(?:s)?:\/\//.test(config.url)) {
config.url = `http://${config.url}`;
}
// If port is explicitly passed via install vars, use it. Otherwise, glean from url if set.
const urlObj = url.parse(config.url);
if (urlObj.port && (!install.values || !install.values.hasOwnProperty('port'))) {
config.port = urlObj.port;
}
// Remove trailing slash from non-subfolder installs
if (urlObj.path === '/') {
urlObj.path = '';
urlObj.pathname = '';
}
config.url = url.format(urlObj);
// ref: https://github.com/indexzero/nconf/issues/300
delete config.type;
const meta = require('./meta');
await meta.configs.set('submitPluginUsage', config.submitPluginUsage === 'yes' ? 1 : 0);
delete config.submitPluginUsage;
await install.save(config);
}
async function setupDefaultConfigs() {
console.log('Populating database with default configs, if not already set...');
const meta = require('./meta');
const defaults = require(path.join(__dirname, '../', 'install/data/defaults.json'));
await meta.configs.setOnEmpty(defaults);
await meta.configs.init();
}
async function enableDefaultTheme() {
const meta = require('./meta');
const id = await meta.configs.get('theme:id');
if (id) {
console.log('Previous theme detected, skipping enabling default theme');
return;
}
const defaultTheme = nconf.get('defaultTheme') || 'nodebb-theme-harmony';
console.log(`Enabling default theme: ${defaultTheme}`);
await meta.themes.set({
type: 'local',
id: defaultTheme,
});
}
async function createDefaultUserGroups() {
const groups = require('./groups');
async function createGroup(name) {
await groups.create({
name: name,
hidden: 1,
private: 1,
system: 1,
disableLeave: 1,
disableJoinRequests: 1,
});
}
const [verifiedExists, unverifiedExists, bannedExists] = await groups.exists([
'verified-users', 'unverified-users', 'banned-users',
]);
if (!verifiedExists) {
await createGroup('verified-users');
}
if (!unverifiedExists) {
await createGroup('unverified-users');
}
if (!bannedExists) {
await createGroup('banned-users');
}
}
async function createAdministrator() {
const Groups = require('./groups');
const memberCount = await Groups.getMemberCount('administrators');
if (memberCount > 0) {
console.log('Administrator found, skipping Admin setup');
return;
}
return await createAdmin();
}
async function createAdmin() {
const User = require('./user');
const Groups = require('./groups');
let password;
winston.warn('No administrators have been detected, running initial user setup\n');
let questions = [{
name: 'username',
description: 'Administrator username',
required: true,
type: 'string',
}, {
name: 'email',
description: 'Administrator email address',
pattern: /.+@.+/,
required: true,
}];
const passwordQuestions = [{
name: 'password',
description: 'Password',
required: true,
hidden: true,
type: 'string',
}, {
name: 'password:confirm',
description: 'Confirm Password',
required: true,
hidden: true,
type: 'string',
}];
async function success(results) {
if (!results) {
throw new Error('aborted');
}
if (results['password:confirm'] !== results.password) {
winston.warn('Passwords did not match, please try again');
return await retryPassword(results);
}
try {
User.isPasswordValid(results.password);
} catch (err) {
const [namespace, key] = err.message.slice(2, -2).split(':', 2);
if (namespace && key && err.message.startsWith('[[') && err.message.endsWith(']]')) {
const lang = require(path.join(__dirname, `../public/language/en-GB/${namespace}`));
if (lang && lang[key]) {
err.message = lang[key];
}
}
winston.warn(`Password error, please try again. ${err.message}`);
return await retryPassword(results);
}
const adminUid = await User.create({
username: results.username,
password: results.password,
email: results.email,
});
await Groups.join('administrators', adminUid);
await Groups.show('administrators');
await Groups.ownership.grant(adminUid, 'administrators');
return password ? results : undefined;
}
async function retryPassword(originalResults) {
// Ask only the password questions
const results = await prompt.get(passwordQuestions);
// Update the original data with newly collected password
originalResults.password = results.password;
originalResults['password:confirm'] = results['password:confirm'];
// Send back to success to handle
return await success(originalResults);
}
// Add the password questions
questions = questions.concat(passwordQuestions);
if (!install.values) {
const results = await prompt.get(questions);
return await success(results);
}
// If automated setup did not provide a user password, generate one,
// it will be shown to the user upon setup completion
if (!install.values.hasOwnProperty('admin:password') && !nconf.get('admin:password')) {
console.log('Password was not provided during automated setup, generating one...');
password = utils.generateUUID().slice(0, 8);
}
const results = {
username: install.values['admin:username'] || nconf.get('admin:username') || 'admin',
email: install.values['admin:email'] || nconf.get('admin:email') || '',
password: install.values['admin:password'] || nconf.get('admin:password') || password,
'password:confirm': install.values['admin:password:confirm'] || nconf.get('admin:password') || password,
};
return await success(results);
}
async function createGlobalModeratorsGroup() {
const groups = require('./groups');
const exists = await groups.exists('Global Moderators');
if (exists) {
winston.info('Global Moderators group found, skipping creation!');
} else {
await groups.create({
name: 'Global Moderators',
userTitle: 'Global Moderator',
description: 'Forum wide moderators',
hidden: 0,
private: 1,
disableJoinRequests: 1,
});
}
await groups.show('Global Moderators');
}
async function giveGlobalPrivileges() {
const privileges = require('./privileges');
const defaultPrivileges = [
'groups:chat', 'groups:upload:post:image', 'groups:signature', 'groups:search:content',
'groups:search:users', 'groups:search:tags', 'groups:view:users', 'groups:view:tags', 'groups:view:groups',
'groups:local:login',
];
await privileges.global.give(defaultPrivileges, 'registered-users');
await privileges.global.give(defaultPrivileges.concat([
'groups:ban', 'groups:upload:post:file', 'groups:view:users:info',
]), 'Global Moderators');
await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'guests');
await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'spiders');
}
async function createCategories() {
const Categories = require('./categories');
const db = require('./database');
const cids = await db.getSortedSetRange('categories:cid', 0, -1);
if (Array.isArray(cids) && cids.length) {
console.log(`Categories OK. Found ${cids.length} categories.`);
return;
}
console.log('No categories found, populating instance with default categories');
const default_categories = JSON.parse(
await fs.promises.readFile(path.join(__dirname, '../', 'install/data/categories.json'), 'utf8')
);
for (const categoryData of default_categories) {
// eslint-disable-next-line no-await-in-loop
await Categories.create(categoryData);
}
}
async function createMenuItems() {
const db = require('./database');
const exists = await db.exists('navigation:enabled');
if (exists) {
return;
}
const navigation = require('./navigation/admin');
const data = require('../install/data/navigation.json');
await navigation.save(data);
}
async function createWelcomePost() {
const db = require('./database');
const Topics = require('./topics');
const [content, numTopics] = await Promise.all([
fs.promises.readFile(path.join(__dirname, '../', 'install/data/welcome.md'), 'utf8'),
db.getObjectField('global', 'topicCount'),
]);
if (!parseInt(numTopics, 10)) {
console.log('Creating welcome post!');
await Topics.post({
uid: 1,
cid: 2,
title: 'Welcome to your NodeBB!',
content: content,
});
}
}
async function enableDefaultPlugins() {
console.log('Enabling default plugins');
let defaultEnabled = [
'nodebb-plugin-composer-default',
'nodebb-plugin-markdown',
'nodebb-plugin-mentions',
'nodebb-widget-essentials',
'nodebb-rewards-essentials',
'nodebb-plugin-emoji',
'nodebb-plugin-emoji-android',
];
let customDefaults = nconf.get('defaultplugins') || nconf.get('defaultPlugins');
winston.info(`[install/defaultPlugins] customDefaults ${String(customDefaults)}`);
if (customDefaults && customDefaults.length) {
try {
customDefaults = Array.isArray(customDefaults) ? customDefaults : JSON.parse(customDefaults);
defaultEnabled = defaultEnabled.concat(customDefaults);
} catch (e) {
// Invalid value received
winston.info('[install/enableDefaultPlugins] Invalid defaultPlugins value received. Ignoring.');
}
}
defaultEnabled = _.uniq(defaultEnabled);
winston.info('[install/enableDefaultPlugins] activating default plugins', defaultEnabled);
const db = require('./database');
const order = defaultEnabled.map((plugin, index) => index);
await db.sortedSetAdd('plugins:active', order, defaultEnabled);
}
async function setCopyrightWidget() {
const db = require('./database');
const [footerJSON, footer] = await Promise.all([
fs.promises.readFile(path.join(__dirname, '../', 'install/data/footer.json'), 'utf8'),
db.getObjectField('widgets:global', 'footer'),
]);
if (!footer && footerJSON) {
await db.setObjectField('widgets:global', 'sidebar-footer', footerJSON);
}
}
async function copyFavicon() {
const file = require('./file');
const pathToIco = path.join(nconf.get('upload_path'), 'system', 'favicon.ico');
const defaultIco = path.join(nconf.get('base_dir'), 'public', 'favicon.ico');
const targetExists = await file.exists(pathToIco);
const defaultExists = await file.exists(defaultIco);
if (defaultExists && !targetExists) {
try {
await fs.promises.copyFile(defaultIco, pathToIco);
} catch (err) {
winston.error(`Cannot copy favicon.ico\n${err.stack}`);
}
}
}
async function checkUpgrade() {
const upgrade = require('./upgrade');
try {
await upgrade.check();
} catch (err) {
if (err.message === 'schema-out-of-date') {
await upgrade.run();
return;
}
throw err;
}
}
async function installPlugins() {
const pluginInstall = require('./plugins');
const nbbVersion = require(paths.currentPackage).version;
await Promise.all((await pluginInstall.getActive()).map(async (id) => {
if (await pluginInstall.isInstalled(id)) return;
const version = await pluginInstall.suggest(id, nbbVersion);
await pluginInstall.toggleInstall(id, version.version);
}));
}
install.setup = async function () {
try {
checkSetupFlagEnv();
checkCIFlag();
await setupConfig();
await setupDefaultConfigs();
await enableDefaultTheme();
await createCategories();
await createDefaultUserGroups();
const adminInfo = await createAdministrator();
await createGlobalModeratorsGroup();
await giveGlobalPrivileges();
await createMenuItems();
await createWelcomePost();
await enableDefaultPlugins();
await setCopyrightWidget();
await copyFavicon();
if (nconf.get('plugins:autoinstall')) await installPlugins();
await checkUpgrade();
const data = {
...adminInfo,
};
return data;
} catch (err) {
if (err) {
winston.warn(`NodeBB Setup Aborted.\n ${err.stack}`);
process.exit(1);
}
}
};
install.save = async function (server_conf) {
let serverConfigPath = path.join(__dirname, '../config.json');
if (nconf.get('config')) {
serverConfigPath = path.resolve(__dirname, '../', nconf.get('config'));
}
let currentConfig = {};
try {
currentConfig = require(serverConfigPath);
} catch (err) {
if (err.code !== 'MODULE_NOT_FOUND') {
throw err;
}
}
await fs.promises.writeFile(serverConfigPath, JSON.stringify({ ...currentConfig, ...server_conf }, null, 4));
console.log('Configuration Saved OK');
nconf.file({
file: serverConfigPath,
});
};