@casualoffice/sheets 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chrome.cjs +2 -1
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.js +2 -1
- package/dist/chrome.js.map +1 -1
- package/dist/collab.cjs +8 -3
- package/dist/collab.cjs.map +1 -1
- package/dist/collab.d.cts +27 -2
- package/dist/collab.d.ts +27 -2
- package/dist/collab.js +8 -3
- package/dist/collab.js.map +1 -1
- package/dist/embed/embed-runtime.js +103 -103
- package/dist/index.cjs +50 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +50 -1
- package/dist/index.js.map +1 -1
- package/dist/sheets.cjs +51 -2
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.d.cts +30 -4
- package/dist/sheets.d.ts +30 -4
- package/dist/sheets.js +51 -2
- package/dist/sheets.js.map +1 -1
- package/package.json +1 -1
- package/src/chrome/fonts.ts +6 -1
- package/src/collab/attachCollab.ts +9 -3
- package/src/collab/index.ts +3 -0
- package/src/collab/ws-url.ts +35 -5
- package/src/collab/ws-url.unit.test.ts +55 -0
- package/src/embed-runtime/index.tsx +36 -0
- package/src/sheets/CasualSheetsIframe.tsx +5 -3
- package/src/sheets/index.ts +1 -0
- package/src/sheets/read-only.ts +106 -0
|
@@ -25,7 +25,7 @@ import type { IWorkbookData } from '@univerjs/core';
|
|
|
25
25
|
import type { FUniver } from '@univerjs/core/facade';
|
|
26
26
|
import type { CasualSheetsAPI } from '../sheets/api';
|
|
27
27
|
import { startBridge, type BridgeHandle } from './bridge';
|
|
28
|
-
import { buildWsUrl } from './ws-url';
|
|
28
|
+
import { buildWsUrl, type WsUrlShare } from './ws-url';
|
|
29
29
|
|
|
30
30
|
/** Either the SDK's imperative API (`onReady`) or the bare FUniver facade.
|
|
31
31
|
* Collab only needs the facade, so a host that holds the raw FUniver (the
|
|
@@ -64,8 +64,14 @@ export interface AttachCollabOptions {
|
|
|
64
64
|
* hook keep the connection queued without it. Defaults to `'anon'` (the
|
|
65
65
|
* reference server's hook only reads the `role` query param). */
|
|
66
66
|
token?: string;
|
|
67
|
-
/** `view` joins read-only. Defaults to `'write'`.
|
|
67
|
+
/** `view` joins read-only. Defaults to `'write'`. Ignored by the server
|
|
68
|
+
* when a `share` token is supplied — the token is authoritative. */
|
|
68
69
|
role?: CollabRole;
|
|
70
|
+
/** Secure share-link capability (sharing-model §6.1). When `share.share`
|
|
71
|
+
* is set, the role is omitted from the WS URL and the server resolves it
|
|
72
|
+
* from the token (bound to this room at mint time). `share.sp` carries
|
|
73
|
+
* the optional join password for a password-protected token. */
|
|
74
|
+
share?: WsUrlShare;
|
|
69
75
|
/**
|
|
70
76
|
* Called when a peer's compaction snapshot arrives — the host swaps the
|
|
71
77
|
* workbook (typically `api.loadSnapshot(wb)`). MAY return a promise; the
|
|
@@ -103,7 +109,7 @@ export function attachCollab(api: CollabAttachable, opts: AttachCollabOptions):
|
|
|
103
109
|
// Match the reference host: drop the reconnect timeout to 10 s so a dropped
|
|
104
110
|
// socket surfaces as `offline` quickly rather than after the 30 s default.
|
|
105
111
|
const ws = new HocuspocusProviderWebsocket({
|
|
106
|
-
url: buildWsUrl(opts.server, opts.room, role, opts.password),
|
|
112
|
+
url: buildWsUrl(opts.server, opts.room, role, opts.password, opts.share),
|
|
107
113
|
messageReconnectTimeout: 10_000,
|
|
108
114
|
});
|
|
109
115
|
const provider = new HocuspocusProvider({
|
package/src/collab/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export {
|
|
|
18
18
|
type CollabRole,
|
|
19
19
|
type CollabConnectionStatus,
|
|
20
20
|
} from './attachCollab';
|
|
21
|
+
// Secure share-link capability shape (sharing-model §6.1) — referenced by
|
|
22
|
+
// AttachCollabOptions.share.
|
|
23
|
+
export { type WsUrlShare } from './ws-url';
|
|
21
24
|
|
|
22
25
|
// The mutation bridge — the framework-agnostic core (subscribes to
|
|
23
26
|
// onMutationExecutedForCollab, replays with fromCollab, guards __splitChunk__).
|
package/src/collab/ws-url.ts
CHANGED
|
@@ -4,17 +4,47 @@
|
|
|
4
4
|
* `node:test` without dragging in the Univer ESM graph.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Extra capability params carried on the WS upgrade for secure share
|
|
8
|
+
* links (sharing-model §6.1). */
|
|
9
|
+
export type WsUrlShare = {
|
|
10
|
+
/** Secure share-link token. When present the server is authoritative
|
|
11
|
+
* for the role — it resolves the token to a role + a bound room, so
|
|
12
|
+
* the client must NOT also assert `role=` (the server ignores a
|
|
13
|
+
* client `?role=` when a token is present; sending one is just
|
|
14
|
+
* spoofable noise). */
|
|
15
|
+
share?: string;
|
|
16
|
+
/** Optional join password paired with a password-protected share
|
|
17
|
+
* token (`?sp=`). Distinct from the anonymous-room `?p=` password. */
|
|
18
|
+
sp?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the room WS URL.
|
|
23
|
+
*
|
|
24
|
+
* - No share token: `<server>?room=<id>[&p=<pw>]&role=<role>` — the
|
|
25
|
+
* anonymous-room path; byte-identical to before.
|
|
26
|
+
* - With a `share` token: `<server>?room=<id>[&p=<pw>]&share=<token>[&sp=<pw>]`.
|
|
27
|
+
* `role=` is deliberately omitted — the server resolves the role
|
|
28
|
+
* from the token, so shipping a spoofable client role is pointless.
|
|
29
|
+
*/
|
|
8
30
|
export function buildWsUrl(
|
|
9
31
|
server: string,
|
|
10
32
|
room: string,
|
|
11
33
|
role: 'view' | 'write',
|
|
12
34
|
password?: string,
|
|
35
|
+
share?: WsUrlShare,
|
|
13
36
|
): string {
|
|
14
37
|
const sep = server.includes('?') ? '&' : '?';
|
|
15
|
-
|
|
38
|
+
const base =
|
|
16
39
|
`${server}${sep}room=${encodeURIComponent(room)}` +
|
|
17
|
-
`${password ? `&p=${encodeURIComponent(password)}` : ''}
|
|
18
|
-
|
|
19
|
-
)
|
|
40
|
+
`${password ? `&p=${encodeURIComponent(password)}` : ''}`;
|
|
41
|
+
const token = share?.share;
|
|
42
|
+
if (token) {
|
|
43
|
+
return (
|
|
44
|
+
base +
|
|
45
|
+
`&share=${encodeURIComponent(token)}` +
|
|
46
|
+
`${share?.sp ? `&sp=${encodeURIComponent(share.sp)}` : ''}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return base + `&role=${encodeURIComponent(role)}`;
|
|
20
50
|
}
|
|
@@ -33,3 +33,58 @@ test('buildWsUrl percent-encodes the room id', () => {
|
|
|
33
33
|
'wss://h/yjs?room=a%20b%2Fc&role=write',
|
|
34
34
|
);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// ── Secure share-link tokens (sharing-model §6.1) ──────────────────────────
|
|
38
|
+
|
|
39
|
+
test('buildWsUrl with a share token forwards share= and OMITS role=', () => {
|
|
40
|
+
// The server is authoritative for the role when a token is present, so the
|
|
41
|
+
// spoofable client role must not be on the URL — even though `role` is
|
|
42
|
+
// still passed positionally. A `write` here must NOT leak through.
|
|
43
|
+
assert.equal(
|
|
44
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, { share: 'tok123' }),
|
|
45
|
+
'wss://h/yjs?room=room1&share=tok123',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('buildWsUrl with a share token forwards sp= (join password) when set', () => {
|
|
50
|
+
assert.equal(
|
|
51
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', undefined, { share: 'tok123', sp: 'p@ss/word' }),
|
|
52
|
+
'wss://h/yjs?room=room1&share=tok123&sp=p%40ss%2Fword',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('buildWsUrl percent-encodes the share token', () => {
|
|
57
|
+
assert.equal(
|
|
58
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', undefined, { share: 'a b/c' }),
|
|
59
|
+
'wss://h/yjs?room=room1&share=a%20b%2Fc',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('buildWsUrl keeps a room password alongside a share token', () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', 'roompw', { share: 'tok' }),
|
|
66
|
+
'wss://h/yjs?room=room1&p=roompw&share=tok',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('buildWsUrl is byte-identical to the no-token form when share is undefined', () => {
|
|
71
|
+
// The explicit-undefined call site (e.g. attachCollab forwarding an absent
|
|
72
|
+
// opts.share) must produce exactly what the old 4-arg call produced.
|
|
73
|
+
assert.equal(
|
|
74
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, undefined),
|
|
75
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write'),
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', 'pw', undefined),
|
|
79
|
+
buildWsUrl('wss://h/yjs', 'room1', 'view', 'pw'),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('buildWsUrl ignores an empty share token (falls back to role=)', () => {
|
|
84
|
+
// A defensive case: `{ share: '' }` is not a real token, so we must not
|
|
85
|
+
// emit `share=` with an empty value and drop the role.
|
|
86
|
+
assert.equal(
|
|
87
|
+
buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, { share: '' }),
|
|
88
|
+
'wss://h/yjs?room=room1&role=write',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -23,6 +23,7 @@ import { createRoot } from 'react-dom/client';
|
|
|
23
23
|
import { EmbedTransport } from '../embed/EmbedTransport';
|
|
24
24
|
import type { CasualApp } from '../embed/protocol';
|
|
25
25
|
import { CasualSheets } from '../sheets/CasualSheets';
|
|
26
|
+
import { applyReadOnly, getEditable } from '../sheets/read-only';
|
|
26
27
|
import { xlsxToWorkbookData } from '../xlsx';
|
|
27
28
|
import { EMBED_LOCALES } from './locale';
|
|
28
29
|
import type { IWorkbookData } from '@univerjs/core';
|
|
@@ -247,6 +248,41 @@ function EmbeddedSheets({
|
|
|
247
248
|
onSave={(snapshot) => transport.sendSaveNotify({ snapshot, reason: 'shortcut' })}
|
|
248
249
|
onExit={(snapshot) => transport.sendExit({ snapshot })}
|
|
249
250
|
onReady={(api) => {
|
|
251
|
+
// Expose the imperative API on the iframe window so hosts can
|
|
252
|
+
// introspect/debug the embedded editor (and e2e can drive it). Mirrors
|
|
253
|
+
// the React component's `window.__univerAPI` seam.
|
|
254
|
+
(globalThis as unknown as { __casualEmbedApi?: unknown }).__casualEmbedApi = api;
|
|
255
|
+
// Debug seam: report the live workbook-editable permission so hosts/e2e
|
|
256
|
+
// can confirm preview is genuinely read-only (not just chromeless).
|
|
257
|
+
(
|
|
258
|
+
globalThis as unknown as { __casualEmbedEditable?: () => boolean | undefined }
|
|
259
|
+
).__casualEmbedEditable = () => {
|
|
260
|
+
const uid = api.getSnapshot()?.id;
|
|
261
|
+
return uid ? getEditable(api.univer, uid) : undefined;
|
|
262
|
+
};
|
|
263
|
+
// Preview = genuinely READ-ONLY. Hiding the chrome (above) isn't
|
|
264
|
+
// enough — Univer's cell editor still opens on dbl-click / F2. Flip the
|
|
265
|
+
// workbook's edit permission off so preview can't be typed into. The
|
|
266
|
+
// mount remounts on viewMode change (key={viewMode}), so editor mode
|
|
267
|
+
// simply never applies this.
|
|
268
|
+
//
|
|
269
|
+
// Defer to a rAF: applied synchronously in onReady the flag loses a
|
|
270
|
+
// race — sheets-ui initialises WorkbookEditablePermission to `true`
|
|
271
|
+
// during unit setup AFTER onReady fires, clobbering our `false` (a
|
|
272
|
+
// behavioural embed test caught preview still accepting keystrokes).
|
|
273
|
+
// The app's collab view-only path waits a rAF for the same reason
|
|
274
|
+
// (CollabDriver.tsx), so the permission point exists and we
|
|
275
|
+
// updatePermissionPoint(false) over the settled default.
|
|
276
|
+
if (viewMode === 'preview') {
|
|
277
|
+
requestAnimationFrame(() => {
|
|
278
|
+
const unitId = api.getSnapshot()?.id;
|
|
279
|
+
if (unitId)
|
|
280
|
+
applyReadOnly(api.univer, unitId, () => {
|
|
281
|
+
const g = globalThis as unknown as { __casualEmbedBlocked?: number };
|
|
282
|
+
g.__casualEmbedBlocked = (g.__casualEmbedBlocked ?? 0) + 1;
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
250
286
|
// Wire host → editor command.execute (Drive's custom toolbar
|
|
251
287
|
// calls this for bold / italic / undo / …). Maps the small
|
|
252
288
|
// protocol union to the Univer command id the FUniver facade
|
|
@@ -44,8 +44,9 @@ export interface CasualSheetsIframeRef {
|
|
|
44
44
|
setViewMode(mode: 'preview' | 'editor'): void;
|
|
45
45
|
iframe(): HTMLIFrameElement | null;
|
|
46
46
|
/** Dispatch a formatting / navigation command (bold, italic, undo, …)
|
|
47
|
-
* against the iframe's active selection.
|
|
48
|
-
|
|
47
|
+
* against the iframe's active selection. `args` carries command payloads
|
|
48
|
+
* (e.g. font family/size, colour) — forwarded over the protocol. v0.6+. */
|
|
49
|
+
executeCommand(command: CommandExecuteData['command'], args?: CommandExecuteData['args']): void;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface CasualSheetsIframeProps {
|
|
@@ -194,7 +195,8 @@ export const CasualSheetsIframe = forwardRef<CasualSheetsIframeRef, CasualSheets
|
|
|
194
195
|
apiRef.current = {
|
|
195
196
|
setViewMode: (mode) => transportRef.current?.sendSetViewMode({ viewMode: mode }),
|
|
196
197
|
iframe: () => iframeRef.current,
|
|
197
|
-
executeCommand: (command) =>
|
|
198
|
+
executeCommand: (command, args) =>
|
|
199
|
+
transportRef.current?.sendCommandExecute({ command, args }),
|
|
198
200
|
};
|
|
199
201
|
}
|
|
200
202
|
|
package/src/sheets/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { CasualSheets, type CasualSheetsProps } from './CasualSheets';
|
|
5
5
|
export { createCasualSheetsAPI, type CasualSheetsAPI, type RangeRef } from './api';
|
|
6
|
+
export { applyReadOnly, getEditable } from './read-only';
|
|
6
7
|
export {
|
|
7
8
|
CasualSheetsIframe,
|
|
8
9
|
type CasualSheetsIframeProps,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { CustomCommandExecutionError, ICommandService, IPermissionService } from '@univerjs/core';
|
|
2
|
+
import type { FUniver } from '@univerjs/core/facade';
|
|
3
|
+
import { WorkbookEditablePermission } from '@univerjs/sheets';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Command ids that MUTATE a sheet — opening the cell editor, writing values,
|
|
7
|
+
* styling, structural edits, clipboard paste. The read-only veto cancels any
|
|
8
|
+
* command whose id matches. Navigation (selection, scroll, zoom, sheet switch),
|
|
9
|
+
* copy, and undo/redo deliberately fall through so preview stays usable.
|
|
10
|
+
*
|
|
11
|
+
* `set-cell-edit-visible` / `set-activate-cell-edit` are the editor-open
|
|
12
|
+
* operations — blocking them is what actually stops keyboard typing, since the
|
|
13
|
+
* cell editor never opens. The rest stop programmatic / paste / menu mutations.
|
|
14
|
+
*/
|
|
15
|
+
const READONLY_BLOCK =
|
|
16
|
+
/(set-cell-edit-visible|set-activate-cell-edit|set-range-values|set-style|set-bold|set-italic|set-underline|set-strike|set-font|set-background|set-text|set-horizontal|set-vertical|set-wrap|set-rotation|set-border|set-number-format|insert-|delete-|remove-|clear-selection|cut-content|paste|move-range|move-rows|move-cols|merge|split|add-worksheet|set-worksheet-name|set-worksheet-row|set-worksheet-col|auto-fill|reorder|set-defined-name|set-tab-color|set-frozen-cancel)/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Make a workbook genuinely READ-ONLY.
|
|
20
|
+
*
|
|
21
|
+
* Two layers, because they cover different host setups:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Command veto** (`beforeCommandExecuted` → throw
|
|
24
|
+
* `CustomCommandExecutionError`) — cancels every mutating command. This is
|
|
25
|
+
* the load-bearing layer for the iframe embed, whose minimal plugin set does
|
|
26
|
+
* NOT enforce `WorkbookEditablePermission` (verified: the editor still
|
|
27
|
+
* accepts edits with the permission flipped off).
|
|
28
|
+
* 2. **Permission flip** (`WorkbookEditablePermission` → false) — on the full
|
|
29
|
+
* `<CasualSheets>` host path this also greys out mutating menu items and
|
|
30
|
+
* stops the editor opening; harmless where unenforced.
|
|
31
|
+
*
|
|
32
|
+
* Returns a disposer that removes the veto and restores the prior editable
|
|
33
|
+
* state (for callers that toggle a live unit between preview/editor).
|
|
34
|
+
*/
|
|
35
|
+
export function applyReadOnly(
|
|
36
|
+
univerApi: FUniver,
|
|
37
|
+
unitId: string,
|
|
38
|
+
onBlock?: (commandId: string) => void,
|
|
39
|
+
): () => void {
|
|
40
|
+
const injector = (univerApi as unknown as { _injector?: { get(t: unknown): unknown } })._injector;
|
|
41
|
+
|
|
42
|
+
// Layer 1: veto mutating commands — the only layer the minimal embed enforces.
|
|
43
|
+
const cmd = injector?.get(ICommandService) as
|
|
44
|
+
| { beforeCommandExecuted(l: (info: { id: string }) => void): { dispose(): void } }
|
|
45
|
+
| undefined;
|
|
46
|
+
const vetoDisposable = cmd?.beforeCommandExecuted((info) => {
|
|
47
|
+
if (READONLY_BLOCK.test(info.id)) {
|
|
48
|
+
onBlock?.(info.id);
|
|
49
|
+
throw new CustomCommandExecutionError(`read-only: blocked ${info.id}`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Layer 2: permission flip (best-effort; load-bearing only on full hosts).
|
|
54
|
+
const svc = injector?.get(IPermissionService) as
|
|
55
|
+
| {
|
|
56
|
+
addPermissionPoint(p: unknown): boolean;
|
|
57
|
+
updatePermissionPoint(id: string, value: unknown): void;
|
|
58
|
+
getPermissionPoint(id: string): { value: unknown } | undefined;
|
|
59
|
+
}
|
|
60
|
+
| undefined;
|
|
61
|
+
|
|
62
|
+
// Constructing the point yields the deterministic id Univer derives for the
|
|
63
|
+
// workbook-edit permission; we don't keep the instance otherwise.
|
|
64
|
+
const id = new WorkbookEditablePermission(unitId).id;
|
|
65
|
+
let prev: unknown;
|
|
66
|
+
if (svc) {
|
|
67
|
+
try {
|
|
68
|
+
const existing = svc.getPermissionPoint(id);
|
|
69
|
+
if (existing) {
|
|
70
|
+
prev = existing.value;
|
|
71
|
+
svc.updatePermissionPoint(id, false);
|
|
72
|
+
} else {
|
|
73
|
+
const point = new WorkbookEditablePermission(unitId);
|
|
74
|
+
point.value = false;
|
|
75
|
+
svc.addPermissionPoint(point);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
/* best-effort — the veto above is the load-bearing layer */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
vetoDisposable?.dispose();
|
|
84
|
+
try {
|
|
85
|
+
svc?.updatePermissionPoint(id, prev === undefined ? true : prev);
|
|
86
|
+
} catch {
|
|
87
|
+
/* swallow */
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the current `WorkbookEditablePermission` value for a unit — `true`
|
|
94
|
+
* (editable), `false` (read-only), or `undefined` if the point isn't
|
|
95
|
+
* registered yet. Lets hosts/tests confirm {@link applyReadOnly} took.
|
|
96
|
+
*/
|
|
97
|
+
export function getEditable(univerApi: FUniver, unitId: string): boolean | undefined {
|
|
98
|
+
const injector = (univerApi as unknown as { _injector?: { get(t: unknown): unknown } })._injector;
|
|
99
|
+
const svc = injector?.get(IPermissionService) as
|
|
100
|
+
| { getPermissionPoint(id: string): { value: unknown } | undefined }
|
|
101
|
+
| undefined;
|
|
102
|
+
if (!svc) return undefined;
|
|
103
|
+
const id = new WorkbookEditablePermission(unitId).id;
|
|
104
|
+
const point = svc.getPermissionPoint(id);
|
|
105
|
+
return point ? (point.value as boolean) : undefined;
|
|
106
|
+
}
|