[IMP] im_livechat, mail: allow CORS livechat actions

This PR makes several actions available for the embed livechat
such as:
- calls
- message edition
- message reactions
- link previews

task-3523930

closes odoo/odoo#136618

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
tsm-odoo
2023-10-03 19:20:10 +00:00
parent 49277b5950
commit bd4e418fc1
20 changed files with 382 additions and 64 deletions
+1
View File
@@ -3,3 +3,4 @@ from . import controllers
from . import models
from . import report
from . import demo
from . import tools
+5
View File
@@ -107,6 +107,7 @@ Help your customers with this chat, and analyse their feedback.
'im_livechat/static/src/embed/livechat_data.js',
('remove', 'im_livechat/static/src/embed/frontend/**/*'),
('remove', 'im_livechat/static/src/embed/external/**/*'),
('remove', 'im_livechat/static/src/embed/cors/**/*'),
],
'im_livechat.assets_embed': [
'web/static/lib/jquery/jquery.js',
@@ -132,6 +133,10 @@ Help your customers with this chat, and analyse their feedback.
('include', 'im_livechat.assets_core'),
'im_livechat/static/src/embed/external/**/*',
],
'im_livechat.assets_cors': [
('include', 'im_livechat.assets_embed'),
'im_livechat/static/src/embed/cors/**/*',
],
'im_livechat.embed_test_assets': [
('include', 'web.tests_assets'),
('remove', 'web/static/tests/mock_server_tests.js'),
@@ -4,3 +4,4 @@
from . import chatbot
from . import main
from . import webclient
from . import cors
@@ -0,0 +1,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import channel
from . import link_preview
from . import main
from . import message_reaction
from . import rtc
from . import thread
from . import webclient
@@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import route
from odoo.addons.mail.controllers.discuss.channel import ChannelController
from odoo.addons.im_livechat.tools.misc import force_guest_env
class LivechatChannelController(ChannelController):
@route("/im_livechat/cors/channel/messages", methods=["POST"], type="json", auth="public", cors="*")
def livechat_channel_messages(self, guest_token, channel_id, before=None, after=None, limit=30, around=None):
force_guest_env(guest_token)
return self.discuss_channel_messages(channel_id, before, after, limit, around)
@route("/im_livechat/cors/channel/set_last_seen_message", methods=["POST"], type="json", auth="public", cors="*")
def livechat_channel_mark_as_seen(self, guest_token, channel_id, last_message_id, allow_older=False):
force_guest_env(guest_token)
return self.discuss_channel_mark_as_seen(channel_id, last_message_id, allow_older)
@@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import route
from odoo.addons.mail.controllers.link_preview import LinkPreviewController
from odoo.addons.im_livechat.tools.misc import force_guest_env
class LivechatLinkPreviewController(LinkPreviewController):
@route("/im_livechat/cors/link_preview", methods=["POST"], type="json", auth="public", cors="*")
def livechat_link_preview(self, guest_token, message_id, clear=None):
force_guest_env(guest_token)
self.mail_link_preview(message_id, clear)
@route("/im_livechat/cors/link_preview/delete", methods=["POST"], type="json", auth="public", cors="*")
def livechat_link_preview_delete(self, guest_token, link_preview_ids):
force_guest_env(guest_token)
self.mail_link_preview_delete(link_preview_ids)
@@ -0,0 +1,21 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import route
from odoo.addons.im_livechat.controllers.main import LivechatController
from odoo.addons.im_livechat.tools.misc import downgrade_to_public_user, force_guest_env
class CorsLivechatController(LivechatController):
@route("/im_livechat/cors/visitor_leave_session", type="json", auth="public", cors="*")
def cors_visitor_leave_session(self, guest_token, uuid):
force_guest_env(guest_token)
self.visitor_leave_session(uuid)
@route("/im_livechat/cors/get_session", methods=["POST"], type="json", auth="public", cors="*")
def cors_get_session(
self, channel_id, anonymous_name, previous_operator_id=None, chatbot_script_id=None, persisted=True, **kwargs
):
downgrade_to_public_user()
return self.get_session(
channel_id, anonymous_name, previous_operator_id, chatbot_script_id, persisted, **kwargs
)
@@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import route
from odoo.addons.mail.controllers.message_reaction import MessageReactionController
from odoo.addons.im_livechat.tools.misc import force_guest_env
class LivechatMessageReactionController(MessageReactionController):
@route("/im_livechat/cors/message/reaction", methods=["POST"], type="json", auth="public", cors="*")
def livechat_message_add_reaction(self, guest_token, message_id, content, action):
force_guest_env(guest_token)
return self.mail_message_add_reaction(message_id, content, action)
@@ -0,0 +1,32 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import route
from odoo.addons.mail.controllers.discuss.rtc import RtcController
from odoo.addons.im_livechat.tools.misc import force_guest_env
class LivechatRtcController(RtcController):
@route("/im_livechat/cors/rtc/channel/join_call", methods=["POST"], type="json", auth="public", cors="*")
def livechat_channel_call_join(self, guest_token, channel_id, check_rtc_session_ids=None):
force_guest_env(guest_token)
return self.channel_call_join(channel_id, check_rtc_session_ids)
@route("/im_livechat/cors/rtc/channel/leave_call", methods=["POST"], type="json", auth="public", cors="*")
def livechat_channel_call_leave(self, guest_token, channel_id):
force_guest_env(guest_token)
return self.channel_call_leave(channel_id)
@route("/im_livechat/cors/rtc/session/update_and_broadcast", methods=["POST"], type="json", auth="public", cors="*")
def livechat_session_update_and_broadcast(self, guest_token, session_id, values):
force_guest_env(guest_token)
self.session_update_and_broadcast(session_id, values)
@route("/im_livechat/cors/rtc/session/notify_call_members", methods=["POST"], type="json", auth="public", cors="*")
def livechat_session_call_notify(self, guest_token, peer_notifications):
force_guest_env(guest_token)
self.session_call_notify(peer_notifications)
@route("/im_livechat/cors/channel/ping", methods=["POST"], type="json", auth="public", cors="*")
def livechat_channel_ping(self, guest_token, channel_id, rtc_session_id=None, check_rtc_session_ids=None):
force_guest_env(guest_token)
return self.channel_ping(channel_id, rtc_session_id, check_rtc_session_ids)
@@ -0,0 +1,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import route
from odoo.addons.mail.controllers.thread import ThreadController
from odoo.addons.im_livechat.tools.misc import force_guest_env
class LivechatThreadController(ThreadController):
@route("/im_livechat/cors/message/post", methods=["POST"], type="json", auth="public", cors="*")
def livechat_message_post(self, guest_token, thread_model, thread_id, post_data, context=None):
force_guest_env(guest_token)
return self.mail_message_post(thread_model, thread_id, post_data, context)
@route("/im_livechat/cors/message/update_content", methods=["POST"], type="json", auth="public", cors="*")
def livechat_message_update_content(
self, guest_token, message_id, body, attachment_ids, attachment_tokens=None, partner_ids=None
):
force_guest_env(guest_token)
return self.mail_message_update_content(message_id, body, attachment_ids, attachment_tokens, partner_ids)
@@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import route
from odoo.addons.mail.controllers.webclient import WebclientController
from odoo.addons.im_livechat.tools.misc import force_guest_env
class WebClient(WebclientController):
@route("/im_livechat/cors/init_messaging", methods=["POST"], type="json", auth="public", cors="*")
def livechat_init_messaging(self, guest_token):
force_guest_env(guest_token)
return super().mail_init_messaging()
+9 -24
View File
@@ -3,6 +3,7 @@
from markupsafe import Markup
import re
from werkzeug.exceptions import NotFound
from urllib.parse import urlsplit
from odoo import http, tools, _, release
from odoo.http import request
@@ -30,7 +31,13 @@ class LivechatController(http.Controller):
@http.route('/im_livechat/assets_embed.<any(css, js):ext>', type='http', auth='public', cors='*')
def assets_embed(self, ext, **kwargs):
# If the request comes from a different origin, we must provide the CORS
# assets to enable the redirection of routes to the CORS controller.
headers = request.httprequest.headers
origin_url = urlsplit(headers.get('referer'))
bundle = 'im_livechat.assets_embed'
if origin_url.netloc != headers.get('host') or origin_url.scheme != request.httprequest.scheme:
bundle = 'im_livechat.assets_cors'
asset = request.env["ir.qweb"]._get_asset_bundle(bundle)
if ext not in ('css', 'js'):
raise request.not_found()
@@ -143,7 +150,7 @@ class LivechatController(http.Controller):
def _get_guest_name(self):
return _("Visitor")
@http.route('/im_livechat/get_session', methods=["POST"], type="json", auth='public', cors="*")
@http.route('/im_livechat/get_session', methods=["POST"], type="json", auth='public')
@add_guest_to_context
def get_session(self, channel_id, anonymous_name, previous_operator_id=None, chatbot_script_id=None, persisted=True, **kwargs):
user_id = None
@@ -252,20 +259,6 @@ class LivechatController(http.Controller):
channel._send_history_message(pid, page_history)
return True
@http.route('/im_livechat/notify_typing', type='json', auth='public', cors="*")
def notify_typing(self, uuid, is_typing):
""" Broadcast the typing notification of the website user to other channel members
:param uuid: (string) the UUID of the livechat channel
:param is_typing: (boolean) tells whether the website user is typing or not.
"""
channel = request.env['discuss.channel'].sudo().search([('uuid', '=', uuid)])
if not channel:
raise NotFound()
channel_member = channel.env['discuss.channel.member'].search([('channel_id', '=', channel.id), ('partner_id', '=', request.env.user.partner_id.id)])
if not channel_member:
raise NotFound()
channel_member._notify_typing(is_typing=is_typing)
@http.route('/im_livechat/email_livechat_transcript', type='json', auth='public', cors="*")
def email_livechat_transcript(self, uuid, email):
channel = request.env['discuss.channel'].sudo().search([
@@ -274,7 +267,7 @@ class LivechatController(http.Controller):
if channel:
channel._email_livechat_transcript(email)
@http.route('/im_livechat/visitor_leave_session', type='json', auth="public", cors="*")
@http.route('/im_livechat/visitor_leave_session', type='json', auth="public")
@add_guest_to_context
def visitor_leave_session(self, uuid):
""" Called when the livechat visitor leaves the conversation.
@@ -287,11 +280,3 @@ class LivechatController(http.Controller):
channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_context_or_raise(channel_id=discuss_channel.id)
channel_member_sudo._rtc_leave_call()
discuss_channel._close_livechat_session()
@http.route(['/im_livechat/chat_history'], type="json", auth="public", cors="*")
def im_livechat_chat_history(self, uuid, last_id=False, limit=20):
channel = request.env["discuss.channel"].sudo().search([('uuid', '=', uuid)], limit=1)
if not channel:
return []
else:
return channel._channel_fetch_message(last_id, limit)
@@ -0,0 +1,45 @@
/* @odoo-module */
import { livechatRoutingMap } from "@im_livechat/embed/cors/livechat_routing_map";
import { browser } from "@web/core/browser/browser";
import { jsonrpc } from "@web/core/network/rpc_service";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
(async function boot() {
const { fetch } = browser;
browser.fetch = function (url, ...args) {
if (!url.match(/^(?:https?:)?\/\//)) {
url = session.origin + url;
}
return fetch(url, ...args);
};
// Override the rpc service to forward requests to CORS-allowed routes. The
// "guest_token" will be appended to the request parameters for
// authentication.
registry.category("services").add(
"rpc",
{
async: true,
start(env) {
return function rpc(route, params = {}, settings) {
if (route in livechatRoutingMap.content) {
route = livechatRoutingMap.get(route, route);
if (env.services["im_livechat.livechat"]?.guestToken) {
params = {
...params,
guest_token: env.services["im_livechat.livechat"].guestToken,
};
}
}
if (!route.match(/^(?:https?:)?\/\//)) {
route = session.origin + route;
}
return jsonrpc(route, params, { bus: env.bus, ...settings });
};
},
},
{ force: true }
);
})();
@@ -0,0 +1,39 @@
/* @odoo-module */
import { registry } from "@web/core/registry";
/**
* This routing map is used to redirect the requests made by the livechat to
* dedicated CORS-allowed routes. Every route expected to be called by the
* livechat should be added here. Note that this will only be used if the
* livechat is loaded from a different origin than the Odoo server.
*
* @see /im_livechat/embed/cors/boot.js
*/
export const livechatRoutingMap = registry.category("discuss.routing_map");
livechatRoutingMap
.add("/discuss/channel/messages", "/im_livechat/cors/channel/messages")
.add(
"/discuss/channel/set_last_seen_message",
"/im_livechat/cors/channel/set_last_seen_message"
)
.add("/discuss/channel/ping", "/im_livechat/cors/channel/ping")
.add("/mail/init_messaging", "/im_livechat/cors/init_messaging")
.add("/mail/link_preview", "/im_livechat/cors/link_preview")
.add("/mail/link_preview/delete", "/im_livechat/cors/link_preview/delete")
.add("/mail/message/post", "/im_livechat/cors/message/post")
.add("/mail/message/reaction", "/im_livechat/cors/message/reaction")
.add("/mail/message/update_content", "/im_livechat/cors/message/update_content")
.add("/mail/rtc/channel/join_call", "/im_livechat/cors/rtc/channel/join_call")
.add("/mail/rtc/channel/leave_call", "/im_livechat/cors/rtc/channel/leave_call")
.add(
"/mail/rtc/session/notify_call_members",
"/im_livechat/cors/rtc/session/notify_call_members"
)
.add(
"/mail/rtc/session/update_and_broadcast",
"/im_livechat/cors/rtc/session/update_and_broadcast"
)
.add("/im_livechat/visitor_leave_session", "/im_livechat/cors/visitor_leave_session")
.add("/im_livechat/get_session", "/im_livechat/cors/get_session");
+1 -25
View File
@@ -1,7 +1,7 @@
/* @odoo-module */
import { makeRoot, makeShadow } from "@im_livechat/embed/boot_helpers";
import { LivechatButton } from "@im_livechat/embed/core_ui/livechat_button";
import { makeShadow, makeRoot } from "@im_livechat/embed/boot_helpers";
import { serverUrl } from "@im_livechat/embed/livechat_data";
import { ChatWindowContainer } from "@mail/core/common/chat_window_container";
@@ -9,37 +9,13 @@ import { ChatWindowContainer } from "@mail/core/common/chat_window_container";
import { mount, whenReady } from "@odoo/owl";
import { templates } from "@web/core/assets";
import { browser } from "@web/core/browser/browser";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { jsonrpc } from "@web/core/network/rpc_service";
import { registry } from "@web/core/registry";
import { makeEnv, startServices } from "@web/env";
import { session } from "@web/session";
(async function boot() {
session.origin = serverUrl;
const { fetch } = browser;
browser.fetch = function (url, ...args) {
if (!url.match(/^(?:https?:)?\/\//)) {
url = session.origin + url;
}
return fetch(url, ...args);
};
registry.category("services").add(
"rpc",
{
async: true,
start(env) {
return function rpc(route, params = {}, settings = {}) {
if (!route.match(/^(?:https?:)?\/\//)) {
route = session.origin + route;
}
return jsonrpc(route, params, { bus: env.bus, ...settings });
};
},
},
{ force: true }
);
await whenReady();
const mainComponentsRegistry = registry.category("main_components");
mainComponentsRegistry.add("LivechatRoot", { Component: LivechatButton });
+1
View File
@@ -5,6 +5,7 @@ from . import chatbot_common
from . import test_chatbot_form_ui
from . import test_chatbot_internals
from . import test_digest
from . import test_cors_livechat
from . import test_get_discuss_channel
from . import test_get_operator
from . import test_im_livechat_report
@@ -0,0 +1,93 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged, HttpCase, JsonRpcException
@tagged("post_install", "-at_install")
class TestCorsLivechat(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.operator = cls.env["res.users"].create(
{
"name": "Operator",
"login": "operator",
}
)
cls.env["bus.presence"].create(
{
"user_id": cls.operator.id,
"status": "online",
}
)
cls.livechat_channel = cls.env["im_livechat.channel"].create(
{"name": "Test Livechat Channel", "user_ids": [cls.operator.id]}
)
def test_ignore_user_cookie(self):
self.authenticate("admin", "admin")
channel_info = self.make_jsonrpc_request(
"/im_livechat/cors/get_session",
{
"anonymous_name": "Visitor",
"channel_id": self.livechat_channel.id,
"persisted": True,
},
)
channel = self.env["discuss.channel"].browse(channel_info["id"])
self.assertEqual(channel.channel_member_ids[0].partner_id, self.operator.partner_id)
self.assertFalse(channel.channel_member_ids[1].partner_id)
self.assertTrue(channel.channel_member_ids[1].guest_id)
def test_ignore_guest_cookie(self):
guest = self.env["mail.guest"].create({"name": "Visitor"})
channel_info = self.make_jsonrpc_request(
"/im_livechat/cors/get_session",
{
"anonymous_name": "Visitor",
"channel_id": self.livechat_channel.id,
"persisted": True,
},
headers={"Cookie": f"{guest._cookie_name}={guest.id}{guest._cookie_separator}{guest.access_token};"},
)
channel = self.env["discuss.channel"].browse(channel_info["id"])
channel_guest = channel.channel_member_ids.filtered(lambda member: member.guest_id).guest_id
self.assertNotEqual(channel_guest, guest)
def test_access_routes_with_valid_guest_token(self):
channel_info = self.make_jsonrpc_request(
"/im_livechat/cors/get_session",
{
"anonymous_name": "Visitor",
"channel_id": self.livechat_channel.id,
"persisted": True,
},
)
self.authenticate(None, None)
self.make_jsonrpc_request(
"/im_livechat/cors/channel/messages",
{
"guest_token": channel_info["guest_token"],
"channel_id": channel_info["id"],
},
)
def test_access_denied_for_wrong_channel(self):
channel_info = self.make_jsonrpc_request(
"/im_livechat/cors/get_session",
{
"anonymous_name": "Visitor",
"channel_id": self.livechat_channel.id,
"persisted": True,
},
)
guest = self.env["mail.guest"].create({"name": "Visitor"})
self.authenticate(None, None)
with self.assertRaises(JsonRpcException, msg="werkzeug.exceptions.NotFound"):
self.make_jsonrpc_request(
"/im_livechat/cors/channel/messages",
{
"guest_token": guest.access_token,
"channel_id": channel_info["id"],
},
)
+3
View File
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import misc
+25
View File
@@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import request
from werkzeug.exceptions import NotFound
def downgrade_to_public_user():
"""Replace the request user by the public one. All the cookies are removed
in order to ensure that the no user-specific data is kept in the request."""
public_user = request.env.ref("base.public_user")
request.update_env(user=public_user)
request.httprequest.cookies = {}
def force_guest_env(guest_token):
"""Retrieve the guest from the given token and add it to the context.
The request user is then replaced by the public one.
:param str guest_token:
:raise NotFound: if the guest cannot be found from the token
"""
guest = request.env["mail.guest"]._get_guest_from_token(guest_token)
if not guest:
raise NotFound()
request.update_context(guest=guest)
downgrade_to_public_user()
+20 -15
View File
@@ -28,21 +28,15 @@ def add_guest_to_context(func):
req.httprequest.cookies.get(req.env["mail.guest"]._cookie_name)
or req.env.context.get("guest_token", "")
)
parts = token.split(req.env["mail.guest"]._cookie_separator)
guest = req.env["mail.guest"]
if len(parts) == 2:
guest_id, guest_access_token = parts
guest = req.env["mail.guest"].browse(int(guest_id)).sudo().exists()
if not guest or not guest.access_token or not consteq(guest.access_token, guest_access_token):
guest = req.env["mail.guest"]
elif not guest.timezone:
timezone = req.env["mail.guest"]._get_timezone_from_request(req)
if timezone:
guest._update_timezone(timezone)
guest = guest.sudo(False)
req.update_context(guest=guest)
if hasattr(self, "env"):
self.env.context = {**self.env.context, "guest": guest}
guest = req.env["mail.guest"]._get_guest_from_token(token)
if guest and not guest.timezone:
timezone = req.env["mail.guest"]._get_timezone_from_request(req)
if timezone:
guest._update_timezone(timezone)
if guest:
req.update_context(guest=guest)
if hasattr(self, "env"):
self.env.context = {**self.env.context, "guest": guest}
return func(self, *args, **kwargs)
return wrapper
@@ -83,6 +77,17 @@ class MailGuest(models.Model):
for guest in self:
guest.im_status = res.get(guest.id, 'offline')
def _get_guest_from_token(self, token=""):
"""Returns the guest record for the given token, if applicable."""
guest = self.env["mail.guest"]
parts = token.split(self._cookie_separator)
if len(parts) == 2:
guest_id, guest_access_token = parts
guest = self.browse(int(guest_id)).sudo().exists()
if not guest or not guest.access_token or not consteq(guest.access_token, guest_access_token):
guest = self.env["mail.guest"]
return guest.sudo(False)
def _get_guest_from_context(self):
"""Returns the current guest record from the context, if applicable."""
guest = self.env.context.get('guest')