1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-24 15:49:46 +02:00

feat: Version 2

Closes #627, closes #1047
This commit is contained in:
Maksim Eltyshev 2025-05-10 02:09:06 +02:00
parent ad7fb51cfa
commit 2ee1166747
1557 changed files with 76832 additions and 47042 deletions

View file

@ -1,30 +1,14 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const bcrypt = require('bcrypt');
const valuesValidator = (value) => {
if (!_.isPlainObject(value)) {
return false;
}
if (!_.isString(value.email)) {
return false;
}
if (!_.isNil(value.password) && !_.isString(value.password)) {
return false;
}
if (!_.isNil(value.username) && !_.isString(value.username)) {
return false;
}
return true;
};
module.exports = {
inputs: {
values: {
type: 'json',
custom: valuesValidator,
required: true,
},
actorUser: {
@ -39,50 +23,74 @@ module.exports = {
exits: {
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
activeLimitReached: {},
},
async fn(inputs) {
const { values } = inputs;
if (values.password) {
values.password = bcrypt.hashSync(values.password, 10);
values.password = await bcrypt.hash(values.password, 10);
}
if (values.username) {
values.username = values.username.toLowerCase();
}
const user = await User.create({
...values,
email: values.email.toLowerCase(),
})
.intercept(
{
message:
'Unexpected error from database adapter: conflicting key value violates exclusion constraint "user_email_unique"',
},
'emailAlreadyInUse',
)
.intercept(
{
message:
'Unexpected error from database adapter: conflicting key value violates exclusion constraint "user_username_unique"',
},
'usernameAlreadyInUse',
)
.fetch();
let user;
try {
user = await User.qm.createOne({
...values,
email: values.email.toLowerCase(),
});
} catch (error) {
if (error.code === 'E_UNIQUE') {
throw 'emailAlreadyInUse';
}
// const userIds = await sails.helpers.users.getAdminIds();
if (
error.name === 'AdapterError' &&
error.raw.constraint === 'user_account_username_unique'
) {
throw 'usernameAlreadyInUse';
}
const users = await sails.helpers.users.getMany();
const userIds = sails.helpers.utils.mapRecords(users);
if (error.message === 'activeLimitReached') {
throw 'activeLimitReached';
}
userIds.forEach((userId) => {
throw error;
}
const scoper = sails.helpers.users.makeScoper(user);
const privateUserRelatedUserIds = await scoper.getPrivateUserRelatedUserIds();
privateUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userCreate',
{
item: user,
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
role: User.Roles.ADMIN,
}),
},
inputs.request,
);
});
const publicUserRelatedUserIds = await scoper.getPublicUserRelatedUserIds(true);
publicUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userCreate',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
}),
},
inputs.request,
);
@ -90,9 +98,9 @@ module.exports = {
sails.helpers.utils.sendWebhooks.with({
event: 'userCreate',
data: {
item: user,
},
buildData: () => ({
item: sails.helpers.users.presentOne(user),
}),
user: inputs.actorUser,
});

View file

@ -1,3 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
record: {
@ -14,51 +19,46 @@ module.exports = {
},
async fn(inputs) {
await IdentityProviderUser.destroy({
userId: inputs.record.id,
});
const { projectManagers, boardMemberships } = await sails.helpers.users.deleteRelated(
inputs.record,
);
await ProjectManager.destroy({
userId: inputs.record.id,
});
await BoardMembership.destroy({
userId: inputs.record.id,
});
await CardSubscription.destroy({
userId: inputs.record.id,
});
await CardMembership.destroy({
userId: inputs.record.id,
});
const user = await User.updateOne({
id: inputs.record.id,
deletedAt: null,
}).set({
deletedAt: new Date().toISOString(),
});
const user = await User.qm.deleteOne(inputs.record.id);
if (user) {
/* const projectIds = await sails.helpers.users.getManagerProjectIds(user.id);
sails.helpers.users.removeRelatedFiles(user);
const userIds = _.union(
[user.id],
await sails.helpers.users.getAdminIds(),
await sails.helpers.projects.getManagerAndBoardMemberUserIds(projectIds),
); */
const scoper = sails.helpers.users.makeScoper(user);
scoper.boardMemberships = boardMemberships;
const users = await sails.helpers.users.getMany();
const userIds = [inputs.record.id, ...sails.helpers.utils.mapRecords(users)];
const privateUserRelatedUserIds = await scoper.getPrivateUserRelatedUserIds();
userIds.forEach((userId) => {
privateUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userDelete',
{
item: user,
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
role: User.Roles.ADMIN,
}),
},
inputs.request,
);
});
const publicUserRelatedUserIds = await scoper.getPublicUserRelatedUserIds();
publicUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userDelete',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
}),
},
inputs.request,
);
@ -66,11 +66,29 @@ module.exports = {
sails.helpers.utils.sendWebhooks.with({
event: 'userDelete',
data: {
item: user,
},
buildData: () => ({
item: sails.helpers.users.presentOne(user),
}),
user: inputs.actorUser,
});
sails.sockets.leaveAll(`@user:${user.id}`);
const projectIds = await sails.helpers.utils.mapRecords(projectManagers, 'projectId', true);
const lonelyProjects = await sails.helpers.projects.getLonelyByIds(projectIds);
await Promise.all(
lonelyProjects.map((project) =>
// TODO: optimize with scoper
sails.helpers.projectManagers.createOne.with({
values: {
project,
user: inputs.actorUser,
},
actorUser: inputs.actorUser,
}),
),
);
}
return user;

View file

@ -0,0 +1,127 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
recordOrRecords: {
type: 'ref',
required: true,
},
},
async fn(inputs) {
let userIdOrIds;
if (_.isPlainObject(inputs.recordOrRecords)) {
({
recordOrRecords: { id: userIdOrIds },
} = inputs);
} else if (_.every(inputs.recordOrRecords, _.isPlainObject)) {
userIdOrIds = sails.helpers.utils.mapRecords(inputs.recordOrRecords);
}
await IdentityProviderUser.qm.delete({
userId: userIdOrIds,
});
await Session.qm.delete({
userId: userIdOrIds,
});
await ProjectFavorite.qm.delete({
userId: userIdOrIds,
});
const projectManagers = await ProjectManager.qm.delete({
userId: userIdOrIds,
});
const projectManagerIds = sails.helpers.utils.mapRecords(projectManagers);
const projects = await Project.qm.delete({
ownerProjectManagerId: projectManagerIds,
});
await sails.helpers.projects.deleteRelated(projects);
const boardMemberships = await BoardMembership.qm.delete({
userId: userIdOrIds,
});
await Card.qm.update(
{
creatorUserId: userIdOrIds,
},
{
creatorUserId: null,
},
);
await CardSubscription.qm.delete({
userId: userIdOrIds,
});
await CardMembership.qm.delete({
userId: userIdOrIds,
});
await Task.qm.update(
{
assigneeUserId: userIdOrIds,
},
{
assigneeUserId: null,
},
);
await Attachment.qm.update(
{
creatorUserId: userIdOrIds,
},
{
creatorUserId: null,
},
);
await Comment.qm.update(
{
userId: userIdOrIds,
},
{
userId: null,
},
);
await Action.qm.update(
{
userId: userIdOrIds,
},
{
userId: null,
},
);
await Notification.qm.update(
{
creatorUserId: userIdOrIds,
},
{
creatorUserId: null,
},
);
await Notification.qm.delete({
userId: userIdOrIds,
});
await NotificationService.qm.delete({
userId: userIdOrIds,
});
return {
projectManagers,
boardMemberships,
};
},
};

View file

@ -1,9 +0,0 @@
module.exports = {
async fn() {
const users = await sails.helpers.users.getMany({
isAdmin: true,
});
return sails.helpers.utils.mapRecords(users);
},
};

View file

@ -0,0 +1,20 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
roleOrRoles: {
type: 'json',
},
},
async fn(inputs) {
const users = await User.qm.getAll({
roleOrRoles: inputs.roleOrRoles,
});
return sails.helpers.utils.mapRecords(users);
},
};

View file

@ -1,17 +0,0 @@
const idOrIdsValidator = (value) => _.isString(value) || _.every(value, _.isString);
module.exports = {
inputs: {
idOrIds: {
type: 'json',
custom: idOrIdsValidator,
required: true,
},
},
async fn(inputs) {
return sails.helpers.boardMemberships.getMany({
userId: inputs.idOrIds,
});
},
};

View file

@ -1,17 +1,19 @@
const idOrIdsValidator = (value) => _.isString(value) || _.every(value, _.isString);
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
idOrIds: {
type: 'json',
custom: idOrIdsValidator,
id: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const projectManagers = await sails.helpers.users.getProjectManagers(inputs.idOrIds);
const projectManagers = await ProjectManager.qm.getByUserId(inputs.id);
return sails.helpers.utils.mapRecords(projectManagers, 'projectId', _.isArray(inputs.idOrIds));
return sails.helpers.utils.mapRecords(projectManagers, 'projectId');
},
};

View file

@ -1,30 +0,0 @@
const criteriaValidator = (value) => _.isArray(value) || _.isPlainObject(value);
module.exports = {
inputs: {
criteria: {
type: 'json',
custom: criteriaValidator,
},
withDeleted: {
type: 'boolean',
defaultsTo: false,
},
},
async fn(inputs) {
const criteria = {};
if (_.isArray(inputs.criteria)) {
criteria.id = inputs.criteria;
} else if (_.isPlainObject(inputs.criteria)) {
Object.assign(criteria, inputs.criteria);
}
if (!inputs.withDeleted) {
criteria.deletedAt = null;
}
return User.find(criteria).sort('id');
},
};

View file

@ -1,17 +0,0 @@
const idOrIdsValidator = (value) => _.isString(value) || _.every(value, _.isString);
module.exports = {
inputs: {
idOrIds: {
type: 'json',
custom: idOrIdsValidator,
required: true,
},
},
async fn(inputs) {
const boardMemberships = await sails.helpers.users.getBoardMemberships(inputs.idOrIds);
return sails.helpers.utils.mapRecords(boardMemberships, 'boardId', _.isArray(inputs.idOrIds));
},
};

View file

@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const notificationServices = await NotificationService.qm.getByUserId(inputs.id);
return notificationServices.length;
},
};

View file

@ -1,18 +0,0 @@
const idOrIdsValidator = (value) => _.isString(value) || _.every(value, _.isString);
module.exports = {
inputs: {
idOrIds: {
type: 'json',
custom: idOrIdsValidator,
required: true,
},
},
async fn(inputs) {
return sails.helpers.notifications.getMany({
isRead: false,
userId: inputs.idOrIds,
});
},
};

View file

@ -1,16 +0,0 @@
module.exports = {
inputs: {
emailOrUsername: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const fieldName = inputs.emailOrUsername.includes('@') ? 'email' : 'username';
return sails.helpers.users.getOne({
[fieldName]: inputs.emailOrUsername.toLowerCase(),
});
},
};

View file

@ -1,31 +0,0 @@
const criteriaValidator = (value) => _.isString(value) || _.isPlainObject(value);
module.exports = {
inputs: {
criteria: {
type: 'json',
custom: criteriaValidator,
required: true,
},
withDeleted: {
type: 'boolean',
defaultsTo: false,
},
},
async fn(inputs) {
const criteria = {};
if (_.isString(inputs.criteria)) {
criteria.id = inputs.criteria;
} else if (_.isPlainObject(inputs.criteria)) {
Object.assign(criteria, inputs.criteria);
}
if (!inputs.withDeleted) {
criteria.deletedAt = null;
}
return User.findOne(criteria);
},
};

View file

@ -1,162 +0,0 @@
module.exports = {
inputs: {
code: {
type: 'string',
required: true,
},
nonce: {
type: 'string',
required: true,
},
},
exits: {
invalidOIDCConfiguration: {},
invalidCodeOrNonce: {},
invalidUserinfoConfiguration: {},
missingValues: {},
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
},
async fn(inputs) {
let client;
try {
client = await sails.hooks.oidc.getClient();
} catch (error) {
sails.log.warn(`Error while initializing OIDC client: ${error}`);
throw 'invalidOIDCConfiguration';
}
let tokenSet;
try {
tokenSet = await client.callback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{
nonce: inputs.nonce,
},
);
} catch (error) {
sails.log.warn(`Error while exchanging OIDC code: ${error}`);
throw 'invalidCodeOrNonce';
}
let claims;
if (sails.config.custom.oidcClaimsSource === 'id_token') {
claims = tokenSet.claims();
} else {
try {
claims = await client.userinfo(tokenSet);
} catch (error) {
let errorText;
if (
error instanceof SyntaxError &&
error.message.includes('Unexpected token e in JSON at position 0')
) {
errorText = 'response is signed';
} else {
errorText = error.toString();
}
sails.log.warn(`Error while fetching OIDC userinfo: ${errorText}`);
throw 'invalidUserinfoConfiguration';
}
}
if (
!claims[sails.config.custom.oidcEmailAttribute] ||
!claims[sails.config.custom.oidcNameAttribute]
) {
throw 'missingValues';
}
let isAdmin = false;
if (sails.config.custom.oidcAdminRoles.includes('*')) {
isAdmin = true;
} else {
const roles = claims[sails.config.custom.oidcRolesAttribute];
if (Array.isArray(roles)) {
// Use a Set here to avoid quadratic time complexity
const userRoles = new Set(claims[sails.config.custom.oidcRolesAttribute]);
isAdmin = sails.config.custom.oidcAdminRoles.findIndex((role) => userRoles.has(role)) > -1;
}
}
const values = {
isAdmin,
email: claims[sails.config.custom.oidcEmailAttribute],
isSso: true,
name: claims[sails.config.custom.oidcNameAttribute],
subscribeToOwnCards: false,
};
if (!sails.config.custom.oidcIgnoreUsername) {
values.username = claims[sails.config.custom.oidcUsernameAttribute];
}
let user;
// This whole block technically needs to be executed in a transaction
// with SERIALIZABLE isolation level (but Waterline does not support
// that), so this will result in errors if for example users are deleted
// concurrently with logging in via OIDC.
let identityProviderUser = await IdentityProviderUser.findOne({
issuer: sails.config.custom.oidcIssuer,
sub: claims.sub,
});
if (identityProviderUser) {
user = await sails.helpers.users.getOne(identityProviderUser.userId);
} else {
// If no IDP/User mapping exists, search for the user by email.
user = await sails.helpers.users.getOne({
email: values.email.toLowerCase(),
});
// Otherwise, create a new user.
if (!user) {
user = await sails.helpers.users.createOne
.with({
values,
actorUser: User.OIDC,
})
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
}
identityProviderUser = await IdentityProviderUser.create({
userId: user.id,
issuer: sails.config.custom.oidcIssuer,
sub: claims.sub,
});
}
const updateFieldKeys = ['email', 'isSso', 'name'];
if (!sails.config.custom.oidcIgnoreUsername) {
updateFieldKeys.push('username');
}
if (!sails.config.custom.oidcIgnoreRoles) {
updateFieldKeys.push('isAdmin');
}
const updateValues = {};
// eslint-disable-next-line no-restricted-syntax
for (const k of updateFieldKeys) {
if (values[k] !== user[k]) updateValues[k] = values[k];
}
if (Object.keys(updateValues).length > 0) {
user = await sails.helpers.users.updateOne
.with({
record: user,
values: updateValues,
actorUser: User.OIDC,
})
.intercept('emailAlreadyInUse', 'emailAlreadyInUse')
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
}
return user;
},
};

View file

@ -0,0 +1,187 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
code: {
type: 'string',
required: true,
},
nonce: {
type: 'string',
required: true,
},
},
exits: {
invalidOidcConfiguration: {},
invalidCodeOrNonce: {},
invalidUserinfoConfiguration: {},
missingValues: {},
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
activeLimitReached: {},
},
async fn(inputs) {
const client = await sails.hooks.oidc.getClient();
if (!client) {
throw 'invalidOidcConfiguration';
}
let tokenSet;
try {
tokenSet = await client.callback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{
nonce: inputs.nonce,
},
);
} catch (error) {
sails.log.warn(`Error while exchanging OIDC code: ${error}`);
throw 'invalidCodeOrNonce';
}
let claims;
if (sails.config.custom.oidcClaimsSource === 'id_token') {
claims = tokenSet.claims();
} else {
try {
claims = await client.userinfo(tokenSet);
} catch (error) {
let errorText;
if (
error instanceof SyntaxError &&
error.message.includes('Unexpected token e in JSON at position 0')
) {
errorText = 'response is signed';
} else {
errorText = error.toString();
}
sails.log.warn(`Error while fetching OIDC userinfo: ${errorText}`);
throw 'invalidUserinfoConfiguration';
}
}
const email = claims[sails.config.custom.oidcEmailAttribute];
const name = claims[sails.config.custom.oidcNameAttribute];
if (!email || !name) {
throw 'missingValues';
}
let role = User.Roles.BOARD_USER;
if (!sails.config.custom.oidcIgnoreRoles) {
const claimsRoles = claims[sails.config.custom.oidcRolesAttribute];
if (Array.isArray(claimsRoles)) {
// Use a Set here to avoid quadratic time complexity
const claimsRolesSet = new Set(claimsRoles);
const foundRole = [User.Roles.ADMIN, User.Roles.PROJECT_OWNER, User.Roles.BOARD_USER].find(
(roleItem) => {
const configRoles = sails.config.custom[`oidc${_.upperFirst(roleItem)}Roles`];
if (configRoles.includes('*')) {
return true;
}
return configRoles.some((configRole) => claimsRolesSet.has(configRole));
},
);
if (foundRole) {
role = foundRole;
}
}
}
const values = {
email,
role,
name,
isSsoUser: true,
};
if (!sails.config.custom.oidcIgnoreUsername) {
values.username = claims[sails.config.custom.oidcUsernameAttribute];
}
// This whole block technically needs to be executed in a transaction
// with SERIALIZABLE isolation level (but Waterline does not support
// that), so this will result in errors if for example users are deleted
// concurrently with logging in via OIDC.
let identityProviderUser = await IdentityProviderUser.qm.getOneByIssuerAndSub(
sails.config.custom.oidcIssuer,
claims.sub,
);
let user;
let isCreated = false;
if (identityProviderUser) {
user = await User.qm.getOneById(identityProviderUser.userId);
} else {
// If no IDP/User mapping exists, search for the user by email.
user = await User.qm.getOneByEmail(values.email);
// Otherwise, create a new user.
if (!user) {
user = await sails.helpers.users.createOne
.with({
values,
actorUser: User.OIDC,
})
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse')
.intercept('activeLimitReached', 'activeLimitReached');
isCreated = true;
}
identityProviderUser = await IdentityProviderUser.qm.createOne({
userId: user.id,
issuer: sails.config.custom.oidcIssuer,
sub: claims.sub,
});
}
if (!isCreated) {
values.isDeactivated = false;
const updateFieldKeys = ['email', 'name', 'isSsoUser', 'isDeactivated'];
if (!sails.config.custom.oidcIgnoreUsername) {
updateFieldKeys.push('username');
}
if (!sails.config.custom.oidcIgnoreRoles) {
updateFieldKeys.push('role');
}
const updateValues = {};
// eslint-disable-next-line no-restricted-syntax
for (const k of updateFieldKeys) {
if (values[k] !== user[k]) updateValues[k] = values[k];
}
if (Object.keys(updateValues).length > 0) {
user = await sails.helpers.users.updateOne
.with({
record: user,
values: updateValues,
actorUser: User.OIDC,
})
.intercept('emailAlreadyInUse', 'emailAlreadyInUse')
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse')
.intercept('activeLimitReached', 'activeLimitReached');
}
}
return user;
},
};

View file

@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const projectManagers = await ProjectManager.qm.getByUserId(inputs.id);
return projectManagers.length;
},
};

View file

@ -1,17 +0,0 @@
const idOrIdsValidator = (value) => _.isString(value) || _.every(value, _.isString);
module.exports = {
inputs: {
idOrIds: {
type: 'json',
custom: idOrIdsValidator,
required: true,
},
},
async fn(inputs) {
return sails.helpers.projectManagers.getMany({
userId: inputs.idOrIds,
});
},
};

View file

@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
record: {
type: 'ref',
required: true,
},
},
fn(inputs) {
return [User.Roles.ADMIN, User.Roles.PROJECT_OWNER].includes(inputs.record.role);
},
};

View file

@ -1,3 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
@ -11,10 +16,10 @@ module.exports = {
},
async fn(inputs) {
const boardMembership = await BoardMembership.findOne({
boardId: inputs.boardId,
userId: inputs.id,
});
const boardMembership = await BoardMembership.qm.getOneByBoardIdAndUserId(
inputs.boardId,
inputs.id,
);
return !!boardMembership;
},

View file

@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
boardId: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const boardSubscription = await BoardSubscription.qm.getOneByBoardIdAndUserId(
inputs.boardId,
inputs.id,
);
return !!boardSubscription;
},
};

View file

@ -1,3 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
@ -11,10 +16,10 @@ module.exports = {
},
async fn(inputs) {
const cardSubscription = await CardSubscription.findOne({
cardId: inputs.cardId,
userId: inputs.id,
});
const cardSubscription = await CardSubscription.qm.getOneByCardIdAndUserId(
inputs.cardId,
inputs.id,
);
return !!cardSubscription;
},

View file

@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
projectId: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const projectFavorite = await ProjectFavorite.qm.getOneByProjectIdAndUserId(
inputs.projectId,
inputs.id,
);
return !!projectFavorite;
},
};

View file

@ -1,3 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
@ -11,10 +16,10 @@ module.exports = {
},
async fn(inputs) {
const projectManager = await ProjectManager.findOne({
projectId: inputs.projectId,
userId: inputs.id,
});
const projectManager = await ProjectManager.qm.getOneByProjectIdAndUserId(
inputs.projectId,
inputs.id,
);
return !!projectManager;
},

View file

@ -0,0 +1,149 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
class Scoper {
constructor(user) {
this.user = user;
this.projectManagers = null;
this.separatedUserIds = null;
this.userRelatedProjectManagerAndBoardMemberUserIds = null;
this.privateUserRelatedUserIds = null;
this.publicUserRelatedUserIds = null;
}
async getProjectManagers() {
if (!this.projectManagers) {
this.projectManagers = await ProjectManager.qm.getByUserId(this.user.id);
}
return this.projectManagers;
}
async getSeparatedUserIds() {
if (!this.separatedUserIds) {
const users = await User.qm.getAll({
roleOrRoles: [User.Roles.ADMIN, User.Roles.PROJECT_OWNER],
});
const adminUserIds = [];
const projectOwnerUserIds = [];
users.forEach((user) => {
if (user.role === User.Roles.ADMIN) {
adminUserIds.push(user.id);
} else {
projectOwnerUserIds.push(user.id);
}
});
this.separatedUserIds = {
adminUserIds,
projectOwnerUserIds,
};
}
return this.separatedUserIds;
}
async getUserRelatedProjectManagerAndBoardMemberUserIds() {
if (!this.userRelatedProjectManagerAndBoardMemberUserIds) {
const projectManagers = await this.getProjectManagers();
const projectIds = sails.helpers.utils.mapRecords(projectManagers, 'projectId');
const relatedProjectManagers = await ProjectManager.qm.getByProjectIds(projectIds, {
exceptUserIdOrIds: this.user.id,
});
const relatedProjectManagerUserIds = sails.helpers.utils.mapRecords(
relatedProjectManagers,
'userId',
);
const relatedProjectBoardMemberships = await BoardMembership.qm.getByProjectIds(projectIds);
const relatedProjectBoardMemberUserIds = sails.helpers.utils.mapRecords(
relatedProjectBoardMemberships,
'userId',
);
const boardMemberships = await BoardMembership.qm.getByUserId(this.user.id, {
exceptProjectIdOrIds: projectIds,
});
const boardIds = sails.helpers.utils.mapRecords(boardMemberships, 'boardId');
const relatedBoardMemberships = await BoardMembership.qm.getByBoardIds(boardIds, {
exceptUserIdOrIds: this.user.id,
});
const relatedBoardMemberUserIds = sails.helpers.utils.mapRecords(
relatedBoardMemberships,
'userId',
);
this.userRelatedProjectManagerAndBoardMemberUserIds = _.union(
relatedProjectManagerUserIds,
relatedProjectBoardMemberUserIds,
relatedBoardMemberUserIds,
);
}
return this.userRelatedProjectManagerAndBoardMemberUserIds;
}
async getPrivateUserRelatedUserIds() {
if (!this.privateUserRelatedUserIds) {
const { adminUserIds } = await this.getSeparatedUserIds();
this.privateUserRelatedUserIds = _.union([this.user.id], adminUserIds);
}
return this.privateUserRelatedUserIds;
}
async getPublicUserRelatedUserIds(skipRelatedProjectManagerAndBoardMemberUserIds = false) {
if (!this.publicUserRelatedUserIds) {
const privateUserRelatedUserIds = await this.getPrivateUserRelatedUserIds();
const privateUserRelatedUserIdsSet = new Set(privateUserRelatedUserIds);
const { projectOwnerUserIds } = await this.getSeparatedUserIds();
let userRelatedProjectManagerAndBoardMemberUserIds;
if (!skipRelatedProjectManagerAndBoardMemberUserIds) {
userRelatedProjectManagerAndBoardMemberUserIds =
await this.getUserRelatedProjectManagerAndBoardMemberUserIds();
}
const externalPublicUserRelatedUserIds = _.union(
projectOwnerUserIds,
userRelatedProjectManagerAndBoardMemberUserIds,
);
this.publicUserRelatedUserIds = externalPublicUserRelatedUserIds.filter(
(userId) => !privateUserRelatedUserIdsSet.has(userId),
);
}
return this.publicUserRelatedUserIds;
}
}
module.exports = {
sync: true,
inputs: {
record: {
type: 'ref',
required: true,
},
},
fn(inputs) {
return new Scoper(inputs.record);
},
};

View file

@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
records: {
type: 'ref',
required: true,
},
user: {
type: 'ref',
require: true,
},
},
fn(inputs) {
return inputs.records.map((record) => sails.helpers.users.presentOne(record, inputs.user));
},
};

View file

@ -0,0 +1,72 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
record: {
type: 'ref',
required: true,
},
user: {
type: 'ref',
},
},
fn(inputs) {
const fileManager = sails.hooks['file-manager'].getInstance();
const data = {
..._.omit(inputs.record, ['password', 'avatar', 'passwordChangedAt']),
avatar: inputs.record.avatar && {
url: `${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}/original.${inputs.record.avatar.extension}`)}`,
thumbnailUrls: {
cover180: `${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}/cover-180.${inputs.record.avatar.extension}`)}`,
},
},
};
if (inputs.user) {
const isForCurrentUser = inputs.record.id === inputs.user.id;
const isForAdmin = inputs.user.role === User.Roles.ADMIN;
if (isForCurrentUser || isForAdmin) {
const isDefaultAdmin = inputs.record.email === sails.config.custom.defaultAdminEmail;
const lockedFieldNames = [];
if (isDefaultAdmin || inputs.record.isSsoUser) {
lockedFieldNames.push('email', 'password', 'name');
if (isDefaultAdmin) {
lockedFieldNames.push('role', 'username');
} else if (inputs.record.isSsoUser) {
if (!sails.config.custom.oidcIgnoreRoles) {
lockedFieldNames.push('role');
}
if (!sails.config.custom.oidcIgnoreUsername) {
lockedFieldNames.push('username');
}
}
}
Object.assign(data, {
isDefaultAdmin,
lockedFieldNames,
});
if (isForCurrentUser) {
return data;
}
return _.omit(data, User.PERSONAL_FIELD_NAMES);
}
return _.omit(data, [...User.PRIVATE_FIELD_NAMES, ...User.PERSONAL_FIELD_NAMES]);
}
return data;
},
};

View file

@ -1,4 +1,10 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const { rimraf } = require('rimraf');
const mime = require('mime');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
@ -15,6 +21,12 @@ module.exports = {
},
async fn(inputs) {
const mimeType = mime.getType(inputs.file.filename);
if (['image/svg+xml', 'application/pdf'].includes(mimeType)) {
await rimraf(inputs.file.fd);
throw 'fileIsNotImage';
}
let image = sharp(inputs.file.fd, {
animated: true,
});
@ -23,10 +35,7 @@ module.exports = {
try {
metadata = await image.metadata();
} catch (error) {
throw 'fileIsNotImage';
}
if (['svg', 'pdf'].includes(metadata.format)) {
await rimraf(inputs.file.fd);
throw 'fileIsNotImage';
}
@ -35,15 +44,16 @@ module.exports = {
const dirname = uuid();
const dirPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${dirname}`;
let { width, pageHeight: height = metadata.height } = metadata;
if (metadata.orientation && metadata.orientation > 4) {
[image, width, height] = [image.rotate(), height, width];
image = image.rotate();
}
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
let sizeInBytes;
try {
const originalBuffer = await image.toBuffer();
sizeInBytes = originalBuffer.length;
await fileManager.save(
`${dirPathSegment}/original.${extension}`,
@ -51,44 +61,36 @@ module.exports = {
inputs.file.type,
);
const square100Buffer = await image
.resize(
100,
100,
width < 100 || height < 100
? {
kernel: sharp.kernel.nearest,
}
: undefined,
)
const cover180Buffer = await image
.resize(180, 180, {
withoutEnlargement: true,
})
.png({
quality: 75,
force: false,
})
.toBuffer();
await fileManager.save(
`${dirPathSegment}/square-100.${extension}`,
square100Buffer,
`${dirPathSegment}/cover-180.${extension}`,
cover180Buffer,
inputs.file.type,
);
} catch (error1) {
console.warn(error1.stack); // eslint-disable-line no-console
} catch (error) {
sails.log.warn(error.stack);
try {
fileManager.deleteDir(dirPathSegment);
} catch (error2) {
console.warn(error2.stack); // eslint-disable-line no-console
}
await fileManager.deleteDir(dirPathSegment);
await rimraf(inputs.file.fd);
throw 'fileIsNotImage';
}
try {
await rimraf(inputs.file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
await rimraf(inputs.file.fd);
return {
dirname,
extension,
sizeInBytes,
};
},
};

View file

@ -0,0 +1,31 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
recordOrRecords: {
type: 'ref',
required: true,
},
},
fn(inputs) {
const users = _.isPlainObject(inputs.recordOrRecords)
? [inputs.recordOrRecords]
: inputs.recordOrRecords;
const fileManager = sails.hooks['file-manager'].getInstance();
users.forEach(async (user) => {
if (user.avatar) {
await fileManager.deleteDir(
`${sails.config.custom.userAvatarsPathSegment}/${user.avatar.dirname}`,
);
}
});
},
};

View file

@ -1,30 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const bcrypt = require('bcrypt');
const { v4: uuid } = require('uuid');
const valuesValidator = (value) => {
if (!_.isPlainObject(value)) {
return false;
}
if (!_.isUndefined(value.email) && !_.isString(value.email)) {
return false;
}
if (!_.isUndefined(value.password) && !_.isString(value.password)) {
return false;
}
if (!_.isNil(value.username) && !_.isString(value.username)) {
return false;
}
if (!_.isNil(value.avatar) && !_.isPlainObject(value.avatar)) {
return false;
}
return true;
};
module.exports = {
inputs: {
record: {
@ -33,7 +14,6 @@ module.exports = {
},
values: {
type: 'json',
custom: valuesValidator,
required: true,
},
actorUser: {
@ -48,6 +28,7 @@ module.exports = {
exits: {
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
activeLimitReached: {},
},
async fn(inputs) {
@ -57,70 +38,81 @@ module.exports = {
values.email = values.email.toLowerCase();
}
let isOnlyEmailChange = false;
let isOnlyPasswordChange = false;
let isOnlyPersonalFieldsChange = false;
let isDeactivatedChangeToTrue = false;
if (!_.isUndefined(values.email) && Object.keys(values).length === 1) {
isOnlyEmailChange = true;
}
if (_.difference(Object.keys(values), User.PERSONAL_FIELD_NAMES).length === 0) {
isOnlyPersonalFieldsChange = true;
}
if (!_.isUndefined(values.password)) {
if (Object.keys(values).length === 1) {
isOnlyPasswordChange = true;
}
Object.assign(values, {
password: bcrypt.hashSync(values.password, 10),
passwordChangedAt: new Date().toUTCString(), // FIXME: hack
});
values.password = await bcrypt.hash(values.password, 10);
values.passwordChangedAt = new Date().toUTCString(); // FIXME: hack
}
if (values.username) {
values.username = values.username.toLowerCase();
}
const user = await User.updateOne({
id: inputs.record.id,
deletedAt: null,
})
.set({ ...values })
.intercept(
{
message:
'Unexpected error from database adapter: conflicting key value violates exclusion constraint "user_email_unique"',
},
'emailAlreadyInUse',
)
.intercept(
{
message:
'Unexpected error from database adapter: conflicting key value violates exclusion constraint "user_username_unique"',
},
'usernameAlreadyInUse',
);
if (values.isDeactivated && values.isDeactivated !== inputs.record.isDeactivated) {
isDeactivatedChangeToTrue = true;
}
let user;
try {
user = await User.qm.updateOne(inputs.record.id, values);
} catch (error) {
if (error.code === 'E_UNIQUE') {
throw 'emailAlreadyInUse';
}
if (
error.name === 'AdapterError' &&
error.raw.constraint === 'user_account_username_unique'
) {
throw 'usernameAlreadyInUse';
}
if (error.message === 'activeLimitReached') {
throw 'activeLimitReached';
}
throw error;
}
if (user) {
if (
inputs.record.avatar &&
(!user.avatar || user.avatar.dirname !== inputs.record.avatar.dirname)
) {
const fileManager = sails.hooks['file-manager'].getInstance();
try {
await fileManager.deleteDir(
`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}`,
);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
if (inputs.record.avatar) {
if (!user.avatar || user.avatar.dirname !== inputs.record.avatar.dirname) {
sails.helpers.users.removeRelatedFiles(inputs.record);
}
}
if (!_.isUndefined(values.password)) {
if (!_.isUndefined(values.password) || isDeactivatedChangeToTrue) {
sails.sockets.broadcast(
`user:${user.id}`,
'userDelete', // TODO: introduce separate event
{
item: user,
item: sails.helpers.users.presentOne(user, user),
},
inputs.request,
);
if (user.id === inputs.actorUser.id && inputs.request && inputs.request.isSocket) {
if (
!isDeactivatedChangeToTrue &&
user.id === inputs.actorUser.id &&
inputs.request &&
inputs.request.isSocket
) {
const tempRoom = uuid();
sails.sockets.addRoomMembersToRooms(`@user:${user.id}`, tempRoom, () => {
@ -134,36 +126,88 @@ module.exports = {
}
if (!isOnlyPasswordChange) {
/* const projectIds = await sails.helpers.users.getManagerProjectIds(user.id);
const userIds = _.union(
[user.id],
await sails.helpers.users.getAdminIds(),
await sails.helpers.projects.getManagerAndBoardMemberUserIds(projectIds),
); */
const users = await sails.helpers.users.getMany();
const userIds = sails.helpers.utils.mapRecords(users);
userIds.forEach((userId) => {
if (isOnlyPersonalFieldsChange) {
sails.sockets.broadcast(
`user:${userId}`,
`user:${user.id}`,
'userUpdate',
{
item: user,
item: sails.helpers.users.presentOne(user, user),
},
inputs.request,
);
});
} else {
const scoper = sails.helpers.users.makeScoper(user);
const privateUserRelatedUserIds = await scoper.getPrivateUserRelatedUserIds();
privateUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userUpdate',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
role: User.Roles.ADMIN,
}),
},
inputs.request,
);
});
if (!isOnlyEmailChange) {
if (inputs.record.role === User.Roles.ADMIN && user.role !== User.Roles.ADMIN) {
const managerProjectIds = await sails.helpers.users.getManagerProjectIds(user.id);
const sharedProjects = await Project.qm.getShared({
exceptIdOrIds: managerProjectIds,
});
const projectIds = sails.helpers.utils.mapRecords(sharedProjects);
const boards = await Board.qm.getByProjectIds(projectIds);
const boardIds = sails.helpers.utils.mapRecords(boards);
const boardMemberships = await BoardMembership.qm.getByBoardIdsAndUserId(
boardIds,
user.id,
);
const missingBoardIds = _.difference(
boardIds,
sails.helpers.utils.mapRecords(boardMemberships, 'boardId'),
);
missingBoardIds.forEach((boardId) => {
sails.sockets.removeRoomMembersFromRooms(`@user:${user.id}`, `board:${boardId}`);
});
}
const publicUserRelatedUserIds = await scoper.getPublicUserRelatedUserIds();
publicUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userUpdate',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
}),
},
inputs.request,
);
});
}
}
sails.helpers.utils.sendWebhooks.with({
event: 'userUpdate',
data: {
item: user,
},
prevData: {
item: inputs.record,
},
buildData: () => ({
item: sails.helpers.users.presentOne(user),
}),
buildPrevData: () => ({
item: sails.helpers.users.presentOne(inputs.record),
}),
user: inputs.actorUser,
});
}