[IMP] spreadsheet: share spreadsheet

This commit allows to share spreadsheets to other users (public, portal or
internal without the required access rights)

See Enterprise commit. This commit prepares the ground to sharing
`documents.document` spreadsheets.
(I expect dashboard spreadsheet sharing in the near future which will
use the same generic code)

The challenges of sharing odoo spreadsheets
-------------------------------------------

Odoo spreadsheets can have any data from the database.
ODOO.PIVOT and ODOO.LIST functions specifically can target *any model*
and *any field*.
The values are dynamically loaded with RPC calls when the
spreadsheet is open. Normal access rights apply to load this kind of
"embeded" data.

A user can open a spreadsheet if he can read the `documents.document` record,
but odoo specific functions might result in errors if the user doesn't have
the access rights on the underlying model.

That's obviously not what we want when sharing a spreadsheet to an external
person. We want this person to see the values and not a spreadsheet full
of errors.

Giving access to external user?
---------------------------------

Users must have a very clear understanding what they are "leaking" when
they share a spreadsheet. Sharing a spreadsheet should not open any door
the user wouldn't think of or wouldn't understand.

The best way is to be very strict with the data we are sharing.
That means: only the specific models, specific fields and specific records
visible in the spreadsheet by the user who is sharing (different users can
see different values for the same spreadsheet, depending on their access rights).

We also want to consider the following scenario:
Alice is a newcomer (with very limited access rights) and she shares a
spreadsheet to a customer. A few years later, she is manager and has a
lot more access rights (groups, ir.rules, etc.).
The forgotten spreadsheet shared years ago should not leak more data because
Alice now has access to all company data.

Specification
=============

With all those challenges in mind, here is a first approach of shared
spreadsheet:

Readonly freezed spreadsheet for external users
-----------------------------------------------

When sharing a spreadsheet, we actually copy and freeze the spreadsheet at that
time. Odoo formulas are replaced with their value. This is the easiest and safest
way to deal with access rights to other models: there's no access to other
models at all ^^
The spreadsheet is displayed in readonly since it would only be editing a copy.
If the external person wants data to be updated, he can ask a new sharing link.

Read/Write for internal users
-----------------------------

The situation for internal users is different. We can rely on their actual access
rights.
When an internal user opens a spreadsheet sharing link, he is redirected to the
regular spreadsheet client action. A token is used to read/write the
`documents.document` record (and other linked models such as `spreadsheet.revision`),
but the data for pivots, lists, etc. is loaded with the user's own access rights.
If the user doesn't have the rights to read a model or field, the function results in
an error and that's the expected behavior.
This sharing strategy is perfectly fine for all spreadsheets that doesn't contain
any odoo data (think of all the Google Sheets we receive internally by email to
register to an event or any other stuff).

Future work
-----------
From a functional point of view, the spec is far from perfect.
Users would probably expect the data to "update" itself (not freezed).
People will want write access for external users as well.
Given the complexity of getting it right (from a tecnical, security and functional POV),
this is left for a later work

closes odoo/odoo#114040

Task: 3045808
Related: odoo/enterprise#37687
Signed-off-by: Rémi Rahir (rar) <rar@odoo.com>
This commit is contained in:
Lucas Lefèvre
2023-05-15 13:58:59 +02:00
parent 93df76fedb
commit 96669c74f8
12 changed files with 450 additions and 39 deletions
+6
View File
@@ -181,6 +181,12 @@
</xpath>
</template>
<template id="portal.user_sign_in_redirect" name="User Sign In redirect" inherit_id="portal.user_sign_in" primary="True">
<xpath expr="//a" position="attributes">
<attribute name="t-attf-href">/web/login?redirect={{request.httprequest.path}}</attribute>
</xpath>
</template>
<template id="portal_my_home" name="My Portal">
<t t-call="portal.portal_layout">
<t t-set="my_details" t-value="True"/>
+47 -2
View File
@@ -6,9 +6,12 @@
'category': 'Hidden',
'summary': 'Spreadsheet',
'description': 'Spreadsheet',
'depends': ['bus', 'web'],
'depends': ['bus', 'web', 'portal'],
'installable': True,
'license': 'LGPL-3',
'data': [
'views/public_readonly_spreadsheet_templates.xml',
],
'assets': {
'spreadsheet.o_spreadsheet': [
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js',
@@ -16,7 +19,49 @@
# Load all o_spreadsheet templates first to allow to inherit them
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml',
'spreadsheet/static/src/**/*.xml',
('remove', 'spreadsheet/static/src/assets_backend/**/*')
('remove', 'spreadsheet/static/src/assets_backend/**/*'),
('remove', 'spreadsheet/static/src/public_readonly_app/**/*'),
],
'spreadsheet.public_spreadsheet': [
('include', 'web.assets_frontend_minimal'),
('include', 'web._assets_helpers'), # bootstrap variables
'web/static/src/scss/bootstrap_overridden.scss',
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
('include', 'web._assets_bootstrap'),
'web/static/lib/popper/popper.js',
'web/static/lib/bootstrap/js/dist/dom/data.js',
'web/static/lib/bootstrap/js/dist/dom/event-handler.js',
'web/static/lib/bootstrap/js/dist/dom/manipulator.js',
'web/static/lib/bootstrap/js/dist/dom/selector-engine.js',
'web/static/lib/bootstrap/js/dist/base-component.js',
'web/static/lib/bootstrap/js/dist/collapse.js',
'web/static/lib/bootstrap/js/dist/dropdown.js',
'web/static/src/libs/fontawesome/css/font-awesome.css',
'web/static/lib/owl/owl.js',
'web/static/lib/luxon/luxon.js',
'web/static/lib/owl/odoo_module.js',
'web/static/src/core/utils/**/*.js',
'web/static/src/core/browser/browser.js',
'web/static/src/core/registry.js',
'web/static/src/core/assets.js',
'web/static/src/session.js',
'web/static/src/env.js',
'web/static/src/core/network/http_service.js',
'web/static/src/core/network/rpc_service.js',
'web/static/src/core/user_service.js',
'web/static/src/core/l10n/**/*.js',
'web/static/src/core/network/download.js',
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js',
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml',
'spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.scss',
'spreadsheet/static/src/o_spreadsheet/migration.js',
'spreadsheet/static/src/o_spreadsheet/odoo_module.js',
'spreadsheet/static/src/helpers/helpers.js',
'spreadsheet/static/src/public_readonly_app/**/*.xml',
'spreadsheet/static/src/public_readonly_app/**/*.scss',
'spreadsheet/static/src/public_readonly_app/**/*',
],
'web.assets_backend': [
'spreadsheet/static/src/**/*.scss',
@@ -1,16 +1,12 @@
/** @odoo-module */
import { DataSources } from "@spreadsheet/data_sources/data_sources";
import { migrate } from "@spreadsheet/o_spreadsheet/migration";
import { download } from "@web/core/network/download";
import { registry } from "@web/core/registry";
import { Model } from "@odoo/o-spreadsheet";
import { _t } from "@web/core/l10n/translation";
import { createSpreadsheetModel, waitForDataLoaded } from "@spreadsheet/helpers/model";
async function downloadSpreadsheet(env, action) {
const { orm, name, data, stateUpdateMessages } = action.params;
const dataSources = new DataSources(orm);
const model = new Model(migrate(data), { custom: { dataSources } }, stateUpdateMessages);
const model = await createSpreadsheetModel({ orm, data, revisions: stateUpdateMessages });
await waitForDataLoaded(model);
const { files } = model.exportXLSX();
await download({
@@ -22,37 +18,6 @@ async function downloadSpreadsheet(env, action) {
});
}
/**
* Ensure that the spreadsheet does not contains cells that are in loading state
* @param {Model} model
* @returns {Promise<void>}
*/
async function waitForDataLoaded(model) {
const dataSources = model.config.custom.dataSources;
return new Promise((resolve, reject) => {
function check() {
model.dispatch("EVALUATE_CELLS");
if (isLoaded(model)) {
dataSources.removeEventListener("data-source-updated", check);
resolve();
}
}
dataSources.addEventListener("data-source-updated", check);
check();
});
}
function isLoaded(model) {
for (const sheetId of model.getters.getSheetIds()) {
for (const cell of Object.values(model.getters.getEvaluatedCells(sheetId))) {
if (cell.type === "error" && cell.error.message === _t("Data is loading")) {
return false;
}
}
}
return true;
}
registry
.category("actions")
.add("action_download_spreadsheet", downloadSpreadsheet, { force: true });
@@ -0,0 +1,40 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { CopyButton } from "@web/views/fields/copy_clipboard/copy_button";
import { waitForDataLoaded, freezeOdooData } from "@spreadsheet/helpers/model";
import { Model } from "@odoo/o-spreadsheet";
/**
* Share button to share a spreadsheet
*/
export class SpreadsheetShareButton extends Component {
static template = "spreadsheet.ShareButton";
static components = { Dropdown, DropdownItem, CopyButton };
static props = {
model: Model,
onSpreadsheetShared: Function,
};
setup() {
this.copiedText = _t("Copied");
this.state = useState({ url: undefined });
}
async onOpened() {
if (this.state.url) {
return;
}
const model = this.props.model;
await waitForDataLoaded(model);
const data = await freezeOdooData(model);
const url = await this.props.onSpreadsheetShared(data, model.exportXLSX());
this.state.url = url;
browser.navigator.clipboard.writeText(url);
}
}
@@ -0,0 +1,4 @@
.spreadsheet_share_dropdown {
width: 320px;
height: 100px;
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="spreadsheet.ShareButton" owl="1">
<Dropdown
togglerClass="'btn btn-light'"
menuClass="'spreadsheet_share_dropdown d-flex flex-column'"
position="'bottom-end'"
onOpened.bind="onOpened"
>
<t t-set-slot="toggler">
<i class="fa fa-share-alt"/>
Share
</t>
<t t-if="state.url">
<div class="d-flex px-3">
<div class="align-self-center d-flex justify-content-center align-items-center flex-shrink-0">
<i class="fa fa-globe fa-2x" title="Share to web"></i>
</div>
<div class="flex-grow-1 px-3">
<div class="lead">Spreadsheet published</div>
<div>Frozen version - Anyone can view</div>
</div>
</div>
<div class=" px-3 o_field_widget o_readonly_modifier o_field_CopyClipboardChar">
<div class="d-grid rounded-2 overflow-hidden">
<span t-out="state.url"/>
<CopyButton className="'o_btn_char_copy btn-sm'" content="state.url" successText="copiedText"/>
</div>
</div>
</t>
<div t-else="" class="d-flex align-items-center justify-content-center h-100">
<i class="fa fa-spin fa-spinner px-2"/><span>Generating sharing link</span>
</div>
</Dropdown>
</t>
</templates>
@@ -0,0 +1,167 @@
/** @odoo-module */
import { DataSources } from "@spreadsheet/data_sources/data_sources";
import * as spreadsheet from "@odoo/o-spreadsheet";
import { migrate } from "@spreadsheet/o_spreadsheet/migration";
import { _t } from "@web/core/l10n/translation";
import { loadJS } from "@web/core/assets";
const { Model, tokenize } = spreadsheet;
const { toCartesian } = spreadsheet.helpers;
export async function fetchSpreadsheetModel(orm, resModel, resId) {
const { data, revisions } = await orm.call(resModel, "join_spreadsheet_session", [resId]);
return createSpreadsheetModel({ orm, data, revisions });
}
export function createSpreadsheetModel({ orm, data, revisions }) {
const dataSources = new DataSources(orm);
const model = new Model(migrate(data), { custom: { dataSources } }, revisions);
return model;
}
/**
* Ensure that the spreadsheet does not contains cells that are in loading state
* @param {Model} model
* @returns {Promise<void>}
*/
export async function waitForDataLoaded(model) {
const dataSources = model.config.custom.dataSources;
await dataSources.waitForAllLoaded();
return new Promise((resolve, reject) => {
function check() {
model.dispatch("EVALUATE_CELLS");
if (isLoaded(model)) {
dataSources.removeEventListener("data-source-updated", check);
resolve();
}
}
dataSources.addEventListener("data-source-updated", check);
check();
});
}
/**
* @param {Model} model
* @returns {object}
*/
export async function freezeOdooData(model) {
await waitForDataLoaded(model);
const data = model.exportData();
for (const sheet of Object.values(data.sheets)) {
for (const [xc, cell] of Object.entries(sheet.cells)) {
if (containsOdooFunction(cell.content)) {
const { col, row } = toCartesian(xc);
const sheetId = sheet.id;
const evaluatedCell = model.getters.getEvaluatedCell({
sheetId,
col,
row,
});
cell.content = evaluatedCell.formattedValue;
if (evaluatedCell.format) {
cell.format = getItemId(evaluatedCell.format, data.formats);
}
}
}
for (const figure of sheet.figures) {
if (figure.tag === "chart" && figure.data.type.startsWith("odoo_")) {
await loadJS("/web/static/lib/Chart/Chart.js");
const img = odooChartToImage(model, figure);
figure.tag = "image";
figure.data = {
path: img,
size: { width: figure.width, height: figure.height },
};
}
}
}
return data;
}
/**
* copy-pasted from o-spreadsheet. Should be exported
* Get the id of the given item (its key in the given dictionnary).
* If the given item does not exist in the dictionary, it creates one with a new id.
*/
export function getItemId(item, itemsDic) {
for (const [key, value] of Object.entries(itemsDic)) {
if (value === item) {
return parseInt(key, 10);
}
}
// Generate new Id if the item didn't exist in the dictionary
const ids = Object.keys(itemsDic);
const maxId = ids.length === 0 ? 0 : Math.max(...ids.map((id) => parseInt(id, 10)));
itemsDic[maxId + 1] = item;
return maxId + 1;
}
/**
*
* @param {string | undefined} content
* @returns {boolean}
*/
function containsOdooFunction(content) {
if (!content || !content.startsWith("=") || !content.includes("ODOO.")) {
return false;
}
const tokens = tokenize(content);
return tokens.some((token) => token.type === "FUNCTION" && token.value.startsWith("ODOO."));
}
function isLoaded(model) {
for (const sheetId of model.getters.getSheetIds()) {
for (const cell of Object.values(model.getters.getEvaluatedCells(sheetId))) {
if (cell.type === "error" && cell.error.message === _t("Data is loading")) {
return false;
}
}
}
return true;
}
/**
* Return the chart figure as a base64 image.
* "data:image/png;base64,iVBORw0KGg..."
* @param {Model} model
* @param {object} figure
* @returns {string}
*/
function odooChartToImage(model, figure) {
const runtime = model.getters.getChartRuntime(figure.id);
// wrap the canvas in a div with a fixed size because chart.js would
// fill the whole page otherwise
const div = document.createElement("div");
div.style.width = `${figure.width}px`;
div.style.height = `${figure.height}px`;
const canvas = document.createElement("canvas");
div.append(canvas);
canvas.setAttribute("width", figure.width);
canvas.setAttribute("height", figure.height);
// we have to add the canvas to the DOM otherwise it won't be rendered
document.body.append(div);
runtime.chartJsConfig.plugins = [backgroundColorPlugin];
const chart = new Chart(canvas, runtime.chartJsConfig);
const img = chart.toBase64Image();
chart.destroy();
div.remove();
return img;
}
/**
* Custom chart.js plugin to set the background color of the canvas
* https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
*/
const backgroundColorPlugin = {
id: "customCanvasBackgroundColor",
beforeDraw: (chart, args, options) => {
const { ctx } = chart;
ctx.save();
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, chart.width, chart.height);
ctx.restore();
},
};
@@ -0,0 +1,32 @@
/** @odoo-module **/
import { App, whenReady } from "@odoo/owl";
import { PublicReadonlySpreadsheet } from "./public_readonly";
import { setLoadXmlDefaultApp, templates } from "@web/core/assets";
import { makeEnv, startServices } from "@web/env";
import { session } from "@web/session";
(async function boot() {
odoo.info = {
db: session.db,
server_version: session.server_version,
server_version_info: session.server_version_info,
isEnterprise: session.server_version_info.slice(-1)[0] === "e",
};
odoo.isReady = false;
const env = makeEnv();
await startServices(env);
await whenReady();
const app = new App(PublicReadonlySpreadsheet, {
env,
props: session.spreadsheet_public_props,
templates,
translateFn: env._t,
dev: env.debug,
warnIfNoStaticProps: env.debug,
translatableAttributes: ["data-tooltip"],
});
setLoadXmlDefaultApp(app);
const root = await app.mount(document.getElementById("spreadsheet-mount-anchor"));
odoo.__WOWL_DEBUG__ = { root };
odoo.isReady = true;
})();
@@ -0,0 +1,52 @@
/** @odoo-module **/
import { Component, onWillStart, useChildSubEnv } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { download } from "@web/core/network/download";
import * as spreadsheet from "@odoo/o-spreadsheet";
import { Spreadsheet, Model, registries } from "@odoo/o-spreadsheet";
import { _lt } from "@web/core/l10n/translation";
import { loadSpreadsheetDependencies } from "../helpers/helpers";
import { migrate } from "../o_spreadsheet/migration";
registries.topbarMenuRegistry.addChild("download_public_excel", ["file"], {
name: _lt("Download"),
execute: (env) => env.downloadExcel(),
isReadonlyAllowed: true,
});
export class PublicReadonlySpreadsheet extends Component {
static props = {
dataUrl: String,
downloadExcelUrl: String,
};
setup() {
this.http = useService("http");
useChildSubEnv({
downloadExcel: () =>
download({
url: this.props.downloadExcelUrl,
data: {},
}),
});
onWillStart(loadSpreadsheetDependencies);
onWillStart(this.createModel.bind(this));
}
async createModel() {
const data = await this.http.get(this.props.dataUrl);
this.model = new Model(migrate(data), {
mode: "readonly",
});
if (this.env.debug) {
// eslint-disable-next-line no-import-assign
spreadsheet.__DEBUG__ = spreadsheet.__DEBUG__ || {};
spreadsheet.__DEBUG__.model = this.model;
}
}
}
PublicReadonlySpreadsheet.template = "spreadsheet.PublicReadonlySpreadsheet";
PublicReadonlySpreadsheet.components = { Spreadsheet };
@@ -0,0 +1,12 @@
#spreadsheet-mount-anchor {
background-color: #DDDDDD;
flex-grow : 1;
}
.o-spreadsheet {
font-size: 12px;
height: 100%;
.o-topbar-toolbar .o-readonly-toolbar.text-muted {
color: $info !important;
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="spreadsheet.PublicReadonlySpreadsheet" owl="1">
<Spreadsheet model="model"/>
</t>
</templates>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="spreadsheet.public_spreadsheet_layout" name="Public spreadsheet layout">
<t t-call="web.layout">
<t t-set="head">
<script>
odoo.__session_info__ = <t t-out="json.dumps(session_info)"/>;
odoo.__session_info__.spreadsheet_public_props = <t t-out="json.dumps(props)"/>;
</script>
<t t-call-assets="spreadsheet.public_spreadsheet"/>
</t>
<div class="d-flex flex-column justify-content vh-100">
<header class="container-fluid p-0 d-flex align-items-center justify-content-between border-bottom">
<div t-out="spreadsheet_name" class="text-primary fw-bold ps-3"/>
<div class="fst-italic flex-fill ps-3">
Frozen and copied on <span t-field="share.create_date"/>
</div>
<div class="d-table my-auto">
<t t-call="portal.user_dropdown">
<t t-set="_user_name" t-value="True"/>
<t t-set="_item_class" t-valuef="nav-item d-table-cell text-center"/>
<t t-set="_link_class" t-valuef="nav-link fw-bold"/>
</t>
<t t-call="portal.user_sign_in_redirect">
<t t-set="_item_class" t-valuef="nav-item d-table-cell text-center"/>
<t t-set="_link_class" t-valuef="nav-link fw-bold"/>
</t>
<span class="d-table-cell text-center border-start" groups="base.group_public">
<a href="https://www.odoo.com/trial?selected_app=documents&amp;utm_source=db&amp;utm_medium=spreadsheet"
class="text-black nav-link" role="button" target="_blank">
<img width="24" src="/mail/static/src/img/odoo_o.png"/>
Try Odoo
</a>
</span>
</div>
</header>
<main id="spreadsheet-mount-anchor" class="flex-fill"/>
</div>
</t>
</template>
</odoo>