Skip to content

Commit c894eb3

Browse files
fcollonvalhbcarlos
andauthored
Add share link feature (#150)
* Add share link feature * Run pre-commit --------- Co-authored-by: Carlos Herrero <[email protected]>
1 parent 3d4a1f4 commit c894eb3

File tree

8 files changed

+298
-52
lines changed

8 files changed

+298
-52
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"title": "Shared link",
3+
"description": "Shared link settings",
4+
"jupyter.lab.toolbars": {
5+
"TopBar": [
6+
{
7+
"name": "@jupyter/collaboration:shared-link",
8+
"command": "collaboration:shared-link",
9+
"rank": 99
10+
}
11+
]
12+
},
13+
"properties": {},
14+
"additionalProperties": false,
15+
"type": "object"
16+
}

packages/collaboration-extension/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
rtcPanelPlugin,
2222
userEditorCursors
2323
} from './collaboration';
24+
import { sharedLink } from './sharedlink';
2425

2526
/**
2627
* Export the plugins as default.
@@ -35,6 +36,7 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
3536
menuBarPlugin,
3637
rtcGlobalAwarenessPlugin,
3738
rtcPanelPlugin,
39+
sharedLink,
3840
userEditorCursors
3941
];
4042

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) Jupyter Development Team.
2+
// Distributed under the terms of the Modified BSD License.
3+
4+
import {
5+
JupyterFrontEnd,
6+
JupyterFrontEndPlugin
7+
} from '@jupyterlab/application';
8+
import { Clipboard, ICommandPalette } from '@jupyterlab/apputils';
9+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
10+
import { shareIcon } from '@jupyterlab/ui-components';
11+
12+
import { showSharedLinkDialog } from '@jupyter/collaboration';
13+
14+
/**
15+
* The command IDs used by the plugin.
16+
*/
17+
namespace CommandIDs {
18+
export const share = 'collaboration:shared-link';
19+
}
20+
21+
/**
22+
* Plugin to share the URL of the running Jupyter Server
23+
*/
24+
export const sharedLink: JupyterFrontEndPlugin<void> = {
25+
id: '@jupyter/collaboration-extension:shared-link',
26+
autoStart: true,
27+
optional: [ICommandPalette, ITranslator],
28+
activate: async (
29+
app: JupyterFrontEnd,
30+
palette: ICommandPalette | null,
31+
translator: ITranslator | null
32+
) => {
33+
const { commands } = app;
34+
const trans = (translator ?? nullTranslator).load('collaboration');
35+
36+
commands.addCommand(CommandIDs.share, {
37+
label: trans.__('Generate a Shared Link'),
38+
icon: shareIcon,
39+
execute: async () => {
40+
const result = await showSharedLinkDialog({
41+
translator
42+
});
43+
if (result.button.accept && result.value) {
44+
Clipboard.copyToSystem(result.value);
45+
}
46+
}
47+
});
48+
49+
if (palette) {
50+
palette.addItem({
51+
command: CommandIDs.share,
52+
category: trans.__('Server')
53+
});
54+
}
55+
}
56+
};

packages/collaboration/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
*/
77

88
export * from './tokens';
9+
export * from './collaboratorspanel';
910
export * from './cursors';
1011
export * from './menu';
12+
export * from './sharedlink';
1113
export * from './userinfopanel';
12-
export * from './collaboratorspanel';
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) Jupyter Development Team.
2+
// Distributed under the terms of the Modified BSD License.
3+
4+
import { Dialog, showDialog } from '@jupyterlab/apputils';
5+
6+
import { PageConfig, URLExt } from '@jupyterlab/coreutils';
7+
8+
import {
9+
ITranslator,
10+
TranslationBundle,
11+
nullTranslator
12+
} from '@jupyterlab/translation';
13+
14+
import { Widget } from '@lumino/widgets';
15+
16+
import { Message } from '@lumino/messaging';
17+
18+
/**
19+
* Shared link dialog options
20+
*/
21+
export interface ISharedLinkDialogOptions {
22+
/**
23+
* Translation object.
24+
*/
25+
translator?: ITranslator | null;
26+
}
27+
28+
/**
29+
* Show the shared link dialog
30+
*
31+
* @param options Shared link dialog options
32+
* @returns Dialog result
33+
*/
34+
export async function showSharedLinkDialog({
35+
translator
36+
}: ISharedLinkDialogOptions): Promise<Dialog.IResult<string>> {
37+
const trans = (translator ?? nullTranslator).load('collaboration');
38+
39+
const token = PageConfig.getToken();
40+
const url = new URL(
41+
URLExt.normalize(
42+
PageConfig.getUrl({
43+
workspace: PageConfig.defaultWorkspace
44+
})
45+
)
46+
);
47+
48+
return showDialog({
49+
title: trans.__('Share Jupyter Server Link'),
50+
body: new SharedLinkBody(
51+
url.toString(),
52+
token,
53+
PageConfig.getOption('hubUser') !== '',
54+
trans
55+
),
56+
buttons: [
57+
Dialog.cancelButton(),
58+
Dialog.okButton({
59+
label: trans.__('Copy Link'),
60+
caption: trans.__('Copy the link to the Jupyter Server')
61+
})
62+
]
63+
});
64+
}
65+
66+
class SharedLinkBody extends Widget implements Dialog.IBodyWidget {
67+
private _tokenCheckbox: HTMLInputElement | null = null;
68+
private _warning: HTMLDivElement;
69+
70+
constructor(
71+
private _url: string,
72+
private _token: string,
73+
private _behindHub: boolean,
74+
private _trans: TranslationBundle
75+
) {
76+
super();
77+
this._warning = document.createElement('div');
78+
this.populateBody(this.node);
79+
this.addClass('jp-shared-link-body');
80+
}
81+
82+
/**
83+
* Returns the input value.
84+
*/
85+
getValue(): string {
86+
const withToken = this._tokenCheckbox?.checked === true;
87+
88+
if (withToken) {
89+
const url_ = new URL(this._url);
90+
url_.searchParams.set('token', this._token);
91+
return url_.toString();
92+
} else {
93+
return this._url;
94+
}
95+
}
96+
97+
protected onAfterAttach(msg: Message): void {
98+
super.onAfterAttach(msg);
99+
this._tokenCheckbox?.addEventListener('change', this.onTokenChange);
100+
}
101+
102+
protected onBeforeDetach(msg: Message): void {
103+
this._tokenCheckbox?.removeEventListener('change', this.onTokenChange);
104+
super.onBeforeDetach(msg);
105+
}
106+
107+
private updateContent(withToken: boolean): void {
108+
this._warning.innerHTML = '';
109+
const urlInput =
110+
this.node.querySelector<HTMLInputElement>('input[readonly]');
111+
if (withToken) {
112+
if (urlInput) {
113+
const url_ = new URL(this._url);
114+
url_.searchParams.set('token', this._token.slice(0, 5));
115+
urlInput.value = url_.toString() + '…';
116+
}
117+
this._warning.appendChild(document.createElement('h3')).textContent =
118+
this._trans.__('Security warning!');
119+
this._warning.insertAdjacentText(
120+
'beforeend',
121+
this._trans.__(
122+
'Anyone with this link has full access to your notebook server, including all your files!'
123+
)
124+
);
125+
this._warning.insertAdjacentHTML('beforeend', '<br>');
126+
this._warning.insertAdjacentText(
127+
'beforeend',
128+
this._trans.__('Please be careful who you share it with.')
129+
);
130+
this._warning.insertAdjacentHTML('beforeend', '<br>');
131+
if (this._behindHub) {
132+
this._warning.insertAdjacentText(
133+
'beforeend', // You can restart the server to revoke the token in a JupyterHub
134+
this._trans.__('They will be able to access this server AS YOU.')
135+
);
136+
this._warning.insertAdjacentHTML('beforeend', '<br>');
137+
this._warning.insertAdjacentText(
138+
'beforeend',
139+
this._trans.__(
140+
'To revoke access, go to File -> Hub Control Panel, and restart your server.'
141+
)
142+
);
143+
} else {
144+
this._warning.insertAdjacentText(
145+
'beforeend',
146+
// Elsewhere, you *must* shut down your server - no way to revoke it
147+
this._trans.__(
148+
'Currently, there is no way to revoke access other than shutting down your server.'
149+
)
150+
);
151+
}
152+
} else {
153+
if (urlInput) {
154+
urlInput.value = this._url;
155+
}
156+
if (this._behindHub) {
157+
this._warning.insertAdjacentText(
158+
'beforeend',
159+
this._trans.__(
160+
'Only users with `access:servers` permissions for this server will be able to use this link.'
161+
)
162+
);
163+
} else {
164+
this._warning.insertAdjacentText(
165+
'beforeend',
166+
this._trans.__(
167+
'Only authenticated users will be able to use this link.'
168+
)
169+
);
170+
}
171+
}
172+
}
173+
174+
private onTokenChange = (e: Event) => {
175+
const target = e.target as HTMLInputElement;
176+
this.updateContent(target?.checked);
177+
};
178+
179+
private populateBody(dialogBody: HTMLElement): void {
180+
dialogBody.insertAdjacentHTML(
181+
'afterbegin',
182+
`<input readonly value="${this._url}">`
183+
);
184+
185+
if (this._token) {
186+
const label = dialogBody.appendChild(document.createElement('label'));
187+
label.insertAdjacentHTML('beforeend', '<input type="checkbox">');
188+
this._tokenCheckbox = label.firstChild as HTMLInputElement;
189+
label.insertAdjacentText(
190+
'beforeend',
191+
this._trans.__('Include token in URL')
192+
);
193+
dialogBody.insertAdjacentElement('beforeend', this._warning);
194+
this.updateContent(false);
195+
}
196+
}
197+
}

packages/collaboration/src/utils.ts

Lines changed: 0 additions & 43 deletions
This file was deleted.

packages/collaboration/style/base.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@
55

66
@import url('./menu.css');
77
@import url('./sidepanel.css');
8+
9+
.jp-shared-link-body {
10+
user-select: none;
11+
}

packages/docprovider/src/requests.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,37 @@ export async function requestDocSession(
3939
type: string,
4040
path: string
4141
): Promise<ISessionModel> {
42-
const { makeSettings, makeRequest, ResponseError } = ServerConnection;
43-
44-
const settings = makeSettings();
42+
const settings = ServerConnection.makeSettings();
4543
const url = URLExt.join(
4644
settings.baseUrl,
4745
DOC_SESSION_URL,
4846
encodeURIComponent(path)
4947
);
50-
const data = {
48+
const body = {
5149
method: 'PUT',
5250
body: JSON.stringify({ format, type })
5351
};
5452

55-
const response = await makeRequest(url, data, settings);
53+
let response: Response;
54+
try {
55+
response = await ServerConnection.makeRequest(url, body, settings);
56+
} catch (error) {
57+
throw new ServerConnection.NetworkError(error as Error);
58+
}
59+
60+
let data: any = await response.text();
61+
62+
if (data.length > 0) {
63+
try {
64+
data = JSON.parse(data);
65+
} catch (error) {
66+
console.log('Not a JSON response body.', response);
67+
}
68+
}
5669

57-
if (response.status !== 200 && response.status !== 201) {
58-
throw new ResponseError(response);
70+
if (!response.ok) {
71+
throw new ServerConnection.ResponseError(response, data.message || data);
5972
}
6073

61-
return response.json();
74+
return data;
6275
}

0 commit comments

Comments
 (0)