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
@@ -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();
};