[FIX] mail, im_livechat, *: restore usage of livechat user name

* = website_livechat

Prevents from leaking unnecessary information to livechat clients.

opw-2453956

closes odoo/odoo#82026

X-original-commit: 5ec191d288d5b44f78cd1f6980c47bb64cfd9bd1
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Sébastien Theys
2021-12-29 16:55:47 +00:00
parent 0c2f518c58
commit 07048871af
18 changed files with 247 additions and 89 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
<td style="padding: 0 50px;">
<div style="font-size: 13px; padding: 10px 0;">
<span>Hello,</span><br />Here's a copy of your conversation with
<span t-field="channel.livechat_operator_id.name"/>, on the
<span t-esc="channel.livechat_operator_id.user_livechat_username or channel.livechat_operator_id.name"/>, on the
<span t-field="channel.livechat_channel_id.create_date"/>
</div>
<table cellspacing="0" cellpadding="0" style="width:100%; border-collapse: collapse;">
+1
View File
@@ -5,6 +5,7 @@ from . import res_partner
from . import im_livechat_channel
from . import mail_channel
from . import mail_channel_partner
from . import mail_message
from . import res_users_settings
from . import rating
from . import digest
+25 -12
View File
@@ -35,10 +35,7 @@ class MailChannel(models.Model):
clicking on livechat button). So when the anonymous person is sending its FIRST message, the channel header
should be added to the notification, since the user cannot be listining to the channel.
"""
livechat_channels = self.filtered(lambda x: x.channel_type == 'livechat')
other_channels = self.filtered(lambda x: x.channel_type != 'livechat')
notifications = super(MailChannel, livechat_channels)._channel_message_notifications(message.with_context(im_livechat_use_username=True)) + \
super(MailChannel, other_channels)._channel_message_notifications(message, message_format)
notifications = super()._channel_message_notifications(message=message, message_format=message_format)
for channel in self:
# add uuid for private livechat channels to allow anonymous to listen
if channel.channel_type == 'livechat' and channel.public == 'private':
@@ -50,11 +47,6 @@ class MailChannel(models.Model):
notifications = self._channel_channel_notifications(unpinned_channel_partner.mapped('partner_id').ids) + notifications
return notifications
def _channel_fetch_message(self, last_id=False, limit=20):
""" Override to add the context of the livechat username."""
channel = self.with_context(im_livechat_use_username=True) if self.channel_type == 'livechat' else self
return super(MailChannel, channel)._channel_fetch_message(last_id=last_id, limit=limit)
def channel_info(self):
""" Extends the channel header by adding the livechat operator and the 'anonymous' profile
:rtype : list(dict)
@@ -66,12 +58,33 @@ class MailChannel(models.Model):
if channel.channel_type == 'livechat':
# add the operator id
if channel.livechat_operator_id:
res = channel.livechat_operator_id.with_context(im_livechat_use_username=True).name_get()[0]
channel_infos_dict[channel.id]['operator_pid'] = (res[0], res[1].replace(',', ''))
display_name = channel.livechat_operator_id.user_livechat_username or channel.livechat_operator_id.display_name
channel_infos_dict[channel.id]['operator_pid'] = (channel.livechat_operator_id.id, display_name.replace(',', ''))
# add the anonymous or partner name
channel_infos_dict[channel.id]['livechat_visitor'] = channel._channel_get_livechat_visitor_info()
return list(channel_infos_dict.values())
def _channel_info_format_member(self, partner, partner_info):
"""Override to remove sensitive information in livechat."""
if self.channel_type == 'livechat':
return {
'active': partner.active,
'id': partner.id,
'name': partner.user_livechat_username or partner.name, # for API compatibility in stable
'email': False, # for API compatibility in stable
'im_status': False, # for API compatibility in stable
'livechat_username': partner.user_livechat_username,
}
return super()._channel_info_format_member(partner=partner, partner_info=partner_info)
def _notify_typing_partner_data(self):
"""Override to remove name and return livechat username if applicable."""
data = super()._notify_typing_partner_data()
if self.channel_type == 'livechat' and self.env.user.partner_id.user_livechat_username:
data['partner_name'] = self.env.user.partner_id.user_livechat_username # for API compatibility in stable
data['livechat_username'] = self.env.user.partner_id.user_livechat_username
return data
def _channel_get_livechat_visitor_info(self):
self.ensure_one()
# remove active test to ensure public partner is taken into account
@@ -171,7 +184,7 @@ class MailChannel(models.Model):
mail_body = template._render(render_context, engine='ir.qweb', minimal_qcontext=True)
mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body)
mail = self.env['mail.mail'].sudo().create({
'subject': _('Conversation with %s', self.livechat_operator_id.name),
'subject': _('Conversation with %s', self.livechat_operator_id.user_livechat_username or self.livechat_operator_id.name),
'email_from': company.catchall_formatted or company.email_formatted,
'author_id': self.env.user.partner_id.id,
'email_to': email,
+21
View File
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailMessage(models.Model):
_inherit = 'mail.message'
def _message_format(self, fnames, format_reply=True):
"""Override to remove email_from and to return the livechat username if applicable.
A third param is added to the author_id tuple in this case to be able to differentiate it
from the normal name in client code."""
vals_list = super()._message_format(fnames=fnames, format_reply=format_reply)
for vals in vals_list:
message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids)
if message_sudo.model == 'mail.channel' and self.env['mail.channel'].browse(message_sudo.res_id).channel_type == 'livechat':
vals.pop('email_from')
if message_sudo.author_id.user_livechat_username:
vals['author_id'] = (message_sudo.author_id.id, message_sudo.author_id.user_livechat_username, message_sudo.author_id.user_livechat_username)
return vals_list
+8 -27
View File
@@ -1,38 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo import api, models, fields
class Partners(models.Model):
""" Update of res.partners class
- override name_get to take into account the livechat username
"""
"""Update of res.partner class to take into account the livechat username."""
_inherit = 'res.partner'
def name_get(self):
if self.env.context.get('im_livechat_use_username'):
# process the ones with livechat username
users_with_livechatname = self.env['res.users'].search([('partner_id', 'in', self.ids), ('livechat_username', '!=', False)])
map_with_livechatname = {}
for user in users_with_livechatname:
map_with_livechatname[user.partner_id.id] = user.livechat_username
# process the ones without livecaht username
partner_without_livechatname = self - users_with_livechatname.mapped('partner_id')
no_livechatname_name_get = super(Partners, partner_without_livechatname).name_get()
map_without_livechatname = dict(no_livechatname_name_get)
# restore order
result = []
for partner in self:
name = map_with_livechatname.get(partner.id)
if not name:
name = map_without_livechatname.get(partner.id)
result.append((partner.id, name))
else:
result = super(Partners, self).name_get()
return result
user_livechat_username = fields.Char(compute='_compute_user_livechat_username')
def _get_channels_as_member(self):
channels = super()._get_channels_as_member()
@@ -44,3 +20,8 @@ class Partners(models.Model):
])),
])
return channels
@api.depends('user_ids.livechat_username')
def _compute_user_livechat_username(self):
for partner in self:
partner.user_livechat_username = next(iter(partner.user_ids.mapped('livechat_username')), False)
@@ -0,0 +1,26 @@
/** @odoo-module **/
import { patchModelMethods } from '@mail/model/model_core';
import { insertAndReplace } from '@mail/model/model_field_command';
// ensure the model definition is loaded before the patch
import '@mail/models/message/message';
patchModelMethods('Message', {
/**
* @override
*/
convertData(data) {
const data2 = this._super(data);
if ('author_id' in data) {
if (data.author_id[2]) {
// flux specific for livechat, a 3rd param is livechat_username
// and means 2nd param (display_name) should be ignored
data2.author = insertAndReplace({
id: data.author_id[0],
livechat_username: data.author_id[2],
});
}
}
return data2;
},
});
@@ -21,7 +21,7 @@ patchRecordMethods('MessagingNotificationHandler', {
/**
* @override
*/
_handleNotificationChannelPartnerTypingStatus({ channel_id, is_typing, partner_id, partner_name }) {
_handleNotificationChannelPartnerTypingStatus({ channel_id, is_typing, livechat_username, partner_id, partner_name }) {
const channel = this.messaging.models['Thread'].findFromIdentifyingData({
id: channel_id,
model: 'mail.channel',
@@ -40,11 +40,20 @@ patchRecordMethods('MessagingNotificationHandler', {
partnerId = partner_id;
partnerName = partner_name;
}
this._super({
const data = {
channel_id,
is_typing,
partner_id: partnerId,
partner_name: partnerName,
});
};
if (livechat_username) {
// flux specific, livechat_username is returned instead of name for livechat channels
delete data.partner_name; // value still present for API compatibility in stable
this.models['Partner'].insert({
id: partnerId,
livechat_username: livechat_username,
});
}
this._super(data);
},
});
@@ -1,6 +1,8 @@
/** @odoo-module **/
import { addModelMethods } from '@mail/model/model_core';
import { addFields, addModelMethods, patchModelMethods } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
// ensure that the model definition is loaded before the patch
import '@mail/models/partner/partner';
@@ -14,3 +16,27 @@ addModelMethods('Partner', {
},
});
patchModelMethods('Partner', {
convertData(data) {
const data2 = this._super(data);
if ('livechat_username' in data) {
// flux specific, if livechat username is present it means `name`,
// `email` and `im_status` contain `false` even though their value
// might actually exist. Remove them from data2 to avoid overwriting
// existing value (that could be known through other means).
delete data2.name;
delete data2.email;
delete data2.im_status;
data2.livechat_username = data.livechat_username;
}
return data2;
},
});
addFields('Partner', {
/**
* States the specific name of this partner in the context of livechat.
* Either a string or undefined.
*/
livechat_username: attr(),
});
@@ -77,6 +77,15 @@ patchModelMethods('Thread', {
});
patchRecordMethods('Thread', {
/**
* @override
*/
getMemberName(partner) {
if (this.channel_type === 'livechat' && partner.livechat_username) {
return partner.livechat_username;
}
return this._super(partner);
},
/**
* @override
*/
@@ -9,7 +9,8 @@ class TestGetMailChannel(TransactionCase):
super(TestGetMailChannel, self).setUp()
self.operators = self.env['res.users'].create([{
'name': 'Michel',
'login': 'michel'
'login': 'michel',
'livechat_username': "Michel Operator",
}, {
'name': 'Paul',
'login': 'paul'
@@ -58,12 +59,30 @@ class TestGetMailChannel(TransactionCase):
test_user = self.env['res.users'].create({'name': 'Roger', 'login': 'roger', 'country_id': belgium.id})
# ensure visitor info are correct with anonymous
channel_info = self.livechat_channel.with_user(public_user)._open_livechat_mail_channel(anonymous_name='Visitor 22', country_id=belgium.id)
operator = self.operators[0]
channel_info = self.livechat_channel.with_user(public_user)._open_livechat_mail_channel(anonymous_name='Visitor 22', previous_operator_id=operator.partner_id.id, country_id=belgium.id)
visitor_info = channel_info['livechat_visitor']
self.assertFalse(visitor_info['id'])
self.assertEqual(visitor_info['name'], "Visitor 22")
self.assertEqual(visitor_info['country'], (20, "Belgium"))
# ensure member info are hidden (in particular email and real name when livechat username is present)
self.assertEqual(sorted(channel_info['members'], key=lambda m: m['id']), sorted([{
'active': True,
'email': False,
'id': operator.partner_id.id,
'im_status': False,
'livechat_username': 'Michel Operator',
'name': 'Michel Operator',
}, {
'active': False,
'email': False,
'id': public_user.partner_id.id,
'im_status': False,
'livechat_username': False,
'name': 'Public user',
}], key=lambda m: m['id']))
# ensure visitor info are correct with real user
channel_info = self.livechat_channel.with_user(test_user)._open_livechat_mail_channel(anonymous_name='whatever', user_id=test_user.id)
visitor_info = channel_info['livechat_visitor']
@@ -74,7 +93,7 @@ class TestGetMailChannel(TransactionCase):
# ensure visitor info are correct when operator is testing himself
operator = self.operators[0]
channel_info = self.livechat_channel.with_user(operator)._open_livechat_mail_channel(anonymous_name='whatever', previous_operator_id=operator.partner_id.id, user_id=operator.id)
self.assertEqual(channel_info['operator_pid'], (operator.partner_id.id, "Michel"))
self.assertEqual(channel_info['operator_pid'], (operator.partner_id.id, "Michel Operator"))
visitor_info = channel_info['livechat_visitor']
self.assertEqual(visitor_info['id'], operator.partner_id.id)
self.assertEqual(visitor_info['name'], "Michel")
@@ -100,3 +119,22 @@ class TestGetMailChannel(TransactionCase):
self.env['mail.channel'].browse(channel_info['id']).message_post(body='cc')
self.assertTrue(operator_channel_partner.is_pinned, "channel should be pinned for operator after visitor sent a message")
self.assertIn(channel_info['id'], operator_channel_partner.partner_id._get_channels_as_member().ids, "channel should be fetched by operator on new page")
def test_operator_livechat_username(self):
"""Ensures the operator livechat_username is returned by `_channel_fetch_message`, which is
the method called by the public route displaying chat history."""
public_user = self.env.ref('base.public_user')
operator = self.operators[0]
operator.write({
'email': 'michel@example.com',
'livechat_username': 'Michel at your service',
})
channel_info = self.livechat_channel.with_user(public_user)._open_livechat_mail_channel(anonymous_name='whatever')
channel = self.env['mail.channel'].browse(channel_info['id'])
channel.with_user(operator).message_post(body='Hello', message_type='comment', subtype_xmlid='mail.mt_comment')
message_formats = channel.with_user(public_user)._channel_fetch_message()
self.assertEqual(len(message_formats), 1)
self.assertEqual(message_formats[0]['author_id'][0], operator.partner_id.id)
self.assertEqual(message_formats[0]['author_id'][1], operator.livechat_username)
self.assertEqual(message_formats[0]['author_id'][2], operator.livechat_username)
self.assertFalse(message_formats[0].get('email_from'), "should not send email_from to livechat user")
+16 -5
View File
@@ -771,7 +771,7 @@ class Channel(models.Model):
# avoid sending potentially a lot of members for big channels
# exclude chat and other small channels from this optimization because they are
# assumed to be smaller and it's important to know the member list for them
info['members'] = sorted(list(partner_format_by_partner[member.partner_id] for member in members_by_channel[channel] if member.partner_id), key=lambda p: p['id'])
info['members'] = sorted(list(channel._channel_info_format_member(member.partner_id, partner_format_by_partner[member.partner_id]) for member in members_by_channel[channel] if member.partner_id), key=lambda p: p['id'])
info['seen_partners_info'] = sorted([{
'id': cp.id,
'partner_id': cp.partner_id.id,
@@ -789,6 +789,11 @@ class Channel(models.Model):
channel_infos.append(info)
return channel_infos
def _channel_info_format_member(self, partner, partner_info):
"""Returns member information in the context of self channel."""
self.ensure_one()
return partner_info
def _channel_fetch_message(self, last_id=False, limit=20):
""" Return message values of the current channel.
:param last_id : last message id to start the research
@@ -1002,16 +1007,22 @@ class Channel(models.Model):
"""
notifications = []
for channel in self:
data = {
data = dict({
'channel_id': channel.id,
'is_typing': is_typing,
'partner_id': self.env.user.partner_id.id,
'partner_name': self.env.user.partner_id.name,
}
}, **channel._notify_typing_partner_data())
notifications.append([channel, 'mail.channel.partner/typing_status', data]) # notify backend users
notifications.append([channel.uuid, 'mail.channel.partner/typing_status', data]) # notify frontend users
self.env['bus.bus']._sendmany(notifications)
def _notify_typing_partner_data(self):
"""Returns typing partner data for self channel."""
self.ensure_one()
return {
'partner_id': self.env.user.partner_id.id,
'partner_name': self.env.user.partner_id.name,
}
@api.model
def channel_search_to_join(self, name=None, domain=None):
""" Return the channel info of the channel the current partner can join
@@ -59,7 +59,12 @@
<div class="o_Message_header ml-2">
<t t-if="messageView.message.author">
<div class="o_Message_authorName o_Message_authorRedirect o_redirect text-truncate" t-on-click="_onClickAuthorName" title="Open profile">
<t t-esc="messageView.message.author.nameOrDisplayName"/>
<t t-if="messageView.message.originThread">
<t t-esc="messageView.message.originThread.getMemberName(messageView.message.author)"/>
</t>
<t t-else="">
<t t-esc="messageView.message.author.nameOrDisplayName"/>
</t>
</div>
</t>
<t t-elif="messageView.message.guestAuthor">
@@ -21,9 +21,12 @@ export class MessageSeenIndicator extends Component {
return this.env._t("Seen by Everyone");
}
if (this.messageSeenIndicator.hasSomeoneSeen) {
const partnersThatHaveSeen = this.messageSeenIndicator.partnersThatHaveSeen.map(
partner => partner.name
);
const partnersThatHaveSeen = this.messageSeenIndicator.partnersThatHaveSeen.map(partner => {
if (this.message.originThread) {
return this.message.originThread.getMemberName(partner);
}
return partner.nameOrDisplayName;
});
if (partnersThatHaveSeen.length === 1) {
return _.str.sprintf(
this.env._t("Seen by %s"),
@@ -47,9 +50,12 @@ export class MessageSeenIndicator extends Component {
return this.env._t("Received by Everyone");
}
if (this.messageSeenIndicator.hasSomeoneFetched) {
const partnersThatHaveFetched = this.messageSeenIndicator.partnersThatHaveFetched.map(
partner => partner.name
);
const partnersThatHaveFetched = this.messageSeenIndicator.partnersThatHaveFetched.map(partner => {
if (this.message.originThread) {
return this.message.originThread.getMemberName(partner);
}
return partner.nameOrDisplayName;
});
if (partnersThatHaveFetched.length === 1) {
return _.str.sprintf(
this.env._t("Received by %s"),
@@ -55,7 +55,12 @@ registerModel({
1: secondUser,
2: thirdUser,
} = [
...this.partners.map(partner => partner.nameOrDisplayName),
...this.partners.map(partner => {
if (this.message.originThread) {
return this.message.originThread.getMemberName(partner);
}
return partner.nameOrDisplayName;
}),
...this.guests.map(guest => guest.name),
];
switch (length) {
@@ -410,6 +410,15 @@ registerModel({
compute: '_computeAvatarUrl',
}),
country: many2one('Country'),
/**
* Deprecated.
* States the `display_name` of this partner, as returned by the server.
* The value of this field is unreliable (notably its value depends on
* context on which it was received) therefore it should only be used as
* a default if the actual `name` is missing (@see `nameOrDisplayName`).
* And if a specific name format is required, it should be computed from
* relevant fields instead.
*/
display_name: attr({
compute: '_computeDisplayName',
default: "",
+14 -5
View File
@@ -792,6 +792,15 @@ registerModel({
this.endCall();
}
},
/**
* Returns the name of the given partner in the context of this thread.
*
* @param {mail.partner} partner
* @returns {string}
*/
getMemberName(partner) {
return partner.nameOrDisplayName;
},
/**
* Returns the text that identifies this thread in a mention.
*
@@ -1679,20 +1688,20 @@ registerModel({
if (this.orderedOtherTypingMembers.length === 1) {
return _.str.sprintf(
this.env._t("%s is typing..."),
this.orderedOtherTypingMembers[0].nameOrDisplayName
this.getMemberName(this.orderedOtherTypingMembers[0])
);
}
if (this.orderedOtherTypingMembers.length === 2) {
return _.str.sprintf(
this.env._t("%s and %s are typing..."),
this.orderedOtherTypingMembers[0].nameOrDisplayName,
this.orderedOtherTypingMembers[1].nameOrDisplayName
this.getMemberName(this.orderedOtherTypingMembers[0]),
this.getMemberName(this.orderedOtherTypingMembers[1])
);
}
return _.str.sprintf(
this.env._t("%s, %s and more are typing..."),
this.orderedOtherTypingMembers[0].nameOrDisplayName,
this.orderedOtherTypingMembers[1].nameOrDisplayName
this.getMemberName(this.orderedOtherTypingMembers[0]),
this.getMemberName(this.orderedOtherTypingMembers[1])
);
},
/**
@@ -620,25 +620,19 @@ class TestDiscussFullPerformance(TransactionCase):
'members': [
{
'active': True,
'display_name': 'Ernest Employee',
'email': 'e.e@example.com',
'email': False,
'id': self.users[0].partner_id.id,
'im_status': 'leave_offline',
'is_internal_user': True,
'im_status': False,
'livechat_username': False,
'name': 'Ernest Employee',
'out_of_office_date_end': self.leaves.filtered(lambda l: l.employee_id.user_id == self.users[0]).date_to.strftime(DEFAULT_SERVER_DATE_FORMAT),
'user_id': self.users[0].id,
},
{
'active': True,
'display_name': 'test1',
'email': 'test1@example.com',
'email': False,
'id': self.users[1].partner_id.id,
'im_status': 'leave_offline',
'is_internal_user': True,
'im_status': False,
'livechat_username': False,
'name': 'test1',
'out_of_office_date_end': self.leaves.filtered(lambda l: l.employee_id.user_id == self.users[1]).date_to.strftime(DEFAULT_SERVER_DATE_FORMAT),
'user_id': self.users[1].id,
},
],
'message_needaction_counter': 0,
@@ -689,25 +683,19 @@ class TestDiscussFullPerformance(TransactionCase):
'members': [
{
'active': False,
'display_name': 'Public user',
'email': False,
'id': self.env.ref('base.public_partner').id,
'im_status': 'im_partner',
'is_internal_user': False,
'im_status': False,
'livechat_username': False,
'name': 'Public user',
'out_of_office_date_end': False,
'user_id': False,
},
{
'active': True,
'display_name': 'Ernest Employee',
'email': 'e.e@example.com',
'email': False,
'id': self.users[0].partner_id.id,
'im_status': 'leave_offline',
'is_internal_user': True,
'im_status': False,
'livechat_username': False,
'name': 'Ernest Employee',
'out_of_office_date_end': self.leaves.filtered(lambda l: l.employee_id.user_id == self.users[0]).date_to.strftime(DEFAULT_SERVER_DATE_FORMAT),
'user_id': self.users[0].id,
},
],
'message_needaction_counter': 0,
+2 -1
View File
@@ -49,7 +49,8 @@ class Website(models.Model):
"id": chat_request_channel.id,
"operator_pid": [
chat_request_channel.livechat_operator_id.id,
chat_request_channel.livechat_operator_id.display_name
chat_request_channel.livechat_operator_id.user_livechat_username or chat_request_channel.livechat_operator_id.display_name,
chat_request_channel.livechat_operator_id.user_livechat_username,
],
"name": chat_request_channel.name,
"uuid": chat_request_channel.uuid,