Skip to content

Commit 11a8dc6

Browse files
committed
feat: add sharing link functionality for drive files and update Nautilus extension
1 parent e65f8e0 commit 11a8dc6

24 files changed

Lines changed: 495 additions & 18 deletions

File tree

assets/python-nautilus/internxt-virtual-drive.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,13 @@ def _create_menu_items(self, files, group):
162162

163163
active_items.append(clear)
164164

165-
if len(remote_files) > 0:
166-
download = Nautilus.MenuItem(
167-
name="InternxtVirtualDrive::DOWNLOAD" + group,
168-
label="Make Available Offline",
165+
if len(files) == 1 and len(remote_files) == 1:
166+
copy_link = Nautilus.MenuItem(
167+
name="InternxtVirtualDrive::COPY_LINK" + group,
168+
label="Copy Internxt Link",
169169
)
170-
download.connect("activate", self._make_locally_available, remote_files)
171-
active_items.append(download)
170+
copy_link.connect("activate", self._copy_internxt_link, remote_files)
171+
active_items.append(copy_link)
172172

173173
return active_items
174174

@@ -199,6 +199,31 @@ def _make_locally_available(self, menu, files):
199199
# if (response.status_code == 202):
200200
# self._setItemStatus(file, 'on_local')
201201

202+
def _copy_internxt_link(self, menu, files):
203+
base64_encoded = self._encode_file_path(files[0])
204+
205+
url = base_url + 'copy-link/' + base64_encoded
206+
207+
try:
208+
response = requests.post(url)
209+
210+
if response.status_code != 202:
211+
print(response.status_code)
212+
print(response.text)
213+
return
214+
215+
data = response.json()
216+
link = data.get('link')
217+
218+
if not link:
219+
print('Copy link failed: missing link in response')
220+
return
221+
222+
self.display.get_clipboard().set(link)
223+
print('link copied')
224+
except Exception as error:
225+
print('Copy link failed:', error)
226+
202227
def _make_remote_only(self, menu, files):
203228
for file in files:
204229
self._setItemStatus(file, 'removing')

src/__mocks__/electron.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,8 @@ const shell = {
5151
openExternal: vi.fn(),
5252
};
5353

54-
export { app, ipcMain, ipcRenderer, dialog, BrowserWindow, safeStorage, nativeImage, shell };
54+
const clipboard = {
55+
writeText: vi.fn(),
56+
};
57+
58+
export { app, ipcMain, ipcRenderer, dialog, BrowserWindow, safeStorage, nativeImage, shell, clipboard };

src/apps/drive/hydration-api/controllers/contents.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { StorageFileIsAvailableOffline } from '../../../../context/storage/Stora
88
import { Optional } from '../../../../shared/types/Optional';
99
import { MakeFolderAvaliableOffline } from '../../../../context/storage/StorageFolders/application/offline/MakeFolderAvaliableOffline';
1010
import { StorageFolderDeleter } from '../../../../context/storage/StorageFolders/application/delete/StorageFolderDeleter';
11+
import { generateLink } from '../../../main/nautilus-extension/create-sharing-link/generate-link';
1112

1213
export function buildContentsController(container: Container) {
1314
async function isFileLocallyAvailable(path: string): Promise<Optional<boolean>> {
@@ -141,7 +142,23 @@ export function buildContentsController(container: Container) {
141142
}
142143
};
143144

145+
const copyLink = async (req: Request, res: Response, next: NextFunction) => {
146+
try {
147+
const decodedBuffer = Buffer.from(req.params.path, 'base64');
148+
149+
const path = decodedBuffer.toString('utf-8').replaceAll('%20', ' ');
150+
151+
const link = await generateLink({ path });
152+
153+
res.status(202).json({ path, link });
154+
} catch (error) {
155+
next(error);
156+
return;
157+
}
158+
};
159+
144160
return {
161+
copyLink,
145162
downloadFile,
146163
removeFile,
147164
get,

src/apps/drive/hydration-api/routes/contents.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@ export function buildHydrationRouter(container: Container): Router {
1818
router.delete('/files/:path', controllers.removeFile);
1919
router.post('/files/:path', controllers.downloadFile);
2020

21+
// link
22+
router.post('/copy-link/:path', controllers.copyLink);
23+
2124
return router;
2225
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { createSharing } from '../../../../infra/drive-server/services/sharings/services/create-sharing';
2+
import { CreateSharingPayload, ShareableItem, SharingResponse } from './types';
3+
import { toError } from './to-error';
4+
5+
type Props = {
6+
encryptedCode: string;
7+
encryptionKey: string;
8+
item: ShareableItem;
9+
};
10+
11+
export async function createSharingResult({ encryptedCode, encryptionKey, item }: Props) {
12+
const payload: CreateSharingPayload = {
13+
encryptedCode,
14+
encryptedPassword: null,
15+
encryptionAlgorithm: 'inxt-v2',
16+
encryptionKey,
17+
itemId: item.itemId,
18+
itemType: item.itemType,
19+
persistPreviousSharing: true,
20+
};
21+
22+
const result = await createSharing({ body: payload });
23+
24+
if (result.error) {
25+
throw toError({
26+
context: 'Error while creating sharing',
27+
error: result.error,
28+
});
29+
}
30+
31+
return result.data as SharingResponse;
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { fetchPublicSharingDomains } from '../../../../infra/drive-server/services/sharings/services/fetch-public-sharing-domains';
2+
import { ShareDomainsResponse } from './types';
3+
import { toError } from './to-error';
4+
5+
export async function fetchRandomDomain() {
6+
const result = await fetchPublicSharingDomains();
7+
8+
if (result.error) {
9+
throw toError({
10+
context: 'Error while fetching public sharing domains',
11+
error: result.error,
12+
});
13+
}
14+
15+
const domains = getDomains(result.data);
16+
17+
if (domains.length === 0) {
18+
throw new Error('No share domains available');
19+
}
20+
21+
const randomIndex = Math.floor(Math.random() * domains.length);
22+
23+
return domains[randomIndex].replace(/\/$/, '');
24+
}
25+
26+
function getDomains(data: ShareDomainsResponse | string[]) {
27+
if (Array.isArray(data)) {
28+
return data;
29+
}
30+
31+
return data.list;
32+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { aes, stringUtils } from '@internxt/lib';
2+
import { logger } from '@internxt/drive-desktop-core/build/backend';
3+
import { validateMnemonic } from 'bip39';
4+
import { clipboard } from 'electron';
5+
import { DriveServerError } from '../../../../infra/drive-server/drive-server.error';
6+
import * as fetchFileMetaByPathModule from '../../../../infra/drive-server/services/files/services/fetch-file-meta-by-path';
7+
import * as fetchFolderMetaByPathModule from '../../../../infra/drive-server/services/folder/services/fetch-folder-meta-by-path';
8+
import * as createSharingModule from '../../../../infra/drive-server/services/sharings/services/create-sharing';
9+
import * as fetchPublicSharingDomainsModule from '../../../../infra/drive-server/services/sharings/services/fetch-public-sharing-domains';
10+
import * as getCredentialsModule from '../../auth/get-credentials';
11+
import { generateLink } from './generate-link';
12+
import { call, calls, partialSpyOn } from 'tests/vitest/utils.helper';
13+
14+
vi.mock('bip39', () => ({
15+
validateMnemonic: vi.fn(),
16+
}));
17+
18+
describe('generate-link', () => {
19+
const getCredentialsMock = partialSpyOn(getCredentialsModule, 'getCredentials');
20+
const fetchFileMetaByPathMock = partialSpyOn(fetchFileMetaByPathModule, 'fetchFileMetaByPath');
21+
const fetchFolderMetaByPathMock = partialSpyOn(fetchFolderMetaByPathModule, 'fetchFolderMetaByPath');
22+
const fetchPublicSharingDomainsMock = partialSpyOn(fetchPublicSharingDomainsModule, 'fetchPublicSharingDomains');
23+
const createSharingMock = partialSpyOn(createSharingModule, 'createSharing');
24+
const loggerDebugMock = partialSpyOn(logger, 'debug');
25+
const clipboardWriteTextMock = partialSpyOn(clipboard, 'writeText');
26+
27+
const validateMnemonicMock = vi.mocked(validateMnemonic);
28+
const encryptMock = partialSpyOn(aes, 'encrypt');
29+
const decryptMock = partialSpyOn(aes, 'decrypt');
30+
const generateRandomStringUrlSafeMock = partialSpyOn(stringUtils, 'generateRandomStringUrlSafe');
31+
const encodeV4UuidMock = partialSpyOn(stringUtils, 'encodeV4Uuid');
32+
33+
beforeEach(() => {
34+
getCredentialsMock.mockReturnValue({ newToken: 'token', mnemonic: 'valid mnemonic' });
35+
validateMnemonicMock.mockReturnValue(true);
36+
generateRandomStringUrlSafeMock.mockReturnValue('plain-code');
37+
encryptMock
38+
.mockReturnValueOnce('encrypted-mnemonic')
39+
.mockReturnValueOnce('encrypted-code');
40+
decryptMock.mockReturnValue('recovered-code');
41+
encodeV4UuidMock.mockReturnValue('encoded-sharing-id');
42+
});
43+
44+
it('should create a public sharing link for a file path and copy it to clipboard', async () => {
45+
fetchFileMetaByPathMock.mockResolvedValueOnce({ data: { uuid: 'file-uuid' } } as object);
46+
fetchPublicSharingDomainsMock.mockResolvedValueOnce({ data: { list: ['https://share.internxt.test'] } } as object);
47+
createSharingMock.mockResolvedValueOnce({
48+
data: {
49+
encryptedCode: 'server-encrypted-code',
50+
id: '08dfcc54-16d6-4f9d-9124-8a36b12e07dd',
51+
},
52+
} as object);
53+
54+
const result = await generateLink({ path: '/folder/file.txt' });
55+
56+
expect(result).toBe('https://share.internxt.test/sh/file/encoded-sharing-id/recovered-code');
57+
call(fetchFileMetaByPathMock).toStrictEqual({ path: '/folder/file.txt' });
58+
call(fetchPublicSharingDomainsMock).toStrictEqual([]);
59+
call(createSharingMock).toStrictEqual({
60+
body: {
61+
encryptedCode: 'encrypted-code',
62+
encryptedPassword: null,
63+
encryptionAlgorithm: 'inxt-v2',
64+
encryptionKey: 'encrypted-mnemonic',
65+
itemId: 'file-uuid',
66+
itemType: 'file',
67+
persistPreviousSharing: true,
68+
},
69+
});
70+
call(clipboardWriteTextMock).toBe('https://share.internxt.test/sh/file/encoded-sharing-id/recovered-code');
71+
call(loggerDebugMock).toMatchObject({
72+
msg: 'link copied',
73+
itemType: 'file',
74+
path: '/folder/file.txt',
75+
});
76+
});
77+
78+
it('should fallback to folder metadata when file metadata is not found', async () => {
79+
fetchFileMetaByPathMock.mockResolvedValueOnce({ error: new DriveServerError('NOT_FOUND', 404) } as object);
80+
fetchFolderMetaByPathMock.mockResolvedValueOnce({ data: { uuid: 'folder-uuid' } } as object);
81+
fetchPublicSharingDomainsMock.mockResolvedValueOnce({ data: ['https://share.internxt.test'] } as object);
82+
createSharingMock.mockResolvedValueOnce({
83+
data: {
84+
encryptedCode: 'server-encrypted-code',
85+
id: '08dfcc54-16d6-4f9d-9124-8a36b12e07dd',
86+
},
87+
} as object);
88+
89+
const result = await generateLink({ path: '/folder' });
90+
91+
expect(result).toBe('https://share.internxt.test/sh/folder/encoded-sharing-id/recovered-code');
92+
call(fetchFileMetaByPathMock).toStrictEqual({ path: '/folder' });
93+
call(fetchFolderMetaByPathMock).toStrictEqual({ path: '/folder' });
94+
});
95+
96+
it('should fail before any network call when mnemonic is invalid', async () => {
97+
validateMnemonicMock.mockReturnValue(false);
98+
99+
await expect(generateLink({ path: '/folder/file.txt' })).rejects.toThrow('The user mnemonic is invalid');
100+
101+
calls(fetchFileMetaByPathMock).toHaveLength(0);
102+
calls(fetchFolderMetaByPathMock).toHaveLength(0);
103+
calls(fetchPublicSharingDomainsMock).toHaveLength(0);
104+
calls(createSharingMock).toHaveLength(0);
105+
calls(clipboardWriteTextMock).toHaveLength(0);
106+
});
107+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { aes, stringUtils } from '@internxt/lib';
2+
import { logger } from '@internxt/drive-desktop-core/build/backend';
3+
import { validateMnemonic } from 'bip39';
4+
import { clipboard } from 'electron';
5+
import { getCredentials } from '../../auth/get-credentials';
6+
import { createSharingResult } from './create-sharing-result';
7+
import { fetchRandomDomain } from './fetch-random-domain';
8+
import { resolveShareableItem } from './resolve-shareable-item';
9+
10+
export type Props = { path: string };
11+
12+
export async function generateLink({ path }: Props) {
13+
const { mnemonic } = getCredentials();
14+
15+
if (!validateMnemonic(mnemonic)) {
16+
throw new Error('The user mnemonic is invalid');
17+
}
18+
19+
const item = await resolveShareableItem({ path });
20+
const domain = await fetchRandomDomain();
21+
const plainCode = stringUtils.generateRandomStringUrlSafe(8);
22+
const encryptionKey = aes.encrypt(mnemonic, plainCode);
23+
const encryptedCode = aes.encrypt(plainCode, mnemonic);
24+
const sharing = await createSharingResult({
25+
encryptedCode,
26+
encryptionKey,
27+
item,
28+
});
29+
const recoveredCode = aes.decrypt(sharing.encryptedCode, mnemonic);
30+
const sharingId = stringUtils.encodeV4Uuid(sharing.id);
31+
const shareLink = `${domain}/sh/${item.itemType}/${sharingId}/${recoveredCode}`;
32+
33+
clipboard.writeText(shareLink);
34+
35+
logger.debug({
36+
msg: 'link copied',
37+
itemType: item.itemType,
38+
path,
39+
});
40+
41+
return shareLink;
42+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { fetchFileMetaByPath } from '../../../../infra/drive-server/services/files/services/fetch-file-meta-by-path';
2+
import { fetchFolderMetaByPath } from '../../../../infra/drive-server/services/folder/services/fetch-folder-meta-by-path';
3+
import { ShareableItem } from './types';
4+
import { toError } from './to-error';
5+
6+
type Props = {
7+
path: string;
8+
};
9+
10+
export async function resolveShareableItem({ path }: Props) {
11+
const candidatePaths = Array.from(new Set([path, path.startsWith('/') ? path.slice(1) : `/${path}`].filter(Boolean)));
12+
13+
for (const candidatePath of candidatePaths) {
14+
const fileMeta = await tryGetFileMeta({ path: candidatePath });
15+
16+
if (fileMeta) {
17+
return {
18+
itemId: fileMeta.uuid,
19+
itemType: 'file',
20+
} satisfies ShareableItem;
21+
}
22+
23+
const folderMeta = await tryGetFolderMeta({ path: candidatePath });
24+
25+
if (folderMeta) {
26+
return {
27+
itemId: folderMeta.uuid,
28+
itemType: 'folder',
29+
} satisfies ShareableItem;
30+
}
31+
}
32+
33+
throw new Error(`No Internxt item metadata found for path: ${path}`);
34+
}
35+
36+
async function tryGetFileMeta({ path }: { path: string }) {
37+
const result = await fetchFileMetaByPath({ path });
38+
39+
if (result.error) {
40+
if (result.error.cause === 'NOT_FOUND') {
41+
return null;
42+
}
43+
44+
throw toError({
45+
context: 'Error while fetching file metadata by path',
46+
error: result.error,
47+
});
48+
}
49+
50+
return result.data;
51+
}
52+
53+
async function tryGetFolderMeta({ path }: { path: string }) {
54+
const result = await fetchFolderMetaByPath({ path });
55+
56+
if (result.error) {
57+
if (result.error.cause === 'NOT_FOUND') {
58+
return null;
59+
}
60+
61+
throw toError({
62+
context: 'Error while fetching folder metadata by path',
63+
error: result.error,
64+
});
65+
}
66+
67+
return result.data;
68+
}

0 commit comments

Comments
 (0)