feat: Add internal runtime configuration

This commit is contained in:
Maksim Eltyshev
2026-01-22 18:02:42 +01:00
parent b852481850
commit 1264fd5715
79 changed files with 561 additions and 122 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ SECRET_KEY=notsecretkey
# INTERNAL_ACCESS_TOKEN=
# STORAGE_LIMIT=
# ACTIVE_USER_LIMIT=
# ACTIVE_USERS_LIMIT=
# CUSTOMER_PANEL_URL=
# DEMO_MODE=true
@@ -0,0 +1,51 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
};
module.exports = {
inputs: {
storageLimit: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
allowNull: true,
},
activeUsersLimit: {
type: 'number',
min: 0,
allowNull: true,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
},
async fn(inputs) {
// eslint-disable-next-line no-restricted-syntax
for (const fieldName of Object.keys(inputs)) {
if (!_.isNil(sails.config.custom[fieldName])) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
}
const values = _.pick(inputs, ['storageLimit', 'activeUsersLimit']);
const internalConfig = await sails.helpers.internalConfig.updateMain.with({
values,
});
return {
item: internalConfig,
};
},
};
@@ -202,11 +202,11 @@ module.exports = {
({ user } = await User.qm.updateOne(user.id, values));
}
const config = await Config.qm.getOneMain();
const internalConfig = await InternalConfig.qm.getOneMain();
if (!config.isInitialized) {
if (!internalConfig.isInitialized) {
if (user.role === User.Roles.ADMIN) {
await Config.qm.updateOneMain({
await InternalConfig.qm.updateOneMain({
isInitialized: true,
});
} else {
@@ -119,7 +119,7 @@
* enum:
* - Email already in use
* - Username already in use
* - Active user limit reached
* - Active users limit reached
* description: Specific error message
* example: Email already in use
* 422:
@@ -182,8 +182,8 @@ const Errors = {
USERNAME_ALREADY_IN_USE: {
usernameAlreadyInUse: 'Username already in use',
},
ACTIVE_USER_LIMIT_REACHED: {
activeUserLimitReached: 'Active user limit reached',
ACTIVE_USERS_LIMIT_REACHED: {
activeUsersLimitReached: 'Active users limit reached',
},
MISSING_VALUES: {
missingValues: 'Unable to retrieve required values (email, name)',
@@ -229,7 +229,7 @@ module.exports = {
usernameAlreadyInUse: {
responseType: 'conflict',
},
activeUserLimitReached: {
activeUsersLimitReached: {
responseType: 'conflict',
},
missingValues: {
@@ -250,7 +250,7 @@ module.exports = {
.intercept('invalidUserinfoConfiguration', () => Errors.INVALID_USERINFO_CONFIGURATION)
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
.intercept('activeLimitReached', () => Errors.ACTIVE_USER_LIMIT_REACHED)
.intercept('activeLimitReached', () => Errors.ACTIVE_USERS_LIMIT_REACHED)
.intercept('missingValues', () => Errors.MISSING_VALUES);
return sails.helpers.accessTokens.handleSteps
+3 -2
View File
@@ -47,7 +47,7 @@
* type: boolean
* description: Whether OIDC authentication is enforced (users must use OIDC to login)
* example: false
* activeUserLimit:
* activeUsersLimit:
* type: number
* nullable: true
* description: Maximum number of active users allowed (conditionally added for admins if configured)
@@ -68,10 +68,11 @@ module.exports = {
async fn() {
const { currentUser } = this.req;
const internalConfig = await InternalConfig.qm.getOneMain();
const oidc = await sails.hooks.oidc.getBootstrap();
return {
item: sails.helpers.bootstrap.presentOne(oidc, currentUser),
item: sails.helpers.bootstrap.presentOne(internalConfig, oidc, currentUser),
};
},
};
@@ -42,12 +42,12 @@ module.exports = {
},
async fn(inputs) {
const config = await Config.qm.getOneMain();
const internalConfig = await InternalConfig.qm.getOneMain();
if (!config.isInitialized) {
if (!internalConfig.isInitialized) {
if (inputs.user.role === User.Roles.ADMIN) {
if (inputs.user.termsSignature) {
await Config.qm.updateOneMain({
await InternalConfig.qm.updateOneMain({
isInitialized: true,
});
}
+5 -1
View File
@@ -7,6 +7,10 @@ module.exports = {
sync: true,
inputs: {
internalConfig: {
type: 'ref',
required: true,
},
oidc: {
type: 'ref',
},
@@ -22,7 +26,7 @@ module.exports = {
};
if (inputs.user && inputs.user.role === User.Roles.ADMIN) {
Object.assign(data, {
activeUserLimit: sails.config.custom.activeUserLimit,
activeUsersLimit: inputs.internalConfig.activeUsersLimit,
customerPanelUrl: sails.config.custom.customerPanelUrl,
});
}
@@ -0,0 +1,54 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
values: {
type: 'json',
required: true,
},
},
async fn(inputs) {
const { values } = inputs;
const { internalConfig, deactivatedUserIds, prev } =
await InternalConfig.qm.updateOneMain(values);
if (deactivatedUserIds) {
deactivatedUserIds.forEach((userId) => {
sails.sockets.broadcast(`user:${userId}`, 'logout');
sails.sockets.leaveAll(`@user:${userId}`);
});
}
if (internalConfig.activeUsersLimit !== prev.activeUsersLimit) {
let adminUserIds;
if (deactivatedUserIds && deactivatedUserIds.length > 0) {
const users = await User.qm.getAll({
roleOrRoles: [User.Roles.ADMIN, User.Roles.PROJECT_OWNER],
});
adminUserIds = users.flatMap((user) => {
sails.sockets.broadcast(`user:${user.id}`, 'usersReset');
return user.role === User.Roles.ADMIN ? user.id : [];
});
} else {
adminUserIds = await sails.helpers.users.getAllIds(User.Roles.ADMIN);
}
adminUserIds.forEach((userId) => {
sails.sockets.broadcast(`user:${userId}`, 'bootstrapUpdate', {
item: {
activeUsersLimit: internalConfig.activeUsersLimit,
},
});
});
}
return internalConfig;
},
};
+1 -8
View File
@@ -112,14 +112,7 @@ module.exports = {
}
if (!_.isUndefined(values.password) || isDeactivatedChangeToTrue) {
sails.sockets.broadcast(
`user:${user.id}`,
'userDelete', // TODO: introduce separate event
{
item: sails.helpers.users.presentOne(user, user),
},
inputs.request,
);
sails.sockets.broadcast(`user:${user.id}`, 'logout', undefined, inputs.request);
if (
!isDeactivatedChangeToTrue &&
@@ -3,9 +3,12 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const bytes = require('bytes');
module.exports = {
async fn() {
const { storageLimit } = sails.config.custom;
let { storageLimit } = await InternalConfig.qm.getOneMain();
storageLimit = bytes(storageLimit);
if (storageLimit === null) {
return null;
@@ -0,0 +1,66 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const { makeRowToModelTransformer } = require('../helpers');
const transformRowToModel = makeRowToModelTransformer(InternalConfig);
/* Query methods */
const getOneMain = () => InternalConfig.findOne(InternalConfig.MAIN_ID);
const updateOneMain = (values) =>
sails.getDatastore().transaction(async (db) => {
let queryResult = await sails
.sendNativeQuery(
'SELECT active_users_limit FROM internal_config WHERE id = $1 LIMIT 1 FOR UPDATE',
[InternalConfig.MAIN_ID],
)
.usingConnection(db);
const prev = transformRowToModel(queryResult.rows[0]);
const internalConfig = await InternalConfig.updateOne(InternalConfig.MAIN_ID)
.set({ ...values })
.usingConnection(db);
let deactivatedUserIds;
if (
_.isInteger(internalConfig.activeUsersLimit) &&
(prev.activeUsersLimit === null || internalConfig.activeUsersLimit < prev.activeUsersLimit)
) {
const { defaultAdminEmail } = sails.config.custom;
const query = `
WITH user_to_deactivate AS (
SELECT id
FROM user_account
WHERE is_deactivated = false
ORDER BY
CASE ${defaultAdminEmail ? 'WHEN email = $1 THEN 0 WHEN role = $2 THEN 1' : 'WHEN role = $1 THEN 0'} ELSE ${defaultAdminEmail ? '2' : '1'} END,
id
OFFSET $${defaultAdminEmail ? 3 : 2}
)
UPDATE user_account
SET is_deactivated = true
WHERE id IN (SELECT id FROM user_to_deactivate)
RETURNING id
`;
const queryValues = defaultAdminEmail
? [defaultAdminEmail, User.Roles.ADMIN, internalConfig.activeUsersLimit]
: [User.Roles.ADMIN, internalConfig.activeUsersLimit];
queryResult = await sails.sendNativeQuery(query, queryValues).usingConnection(db);
deactivatedUserIds = queryResult.rows.map((row) => row.id);
}
return { internalConfig, deactivatedUserIds, prev };
});
module.exports = {
getOneMain,
updateOneMain,
};
@@ -24,8 +24,10 @@ const defaultFind = (criteria) => User.find(criteria).sort('id');
/* Query methods */
const createOne = (values) => {
if (sails.config.custom.activeUserLimit !== null) {
const createOne = async (values) => {
const { activeUsersLimit } = await InternalConfig.qm.getOneMain();
if (activeUsersLimit !== null) {
return sails.getDatastore().transaction(async (db) => {
const queryResult = await sails
.sendNativeQuery('SELECT NULL FROM user_account WHERE is_deactivated = $1 FOR UPDATE', [
@@ -33,7 +35,7 @@ const createOne = (values) => {
])
.usingConnection(db);
if (queryResult.rowCount >= sails.config.custom.activeUserLimit) {
if (queryResult.rowCount >= activeUsersLimit) {
throw 'activeLimitReached';
}
@@ -86,8 +88,8 @@ const getOneActiveByApiKeyHash = (apiKeyHash) =>
});
const updateOne = async (criteria, values) => {
const enforceActiveLimit =
values.isDeactivated === false && sails.config.custom.activeUserLimit !== null;
const { activeUsersLimit } = await InternalConfig.qm.getOneMain();
const enforceActiveLimit = values.isDeactivated === false && activeUsersLimit !== null;
if (!_.isUndefined(values.avatar) || enforceActiveLimit) {
return sails.getDatastore().transaction(async (db) => {
@@ -98,7 +100,7 @@ const updateOne = async (criteria, values) => {
])
.usingConnection(db);
if (queryResult.rowCount >= sails.config.custom.activeUserLimit) {
if (queryResult.rowCount >= activeUsersLimit) {
throw 'activeLimitReached';
}
}
-10
View File
@@ -18,7 +18,6 @@
* type: object
* required:
* - id
* - isInitialized
* properties:
* id:
* type: string
@@ -62,10 +61,6 @@
* nullable: true
* description: Default "from" used for outgoing SMTP emails
* example: no-reply@example.com
* isInitialized:
* type: boolean
* description: Whether the PLANKA instance has been initialized
* example: true
* createdAt:
* type: string
* format: date-time
@@ -142,11 +137,6 @@ module.exports = {
allowNull: true,
columnName: 'smtp_from',
},
isInitialized: {
type: 'boolean',
required: true,
columnName: 'is_initialized',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
+49
View File
@@ -0,0 +1,49 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* InternalConfig.js
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
const MAIN_ID = '1';
module.exports = {
MAIN_ID,
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
storageLimit: {
type: 'string',
allowNull: true,
columnName: 'storage_limit',
},
activeUsersLimit: {
type: 'number',
allowNull: true,
columnName: 'active_users_limit',
},
isInitialized: {
type: 'boolean',
required: true,
columnName: 'is_initialized',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
},
tableName: 'internal_config',
};
+12
View File
@@ -0,0 +1,12 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = async function isInternal(req, res, proceed) {
if (req.currentUser.id !== User.INTERNAL.id) {
return res.notFound(); // Forbidden
}
return proceed();
};
+1 -1
View File
@@ -61,7 +61,7 @@ module.exports.custom = {
internalAccessToken: process.env.INTERNAL_ACCESS_TOKEN,
storageLimit: envToBytes(process.env.STORAGE_LIMIT),
activeUserLimit: envToNumber(process.env.ACTIVE_USER_LIMIT),
activeUsersLimit: envToNumber(process.env.ACTIVE_USERS_LIMIT),
customerPanelUrl: process.env.CUSTOMER_PANEL_URL,
demoMode: process.env.DEMO_MODE === 'true',
+2
View File
@@ -42,6 +42,8 @@ module.exports.policies = {
'projects/create': ['is-authenticated', 'is-external', 'is-admin-or-project-owner'],
'_internal/update-config': ['is-authenticated', 'is-internal'],
'bootstrap/show': true,
'terms/show': true,
'access-tokens/create': true,
+2
View File
@@ -195,6 +195,8 @@ module.exports.routes = {
'POST /api/notification-services/:id/test': 'notification-services/test',
'DELETE /api/notification-services/:id': 'notification-services/delete',
'PATCH /api/_internal/config': '_internal/update-config',
'GET /preloaded-favicons/*': {
fn: staticDirServer('/preloaded-favicons', () =>
path.join(
@@ -0,0 +1,47 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.createTable('internal_config', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.text('storage_limit');
table.integer('active_users_limit');
table.boolean('is_initialized').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
});
const { is_initialized: isInitialized } = await knex('config').select('is_initialized').first();
await knex('internal_config').insert({
isInitialized,
id: 1,
createdAt: new Date().toISOString(),
});
return knex.schema.alterTable('config', (table) => {
table.dropColumn('is_initialized');
});
};
exports.down = async (knex) => {
const { is_initialized: isInitialized } = await knex('internal_config')
.select('is_initialized')
.first();
await knex.schema.alterTable('config', (table) => {
table.boolean('is_initialized').notNullable().default(isInitialized);
});
await knex.schema.alterTable('config', (table) => {
table.boolean('is_initialized').notNullable().alter();
});
return knex.schema.dropTable('internal_config');
};
+41 -13
View File
@@ -5,7 +5,7 @@
const bcrypt = require('bcrypt');
const buildData = () => {
const buildUserData = () => {
const data = {
role: 'admin',
isSsoUser: false,
@@ -25,18 +25,35 @@ const buildData = () => {
return data;
};
exports.seed = async (knex) => {
const email = process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase();
const buildInternalConfigData = () => {
const data = {};
if (process.env.STORAGE_LIMIT) {
data.storageLimit = process.env.STORAGE_LIMIT;
}
if (process.env.ACTIVE_USERS_LIMIT) {
const activeUsersLimit = parseInt(process.env.ACTIVE_USERS_LIMIT, 10);
if (email) {
const data = buildData();
if (Number.isInteger(activeUsersLimit)) {
data.activeUsersLimit = activeUsersLimit;
}
}
return data;
};
exports.seed = async (knex) => {
const defaultAdminEmail =
process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase();
if (defaultAdminEmail) {
const userData = buildUserData();
let userId;
try {
[{ id: userId }] = await knex('user_account').insert(
{
...data,
email,
...userData,
email: defaultAdminEmail,
subscribeToOwnCards: false,
subscribeToCardWhenCommenting: true,
turnOffRecentCardHighlighting: false,
@@ -53,19 +70,30 @@ exports.seed = async (knex) => {
}
if (!userId) {
await knex('user_account').update(data).where('email', email);
await knex('user_account').update(data).where('email', defaultAdminEmail);
}
}
const activeUserLimit = parseInt(process.env.ACTIVE_USER_LIMIT, 10);
const internalConfigData = buildInternalConfigData();
if (!Number.isNaN(activeUserLimit)) {
let activeUsersLimit;
if (Object.keys(internalConfigData).length > 0) {
[{ active_users_limit: activeUsersLimit }] = await knex('internal_config')
.update(internalConfigData)
.returning('active_users_limit');
} else {
({ active_users_limit: activeUsersLimit } = await knex('internal_config')
.select('active_users_limit')
.first());
}
if (Number.isInteger(activeUsersLimit)) {
let orderByQuery;
let orderByQueryValues;
if (email) {
if (defaultAdminEmail) {
orderByQuery = 'CASE WHEN email = ? THEN 0 WHEN role = ? THEN 1 ELSE 2 END';
orderByQueryValues = [email, 'admin'];
orderByQueryValues = [defaultAdminEmail, 'admin'];
} else {
orderByQuery = 'CASE WHEN role = ? THEN 0 ELSE 1 END';
orderByQueryValues = 'admin';
@@ -76,7 +104,7 @@ exports.seed = async (knex) => {
.where('is_deactivated', false)
.orderByRaw(orderByQuery, orderByQueryValues)
.orderBy('id')
.offset(activeUserLimit);
.offset(activeUsersLimit);
if (users.length > 0) {
const userIds = users.map(({ id }) => id);