├── __mocks__
├── pm-srp.js
├── pmcrypto.js
├── sieve.js
├── styleMock.js
└── fileMock.js
├── po
└── lang.json
├── rtl.setup.js
├── tsconfig.json
├── src
├── app
│ ├── openpgpConfig.ts
│ ├── components
│ │ ├── SharingModal
│ │ │ ├── SharingModal.scss
│ │ │ ├── DateTime.tsx
│ │ │ ├── LoadingState.tsx
│ │ │ └── ErrorState.tsx
│ │ ├── Drive
│ │ │ ├── Trash
│ │ │ │ ├── ToolbarButtons
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── DeletePermanentlyButton.tsx
│ │ │ │ │ └── RestoreFromTrashButton.tsx
│ │ │ │ ├── TrashToolbar.tsx
│ │ │ │ ├── Trash.tsx
│ │ │ │ └── EmptyTrashButton.tsx
│ │ │ ├── ToolbarButtons
│ │ │ │ ├── index.ts
│ │ │ │ ├── CreateNewFolderButton.tsx
│ │ │ │ ├── UploadFileButton.tsx
│ │ │ │ ├── UploadFolderButton.tsx
│ │ │ │ ├── MoveToFolderButton.tsx
│ │ │ │ ├── BackButton.tsx
│ │ │ │ ├── MoveToTrashButton.tsx
│ │ │ │ └── LayoutDropdown.tsx
│ │ │ ├── UserSettings
│ │ │ │ └── UserSettingsProvider.tsx
│ │ │ ├── DriveFolderProvider.tsx
│ │ │ └── helpers.tsx
│ │ ├── layout
│ │ │ ├── DriveSidebar
│ │ │ │ ├── DriveSidebar.scss
│ │ │ │ ├── DriveSidebarFooter.tsx
│ │ │ │ ├── ReloadSpinner.tsx
│ │ │ │ ├── DriveSidebarList.tsx
│ │ │ │ ├── DriveSidebar.tsx
│ │ │ │ └── DriveSidebarListItem.tsx
│ │ │ └── DriveHeader.tsx
│ │ ├── FileBrowser
│ │ │ ├── ListView
│ │ │ │ └── Cells
│ │ │ │ │ ├── MIMETypeCell.tsx
│ │ │ │ │ ├── NameCell.tsx
│ │ │ │ │ ├── UserNameCell.tsx
│ │ │ │ │ ├── SizeCell.tsx
│ │ │ │ │ ├── DescriptiveTypeCell.tsx
│ │ │ │ │ ├── TimeCell.tsx
│ │ │ │ │ ├── ShareCell.tsx
│ │ │ │ │ └── LocationCell.tsx
│ │ │ ├── ToolbarButtons
│ │ │ │ ├── index.ts
│ │ │ │ ├── utils.ts
│ │ │ │ ├── ShareFileButton.tsx
│ │ │ │ ├── DownloadButton.tsx
│ │ │ │ ├── RenameButton.tsx
│ │ │ │ ├── ShareLinkButton.tsx
│ │ │ │ ├── DetailsButton.tsx
│ │ │ │ └── PreviewButton.tsx
│ │ │ ├── EmptyTrash.tsx
│ │ │ ├── HasNoFilesToShare.tsx
│ │ │ ├── useFileBrowserColumns.ts
│ │ │ ├── HasNoFolders.tsx
│ │ │ ├── EmptyShared.tsx
│ │ │ ├── SharedURLIcon.tsx
│ │ │ ├── FileBrowser.scss
│ │ │ ├── useFileBrowserView.tsx
│ │ │ ├── EmptyFolder.tsx
│ │ │ ├── FileBrowser.tsx
│ │ │ ├── FolderContextMenu.tsx
│ │ │ └── interfaces.tsx
│ │ ├── thumbnail
│ │ │ ├── thumbnail.test.ts
│ │ │ ├── thumbnail.ts
│ │ │ └── image.ts
│ │ ├── DowloadShared
│ │ │ ├── LinkDoesNotExistInfo.tsx
│ │ │ ├── DownloadProgressBar.tsx
│ │ │ └── EnterPasswordInfo.tsx
│ │ ├── TransferManager
│ │ │ ├── ProgressBar.tsx
│ │ │ ├── interfaces.ts
│ │ │ └── TransferManager.scss
│ │ ├── uploads
│ │ │ ├── UploadDragDrop
│ │ │ │ └── UploadDragDrop.scss
│ │ │ ├── ChunkFileReader.ts
│ │ │ └── UploadButton.tsx
│ │ ├── FolderTree
│ │ │ ├── FolderTree.scss
│ │ │ └── FolderTree.tsx
│ │ ├── SharedLinks
│ │ │ ├── ToolbarButtons
│ │ │ │ └── StopSharingButton.tsx
│ │ │ ├── ShareFileButton.tsx
│ │ │ ├── SharedLinksToolbar.tsx
│ │ │ └── SharedLinks.tsx
│ │ ├── FilesRecoveryModal
│ │ │ ├── FileRecoveryIcon.tsx
│ │ │ ├── FilesRecoveryModal.tsx
│ │ │ └── FilesRecoveryState.tsx
│ │ ├── onboarding
│ │ │ ├── DriveOnboardingModal.tsx
│ │ │ ├── DriveOnboardingModalNoAccess.tsx
│ │ │ └── DriveOnboardingModalNoBeta.tsx
│ │ ├── FilesDetailsModal.tsx
│ │ ├── AppErrorBoundary.tsx
│ │ └── DriveEventManager
│ │ │ └── DriveEventManagerProvider.tsx
│ ├── utils
│ │ ├── share.ts
│ │ ├── MimeTypeParser
│ │ │ ├── signatureChecks
│ │ │ │ ├── applicationSignatures.ts
│ │ │ │ ├── fontSignatures.ts
│ │ │ │ ├── unsafeSignatures.ts
│ │ │ │ ├── audioSignatures.ts
│ │ │ │ ├── videoSignatures.ts
│ │ │ │ └── imageSignatures.ts
│ │ │ ├── constants.ts
│ │ │ ├── MimeTypeParser.ts
│ │ │ └── helpers.ts
│ │ ├── async.ts
│ │ ├── file.ts
│ │ ├── stream.ts
│ │ ├── validation.ts
│ │ └── link.test.ts
│ ├── index.tsx
│ ├── interfaces
│ │ ├── restore.ts
│ │ ├── folder.ts
│ │ ├── userSettings.ts
│ │ ├── volume.ts
│ │ ├── share.ts
│ │ ├── transfer.ts
│ │ ├── file.ts
│ │ └── link.ts
│ ├── api
│ │ ├── userSettings.ts
│ │ ├── volume.ts
│ │ ├── folder.ts
│ │ ├── link.ts
│ │ ├── sharing.ts
│ │ ├── share.ts
│ │ └── files.ts
│ ├── app.scss
│ ├── containers
│ │ ├── OnboardingContainer.tsx
│ │ ├── NoAccessContainer
│ │ │ └── NoAccessContainer.tsx
│ │ ├── TrashContainer
│ │ │ ├── TrashContainerView.tsx
│ │ │ └── TrashContainer.tsx
│ │ ├── SharedURLsContainer
│ │ │ ├── SharedURLsContainerView.tsx
│ │ │ └── SharedURLsContainer.tsx
│ │ └── DriveContainer
│ │ │ ├── DriveContainer.tsx
│ │ │ └── DriveContainerView.tsx
│ ├── hooks
│ │ ├── util
│ │ │ ├── useDebouncedRequest.ts
│ │ │ ├── useConfirm.tsx
│ │ │ ├── useOnScrollEnd.ts
│ │ │ ├── useDebouncedRequest.test.tsx
│ │ │ └── useQueuedFunction.ts
│ │ └── drive
│ │ │ ├── useNavigate.ts
│ │ │ ├── useUserSettings.ts
│ │ │ ├── useStatsHistory.ts
│ │ │ ├── useFileUploadInput.ts
│ │ │ ├── useDriveSorting.ts
│ │ │ └── useDriveCrypto.ts
│ ├── PrivateApp.tsx
│ └── App.tsx
├── assets
│ ├── protondrive.svg
│ └── logoConfig.js
├── app.ejs
└── .htaccess
├── public
└── assets
│ └── social_logo.png
├── .gitlab-ci.yml
├── .prettierrc
├── .editorconfig
├── jest.transform.js
├── typings
└── index.d.ts
├── .gitignore
├── jest.config.js
├── .eslintrc.json
└── README.md
/__mocks__/pm-srp.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/__mocks__/pmcrypto.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/po/lang.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/rtl.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 |
--------------------------------------------------------------------------------
/__mocks__/sieve.js:
--------------------------------------------------------------------------------
1 | // __mocks__/styleMock.js
2 |
3 | module.exports = {};
4 |
--------------------------------------------------------------------------------
/__mocks__/styleMock.js:
--------------------------------------------------------------------------------
1 | // __mocks__/styleMock.js
2 |
3 | module.exports = {};
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "proton-shared/tsconfig.base.json"
3 | }
4 |
--------------------------------------------------------------------------------
/__mocks__/fileMock.js:
--------------------------------------------------------------------------------
1 | // __mocks__/fileMock.js
2 |
3 | module.exports = 'test-file-stub';
4 |
--------------------------------------------------------------------------------
/src/app/openpgpConfig.ts:
--------------------------------------------------------------------------------
1 | export const openpgpConfig = { allow_unauthenticated_stream: true };
2 |
--------------------------------------------------------------------------------
/public/assets/social_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ProtonMail/proton-drive/HEAD/public/assets/social_logo.png
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | include:
2 | - project: 'deploy-app/fe-scripts'
3 | ref: master
4 | file: '/jobs/webapp/open-source.gitlab-ci.yaml'
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "arrowParens": "always",
4 | "singleQuote": true,
5 | "tabWidth": 4,
6 | "proseWrap": "never"
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/components/SharingModal/SharingModal.scss:
--------------------------------------------------------------------------------
1 | .field.field--accented,
2 | .accented {
3 | color: var(--primary);
4 | font-weight: bold;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/components/Drive/Trash/ToolbarButtons/index.ts:
--------------------------------------------------------------------------------
1 | export { default as DeletePermanentlyButton } from './DeletePermanentlyButton';
2 | export { default as RestoreFromTrashButton } from './RestoreFromTrashButton';
3 |
--------------------------------------------------------------------------------
/src/app/utils/share.ts:
--------------------------------------------------------------------------------
1 | import { ShareFlags } from '../interfaces/share';
2 |
3 | export const isPrimaryShare = (meta: { Flags?: number }) => {
4 | return !!(typeof meta.Flags !== 'undefined' && meta.Flags & ShareFlags.PrimaryShare);
5 | };
6 |
--------------------------------------------------------------------------------
/src/app/index.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom';
2 | import React from 'react';
3 | import 'core-js/stable';
4 | import 'regenerator-runtime/runtime';
5 | import 'yetch/polyfill';
6 | import App from './App';
7 |
8 | ReactDOM.render(, document.querySelector('.app-root'));
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_size = 4
6 | indent_style = space
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/src/app/interfaces/restore.ts:
--------------------------------------------------------------------------------
1 | import { RESPONSE_CODE } from '../constants';
2 |
3 | export interface RestoreFromTrashResult {
4 | Responses: { Response: RestoreResponse }[];
5 | }
6 |
7 | export interface RestoreResponse {
8 | Code: RESPONSE_CODE;
9 | Error?: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/components/layout/DriveSidebar/DriveSidebar.scss:
--------------------------------------------------------------------------------
1 | @keyframes rotating {
2 | from {
3 | transform: rotate(0deg);
4 | }
5 | to {
6 | transform: rotate(360deg);
7 | }
8 | }
9 | .location-refresh-rotate {
10 | animation: rotating 0.5s linear infinite;
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/interfaces/folder.ts:
--------------------------------------------------------------------------------
1 | export interface CreateNewFolder {
2 | Name: string;
3 | Hash: string;
4 | ParentLinkID: string;
5 | NodePassphrase: string;
6 | NodePassphraseSignature: string;
7 | SignatureAddress: string;
8 | NodeKey: string;
9 | NodeHashKey: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/components/layout/DriveSidebar/DriveSidebarFooter.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { AppVersion } from 'react-components';
3 |
4 | import changelog from '../../../../../CHANGELOG.md';
5 |
6 | const DriveSidebarFooter = () => ;
7 |
8 | export default DriveSidebarFooter;
9 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/MIMETypeCell.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface Props {
4 | mimeType: string;
5 | }
6 |
7 | const MIMETypeCell = ({ mimeType }: Props) => (
8 |
9 | {mimeType}
10 |
11 | );
12 |
13 | export default MIMETypeCell;
14 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/NameCell.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FileNameDisplay } from 'react-components';
3 |
4 | interface Props {
5 | name: string;
6 | }
7 |
8 | const NameCell = ({ name }: Props) => (
9 |
10 |
11 |
12 | );
13 |
14 | export default NameCell;
15 |
--------------------------------------------------------------------------------
/src/app/components/thumbnail/thumbnail.test.ts:
--------------------------------------------------------------------------------
1 | import { makeThumbnail } from './thumbnail';
2 |
3 | describe('makeThumbnail', () => {
4 | it('does nothing when mime type is not supported', async () => {
5 | await expect(makeThumbnail('png', new Blob())).resolves.toEqual(undefined);
6 | await expect(makeThumbnail('image/jpeeg', new Blob())).resolves.toEqual(undefined);
7 | });
8 | });
9 |
--------------------------------------------------------------------------------
/src/assets/protondrive.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/components/thumbnail/thumbnail.ts:
--------------------------------------------------------------------------------
1 | import { isSupportedImage } from 'react-components/containers/filePreview/helpers';
2 |
3 | import { scaleImageFile } from './image';
4 |
5 | export function makeThumbnail(mimeType: string, file: Blob): Promise {
6 | if (isSupportedImage(mimeType)) {
7 | return scaleImageFile(file);
8 | }
9 | return Promise.resolve(undefined);
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/index.ts:
--------------------------------------------------------------------------------
1 | export { default as DetailsButton } from './DetailsButton';
2 | export { default as DownloadButton } from './DownloadButton';
3 | export { default as PreviewButton } from './PreviewButton';
4 | export { default as RenameButton } from './RenameButton';
5 | export { default as ShareFileButton } from './ShareFileButton';
6 | export { default as ShareLinkButton } from './ShareLinkButton';
7 |
--------------------------------------------------------------------------------
/src/app/components/layout/DriveSidebar/ReloadSpinner.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Icon, classnames } from 'react-components';
3 |
4 | interface Props {
5 | refreshing?: boolean;
6 | }
7 |
8 | const ReloadSpinner = ({ refreshing = false }: Props) => {
9 | return ;
10 | };
11 |
12 | export default ReloadSpinner;
13 |
--------------------------------------------------------------------------------
/src/app/api/userSettings.ts:
--------------------------------------------------------------------------------
1 | import { UserSettings } from '../interfaces/userSettings';
2 |
3 | export const queryUserSettings = () => {
4 | return {
5 | method: 'get',
6 | url: `drive/me/settings`,
7 | };
8 | };
9 |
10 | export const queryUpdateUserSettings = (data: Partial) => {
11 | return {
12 | method: 'put',
13 | url: `drive/me/settings`,
14 | data,
15 | };
16 | };
17 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/UserNameCell.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useUser } from 'react-components';
3 |
4 | const UserNameCell = () => {
5 | const [{ Name }] = useUser();
6 |
7 | return (
8 |
9 | {Name}
10 |
11 | );
12 | };
13 |
14 | export default UserNameCell;
15 |
--------------------------------------------------------------------------------
/src/app/interfaces/userSettings.ts:
--------------------------------------------------------------------------------
1 | export enum SortSetting {
2 | NameAsc = 1,
3 | SizeAsc = 2,
4 | TypeAsc = 3,
5 | ModifiedAsc = 4,
6 | NameDesc = -1,
7 | SizeDesc = -2,
8 | TypeDesc = -3,
9 | ModifiedDesc = -4,
10 | }
11 |
12 | export enum LayoutSetting {
13 | List = 0,
14 | Grid = 1,
15 | }
16 |
17 | export interface UserSettings {
18 | Sort: SortSetting;
19 | Layout: LayoutSetting;
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/api/volume.ts:
--------------------------------------------------------------------------------
1 | import { CreateDriveVolume, RestoreDriveVolume } from '../interfaces/volume';
2 |
3 | export const queryCreateDriveVolume = (data: CreateDriveVolume) => ({
4 | method: 'post',
5 | url: 'drive/volumes',
6 | data,
7 | });
8 |
9 | export const queryRestoreDriveVolume = (encryptedVolumeId: string, data: RestoreDriveVolume) => ({
10 | method: 'put',
11 | url: `drive/volumes/${encryptedVolumeId}/restore`,
12 | data,
13 | });
14 |
--------------------------------------------------------------------------------
/jest.transform.js:
--------------------------------------------------------------------------------
1 | // Custom Jest transform implementation that injects test-specific babel presets.
2 | module.exports = require('babel-jest').createTransformer({
3 | presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
4 | plugins: [
5 | '@babel/plugin-proposal-object-rest-spread',
6 | '@babel/plugin-transform-runtime',
7 | 'transform-class-properties',
8 | 'transform-require-context'
9 | ]
10 | });
11 |
--------------------------------------------------------------------------------
/src/assets/logoConfig.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | logo: 'src/assets/protondrive.svg',
3 | favicons: {
4 | appName: 'ProtonDrive',
5 | appDescription:
6 | 'ProtonDrive allows you to securely store and share your sensitive documents and access them anywhere.',
7 | developerName: 'Proton Technologies AG',
8 | developerURL: 'https://github.com/ProtonMail/proton-drive',
9 | background: '#1c223d',
10 | theme_color: '#1c223d',
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/SizeCell.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import humanSize from 'proton-shared/lib/helpers/humanSize';
3 |
4 | interface Props {
5 | size: number;
6 | }
7 |
8 | const SizeCell = ({ size }: Props) => {
9 | const readableSize = humanSize(size);
10 | return (
11 |
12 | {readableSize}
13 |
14 | );
15 | };
16 |
17 | export default SizeCell;
18 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/EmptyTrash.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { IllustrationPlaceholder } from 'react-components';
5 |
6 | import noContentSvg from 'design-system/assets/img/placeholders/empty-trash.svg';
7 |
8 | const EmptyTrash = () => (
9 |
10 |
11 |
12 | );
13 |
14 | export default EmptyTrash;
15 |
--------------------------------------------------------------------------------
/src/app/app.scss:
--------------------------------------------------------------------------------
1 | @import '~design-system/scss/proton-drive';
2 | @import '~design-system/scss/specifics/placeholder-loading';
3 | @import '~design-system/scss/specifics/list';
4 |
5 | @import './components/FileBrowser/FileBrowser.scss';
6 | @import './components/TransferManager/TransferManager.scss';
7 | @import './components/uploads/UploadDragDrop/UploadDragDrop.scss';
8 | @import './components/FolderTree/FolderTree.scss';
9 | @import './components/SharingModal/SharingModal.scss';
10 | @import './components/layout/DriveSidebar/DriveSidebar.scss';
11 |
--------------------------------------------------------------------------------
/src/app/components/SharingModal/DateTime.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { dateLocale } from 'proton-shared/lib/i18n';
3 |
4 | import { fromUnixTime, format as formatDate } from 'date-fns';
5 |
6 | interface Props extends React.HTMLAttributes {
7 | value: number;
8 | format?: string;
9 | }
10 |
11 | const DateTime = ({ value, format = 'PPp', ...rest }: Props) => {
12 | return ;
13 | };
14 |
15 | export default DateTime;
16 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/utils.ts:
--------------------------------------------------------------------------------
1 | import { LinkType } from '../../../interfaces/link';
2 | import { FileBrowserItem } from '../interfaces';
3 |
4 | export function noSelection(selectedItems: FileBrowserItem[]): boolean {
5 | return selectedItems.length === 0;
6 | }
7 |
8 | export function isMultiSelect(selectedItems: FileBrowserItem[]): boolean {
9 | return selectedItems.length > 1;
10 | }
11 |
12 | export function hasFoldersSelected(selectedItems: FileBrowserItem[]): boolean {
13 | return selectedItems.some((item) => item.Type === LinkType.FOLDER)
14 | }
15 |
--------------------------------------------------------------------------------
/typings/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.png';
2 |
3 | declare module '@transcend-io/conflux';
4 |
5 | declare module 'service-worker-loader*' {
6 | import {
7 | ServiceWorkerRegister,
8 | ScriptUrl as scriptUrl,
9 | ServiceWorkerNoSupportError,
10 | } from 'service-worker-loader/types.d';
11 |
12 | const register: ServiceWorkerRegister;
13 | export default register;
14 | export { ServiceWorkerNoSupportError, scriptUrl };
15 | }
16 |
17 | type UnderlyingByteSource = undefined;
18 | type ReadableStreamBYOBReader = undefined;
19 | declare module '@mattiasbuelens/web-streams-adapter';
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .vscode
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | package-lock.json
27 | yarn.lock
28 |
29 | dist
30 | .eslintcache
31 | .idea
32 | src/app/config.js
33 | src/app/config.ts
34 | appConfig.json
35 | env.json
36 | .env
37 | po/i18n.txt
38 | po/template.pot
39 |
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ActionsDropdown } from './ActionsDropdown';
2 | export { default as BackButton } from './BackButton';
3 | export { default as CreateNewFolderButton } from './CreateNewFolderButton';
4 | export { default as LayoutDropdown } from './LayoutDropdown';
5 | export { default as MoveToFolderButton } from './MoveToFolderButton';
6 | export { default as MoveToTrashButton } from './MoveToTrashButton';
7 | export { default as SortDropdown } from './SortDropdown';
8 | export { default as UploadFileButton } from './UploadFileButton';
9 | export { default as UploadFolderButton } from './UploadFolderButton';
10 |
--------------------------------------------------------------------------------
/src/app/containers/OnboardingContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useModals } from 'react-components';
3 |
4 | import DriveOnboardingModal from '../components/onboarding/DriveOnboardingModal';
5 | import DriveContainerBlurred from './DriveContainerBlurred';
6 |
7 | interface Props {
8 | onDone: () => void;
9 | }
10 | const OnboardingContainer = ({ onDone }: Props) => {
11 | const { createModal } = useModals();
12 |
13 | useEffect(() => {
14 | createModal();
15 | }, []);
16 |
17 | return ;
18 | };
19 |
20 | export default OnboardingContainer;
21 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/HasNoFilesToShare.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import noContentSvg from 'design-system/assets/img/placeholders/empty-folder.svg';
5 |
6 | const HasNoFilesToShare = () => {
7 | const title = c('Title').t`You have no files to share`;
8 |
9 | return (
10 |
11 |

12 |
{title}
13 |
{c('Info').t`Go to "My files" and upload some files first.`}
14 |
15 | );
16 | };
17 |
18 | export default HasNoFilesToShare;
19 |
--------------------------------------------------------------------------------
/src/app/components/Drive/UserSettings/UserSettingsProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, createContext, Dispatch, SetStateAction } from 'react';
2 | import { UserSettings } from '../../../interfaces/userSettings';
3 | import { DEFAULT_USER_SETTINGS } from '../../../constants';
4 |
5 | export const UserSettingsContext = createContext<[UserSettings, Dispatch>] | null>(null);
6 |
7 | const UserSettingsProvider = ({ children }: { children: React.ReactNode }) => {
8 | const userSettingsState = useState(DEFAULT_USER_SETTINGS);
9 |
10 | return {children};
11 | };
12 |
13 | export default UserSettingsProvider;
14 |
--------------------------------------------------------------------------------
/src/app/components/DowloadShared/LinkDoesNotExistInfo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon } from 'react-components';
5 |
6 | const LinkDoesNotExistInfo = () => {
7 | return (
8 | <>
9 | {c('Title').t`The link either does not exist or has expired`}
10 |
14 |
15 |
16 | >
17 | );
18 | };
19 |
20 | export default LinkDoesNotExistInfo;
21 |
--------------------------------------------------------------------------------
/src/app/components/TransferManager/ProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { classnames, Progress } from 'react-components';
3 |
4 | export enum ProgressBarStatus {
5 | Disabled = 'disabled',
6 | Running = 'running',
7 | Success = 'success',
8 | Warning = 'warning',
9 | Error = 'error',
10 | }
11 |
12 | interface Props {
13 | status?: ProgressBarStatus;
14 | value?: number;
15 | max?: number;
16 | }
17 |
18 | const ProgressBar = ({ status = ProgressBarStatus.Success, ...rest }: Props) => (
19 |
23 | );
24 |
25 | export default ProgressBar;
26 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/DescriptiveTypeCell.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 | import { LinkType } from '../../../../interfaces/link';
4 | import { getMimeTypeDescription } from '../../../Drive/helpers';
5 |
6 | interface Props {
7 | mimeType: string;
8 | linkType: LinkType;
9 | }
10 |
11 | const DescriptiveTypeCell = ({ mimeType, linkType }: Props) => {
12 | const type = linkType === LinkType.FILE ? getMimeTypeDescription(mimeType) : c('Label').t`Folder`;
13 |
14 | return (
15 |
16 | {type}
17 |
18 | );
19 | };
20 |
21 | export default DescriptiveTypeCell;
22 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/TimeCell.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { readableTime } from 'proton-shared/lib/helpers/time';
3 | import { dateLocale } from 'proton-shared/lib/i18n';
4 | import { Time } from 'react-components';
5 |
6 | interface Props {
7 | time: number;
8 | }
9 |
10 | const TimeCell = ({ time: modifyTime }: Props) => {
11 | return (
12 |
13 |
14 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default TimeCell;
23 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setupFilesAfterEnv: ['./rtl.setup.js'],
3 | verbose: true,
4 | moduleDirectories: ['node_modules'],
5 | transform: {
6 | '^.+\\.(js|tsx?)$': '/jest.transform.js',
7 | },
8 | collectCoverage: false,
9 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'],
10 | transformIgnorePatterns: ['node_modules/(?!(proton-shared|react-components|mutex-browser)/)'],
11 | moduleNameMapper: {
12 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$': '/__mocks__/fileMock.js',
13 | '\\.(css|scss|less)$': '/__mocks__/styleMock.js',
14 | pmcrypto: '/__mocks__/pmcrypto.js',
15 | 'sieve.js': '/__mocks__/sieve.js',
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/app/components/uploads/UploadDragDrop/UploadDragDrop.scss:
--------------------------------------------------------------------------------
1 | @import '~design-system/scss/config/';
2 |
3 | .upload-drag-drop {
4 | position: fixed;
5 | top: 0;
6 | bottom: 0;
7 | right: 0;
8 | left: 0;
9 | background: var(--backdrop-norm);
10 | z-index: 30;
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 |
15 | &-infobox {
16 | width: rem(490);
17 | min-width: rem(380);
18 | background: var(--background-norm);
19 | color: var(--text-norm);
20 | border-radius: $global-border-radius;
21 | box-shadow: var(--shadow-lifted);
22 | text-align: center;
23 | pointer-events: none;
24 | }
25 |
26 | &-image {
27 | max-width: rem(380);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/interfaces/volume.ts:
--------------------------------------------------------------------------------
1 | export interface CreateDriveVolume {
2 | AddressID: string;
3 | VolumeName: string;
4 | ShareName: string;
5 | FolderName: string;
6 | SharePassphrase: string;
7 | ShareKey: string;
8 | FolderPassphrase: string;
9 | FolderKey: string;
10 | FolderHashKey: string;
11 | }
12 |
13 | export interface DriveVolume {
14 | ID: string;
15 | Share: {
16 | ID: string;
17 | LinkID: string;
18 | };
19 | }
20 |
21 | export interface CreatedDriveVolumeResult {
22 | Volume: DriveVolume;
23 | }
24 |
25 | export interface RestoreDriveVolume {
26 | Name: string;
27 | SignatureAddress: string;
28 | Hash: string;
29 | NodePassphrase: string;
30 | NodePassphraseSignature: string;
31 | TargetVolumeID: string;
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/CreateNewFolderButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 |
8 | interface Props {
9 | disabled?: boolean;
10 | }
11 |
12 | const CreateNewFolderButton = ({ disabled }: Props) => {
13 | const { openCreateFolder } = useToolbarActions();
14 |
15 | return (
16 | }
19 | title={c('Action').t`Create new folder`}
20 | onClick={openCreateFolder}
21 | data-testid="toolbar-new-folder"
22 | />
23 | );
24 | };
25 |
26 | export default CreateNewFolderButton;
27 |
--------------------------------------------------------------------------------
/src/app/components/TransferManager/interfaces.ts:
--------------------------------------------------------------------------------
1 | import { Download, Upload } from '../../interfaces/transfer';
2 |
3 | export interface DownloadProps {
4 | transfer: Download;
5 | type: TransferType.Download;
6 | }
7 |
8 | export interface UploadProps {
9 | transfer: Upload;
10 | type: TransferType.Upload;
11 | }
12 |
13 | export interface TransferProps {
14 | transfer: T extends TransferType.Download ? Download : Upload;
15 | type: T;
16 | }
17 |
18 | export interface TransferStats {
19 | active: boolean;
20 | progress: number;
21 | speed: number;
22 | }
23 | export interface TransfersStats {
24 | timestamp: Date;
25 | stats: { [id: string]: TransferStats };
26 | }
27 |
28 | export enum TransferType {
29 | Download = 'download',
30 | Upload = 'upload',
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/ShareFileButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 |
8 | interface Props {
9 | shareId: string;
10 | }
11 |
12 | const ShareFileButton = ({ shareId }: Props) => {
13 | const { openFileSharing } = useToolbarActions();
14 |
15 | return (
16 | }
20 | onClick={() => {
21 | openFileSharing(shareId);
22 | }}
23 | data-testid="toolbar-shareViaLink"
24 | />
25 | );
26 | };
27 |
28 | export default ShareFileButton;
29 |
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/UploadFileButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 | import { Icon, ToolbarButton } from 'react-components';
4 | import useFileUploadInput from '../../../hooks/drive/useFileUploadInput';
5 |
6 | const UploadFileButton = () => {
7 | const { inputRef: fileInput, handleClick, handleChange } = useFileUploadInput();
8 |
9 | return (
10 | <>
11 |
12 | }
15 | title={c('Action').t`Upload file`}
16 | onClick={handleClick}
17 | />
18 | >
19 | );
20 | };
21 |
22 | export default UploadFileButton;
23 |
--------------------------------------------------------------------------------
/src/app/containers/NoAccessContainer/NoAccessContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import { useModals } from 'react-components';
4 |
5 | import DriveOnboardingModalNoAccess from '../../components/onboarding/DriveOnboardingModalNoAccess';
6 | import DriveOnboardingModalNoBeta from '../../components/onboarding/DriveOnboardingModalNoBeta';
7 | import DriveContainerBlurred from '../DriveContainerBlurred';
8 |
9 | interface Props {
10 | reason: 'notpaid' | 'notbeta';
11 | }
12 |
13 | const NoAccessContainer = ({ reason }: Props) => {
14 | const { createModal } = useModals();
15 |
16 | useEffect(() => {
17 | createModal(reason === 'notbeta' ? : );
18 | }, [reason]);
19 |
20 | return ;
21 | };
22 |
23 | export default NoAccessContainer;
24 |
--------------------------------------------------------------------------------
/src/app/components/SharingModal/LoadingState.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { InnerModal, Loader, TextLoader } from 'react-components';
3 | import { c } from 'ttag';
4 |
5 | interface Props {
6 | generated: boolean;
7 | }
8 |
9 | function LoadingState({ generated }: Props) {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | {generated ? c('Info').t`Preparing link to file` : c('Info').t`Creating link to file`}
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | export default LoadingState;
25 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "proton-lint"
4 | ],
5 | "parser": "@typescript-eslint/parser",
6 | "parserOptions": {
7 | "project": "./tsconfig.json"
8 | },
9 | "rules": {
10 | "react/prop-types": "off",
11 | "no-console": [
12 | "error",
13 | {
14 | "allow": [
15 | "warn",
16 | "error"
17 | ]
18 | }
19 | ],
20 | "@typescript-eslint/no-use-before-define": [
21 | "error",
22 | "nofunc"
23 | ]
24 | },
25 | "overrides": [
26 | {
27 | "files": [
28 | "*.test.ts"
29 | ],
30 | "rules": {
31 | "max-classes-per-file": "off",
32 | "class-methods-use-this": "off"
33 | }
34 | }
35 | ]
36 | }
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/UploadFolderButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { ToolbarButton, Icon } from 'react-components';
5 |
6 | import useFileUploadInput from '../../../hooks/drive/useFileUploadInput';
7 |
8 | const UploadFolderButton = () => {
9 | const { inputRef: fileInput, handleClick: handleUploadFolder, handleChange } = useFileUploadInput(true);
10 |
11 | return (
12 | <>
13 |
14 | }
17 | title={c('Action').t`Upload folder`}
18 | onClick={handleUploadFolder}
19 | />
20 | >
21 | );
22 | };
23 |
24 | export default UploadFolderButton;
25 |
--------------------------------------------------------------------------------
/src/app/components/DowloadShared/DownloadProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Progress } from 'react-components';
3 |
4 | import { TransfersStats } from '../TransferManager/interfaces';
5 | import { Download } from '../../interfaces/transfer';
6 | import { calculateProgress, getProgressBarStatus } from '../../utils/transfer';
7 |
8 | interface Props {
9 | latestStats: TransfersStats;
10 | download: Download;
11 | }
12 |
13 | const DownloadProgressBar = ({ latestStats, download }: Props) => {
14 | const percentageDone = calculateProgress(latestStats, [download]);
15 | const status = getProgressBarStatus(download.state);
16 | return (
17 | <>
18 |
19 | {percentageDone} %
20 | >
21 | );
22 | };
23 |
24 | export default DownloadProgressBar;
25 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/signatureChecks/applicationSignatures.ts:
--------------------------------------------------------------------------------
1 | import { SupportedMimeTypes } from '../constants';
2 | import { SignatureChecker } from '../helpers';
3 |
4 | export default function applicationSignatures({ check, checkString }: ReturnType) {
5 | if (check([0x43, 0x57, 0x53]) || check([0x46, 0x57, 0x53])) {
6 | return SupportedMimeTypes.swf;
7 | }
8 |
9 | if (check([0x46, 0x4c, 0x56, 0x01])) {
10 | return SupportedMimeTypes.flv;
11 | }
12 |
13 | if (checkString('OggS')) {
14 | return SupportedMimeTypes.ogg;
15 | }
16 |
17 | if (checkString('%PDF')) {
18 | return SupportedMimeTypes.pdf;
19 | }
20 |
21 | if (checkString('{\\rtf')) {
22 | return SupportedMimeTypes.rtf;
23 | }
24 |
25 | if (checkString(' = {
5 | drive: ['type', 'modified', 'size'],
6 | trash: ['location', 'type', 'trashed', 'size'],
7 | sharing: ['location', 'share_created', 'share_num_access', 'share_expires'],
8 | };
9 |
10 | const COLUMNS_MOBILE: Record = {
11 | drive: ['type', 'size'],
12 | trash: ['location', 'type', 'size'],
13 | sharing: ['location', 'share_expires'],
14 | };
15 |
16 | export const useFileBrowserColumns = (fileBrowserType: FileBrowserLayouts) => {
17 | const { isDesktop } = useActiveBreakpoint();
18 | const columnsSource = isDesktop ? COLUMNS_DESKTOP : COLUMNS_MOBILE;
19 |
20 | return columnsSource[fileBrowserType];
21 | };
22 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/ShareCell.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { PrimaryButton, useModals } from 'react-components';
4 | import { c } from 'ttag';
5 | import SharingModal from '../../../SharingModal/SharingModal';
6 | import { FileBrowserItem } from '../../interfaces';
7 |
8 | interface Props {
9 | shareId: string;
10 | item: FileBrowserItem;
11 | }
12 |
13 | const ShareCell = ({ shareId, item }: Props) => {
14 | const { createModal } = useModals();
15 |
16 | return (
17 | {
21 | e.stopPropagation();
22 | createModal();
23 | }}
24 | >
25 | {c('Action').t`Share`}
26 |
27 | );
28 | };
29 |
30 | export default ShareCell;
31 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/DownloadButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { FileBrowserItem } from '../interfaces';
8 | import { noSelection } from './utils';
9 |
10 | interface Props {
11 | shareId: string;
12 | selectedItems: FileBrowserItem[];
13 | }
14 |
15 | const DownloadButton = ({ shareId, selectedItems }: Props) => {
16 | const { download } = useToolbarActions();
17 |
18 | return (
19 | }
23 | onClick={() => download(shareId, selectedItems)}
24 | data-testid="toolbar-download"
25 | />
26 | );
27 | };
28 |
29 | export default DownloadButton;
30 |
--------------------------------------------------------------------------------
/src/app/components/FolderTree/FolderTree.scss:
--------------------------------------------------------------------------------
1 | .folder-tree {
2 | @extend .bordered;
3 | @extend .scroll-if-needed;
4 |
5 | border-radius: $global-border-radius;
6 | height: 28em;
7 |
8 | &-table {
9 | width: auto;
10 | min-width: 100%;
11 | max-width: initial;
12 | }
13 |
14 | &-list-item {
15 | td {
16 | padding-top: 0;
17 | padding-bottom: 0;
18 | }
19 |
20 | &-expand {
21 | padding: 0.85em;
22 |
23 | &-button {
24 | @extend .button;
25 | @extend .button-for-icon;
26 |
27 | display: flex;
28 | padding: 0;
29 | }
30 | }
31 | }
32 |
33 | &-list-item-name {
34 | min-width: 10em;
35 | }
36 |
37 | &-list-item-selected {
38 | justify-content: center;
39 | min-width: 3em;
40 |
41 | &-check {
42 | padding: 0.15em;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/RenameButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { FileBrowserItem } from '../interfaces';
8 | import { noSelection, isMultiSelect } from './utils';
9 |
10 | interface Props {
11 | shareId: string;
12 | selectedItems: FileBrowserItem[];
13 | }
14 |
15 | const RenameButton = ({ shareId, selectedItems }: Props) => {
16 | const { openRename } = useToolbarActions();
17 |
18 | return (
19 | }
23 | onClick={() => openRename(shareId, selectedItems[0])}
24 | data-testid="toolbar-rename"
25 | />
26 | );
27 | };
28 |
29 | export default RenameButton;
30 |
--------------------------------------------------------------------------------
/src/app/hooks/util/useDebouncedRequest.ts:
--------------------------------------------------------------------------------
1 | import { useCache, useApi } from 'react-components';
2 | import { Api } from 'proton-shared/lib/interfaces';
3 |
4 | const useDebouncedRequest = () => {
5 | const api = useApi();
6 | const cache = useCache();
7 |
8 | /**
9 | * If promise is pending, returns it, otherwise executes query function
10 | */
11 | const debouncedRequest: Api = (args: object) => {
12 | const key = `request_${JSON.stringify(args)}`;
13 | const existingPromise: Promise | undefined = cache.get(key);
14 |
15 | if (existingPromise) {
16 | return existingPromise;
17 | }
18 |
19 | const promise = api(args);
20 | cache.set(key, promise);
21 |
22 | const cleanup = () => {
23 | cache.delete(key);
24 | };
25 | promise.then(cleanup).catch(cleanup);
26 | return promise;
27 | };
28 |
29 | return debouncedRequest;
30 | };
31 |
32 | export default useDebouncedRequest;
33 |
--------------------------------------------------------------------------------
/src/app/components/SharingModal/ErrorState.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Alert, Button, FooterModal, HeaderModal, InnerModal } from 'react-components';
3 | import { c } from 'ttag';
4 |
5 | interface Props {
6 | modalTitleID: string;
7 | onClose?: () => void;
8 | }
9 |
10 | function ErrorState({ modalTitleID, onClose }: Props) {
11 | return (
12 | <>
13 |
14 | {c('Title').t`Manage secure link`}
15 |
16 |
17 |
18 | {c('Info').t`Failed to generate a secure link. Try again later.`}
19 |
20 |
21 |
22 |
23 |
24 | >
25 | );
26 | }
27 |
28 | export default ErrorState;
29 |
--------------------------------------------------------------------------------
/src/app/api/folder.ts:
--------------------------------------------------------------------------------
1 | import { SORT_DIRECTION } from 'proton-shared/lib/constants';
2 | import { FOLDER_PAGE_SIZE, DEFAULT_SORT_FIELD, DEFAULT_SORT_ORDER } from '../constants';
3 | import { CreateNewFolder } from '../interfaces/folder';
4 |
5 | export const queryFolderChildren = (
6 | shareID: string,
7 | linkID: string,
8 | {
9 | Page,
10 | PageSize = FOLDER_PAGE_SIZE,
11 | FoldersOnly = 0,
12 | Sort = DEFAULT_SORT_FIELD,
13 | // @ts-ignore
14 | Desc = DEFAULT_SORT_ORDER === SORT_DIRECTION.ASC ? 0 : 1,
15 | }: { Page: number; PageSize?: number; FoldersOnly?: number; Sort?: string; Desc?: 0 | 1 }
16 | ) => ({
17 | method: 'get',
18 | url: `drive/shares/${shareID}/folders/${linkID}/children`,
19 | params: { Page, PageSize, FoldersOnly, Sort, Desc, Thumbnails: 1 },
20 | });
21 |
22 | export const queryCreateFolder = (shareID: string, data: CreateNewFolder) => ({
23 | method: 'post',
24 | url: `drive/shares/${shareID}/folders`,
25 | data,
26 | });
27 |
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/MoveToFolderButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { DriveFolder } from '../DriveFolderProvider';
8 | import { FileBrowserItem } from '../../FileBrowser/interfaces';
9 |
10 | interface Props {
11 | sourceFolder: DriveFolder;
12 | selectedItems: FileBrowserItem[];
13 | }
14 |
15 | const MoveToFolderButton = ({ sourceFolder, selectedItems }: Props) => {
16 | const { openMoveToFolder } = useToolbarActions();
17 |
18 | return (
19 | }
23 | onClick={() => openMoveToFolder(sourceFolder, selectedItems)}
24 | data-testid="toolbar-move"
25 | />
26 | );
27 | };
28 |
29 | export default MoveToFolderButton;
30 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/signatureChecks/fontSignatures.ts:
--------------------------------------------------------------------------------
1 | import { SupportedMimeTypes } from '../constants';
2 | import { SignatureChecker } from '../helpers';
3 |
4 | export default function fontSignatures({ check, checkString }: ReturnType) {
5 | if (checkString('wOFF')) {
6 | return SupportedMimeTypes.woff;
7 | }
8 |
9 | if (checkString('wOF2')) {
10 | return SupportedMimeTypes.woff2;
11 | }
12 |
13 | if (check([0x4f, 0x54, 0x54, 0x4f, 0x00])) {
14 | return SupportedMimeTypes.otf;
15 | }
16 |
17 | if (
18 | check([0x4c, 0x50], { offset: 34 }) &&
19 | (check([0x00, 0x00, 0x01], { offset: 8 }) ||
20 | check([0x01, 0x00, 0x02], { offset: 8 }) ||
21 | check([0x02, 0x00, 0x02], { offset: 8 }))
22 | ) {
23 | return SupportedMimeTypes.eot;
24 | }
25 |
26 | if (check([0x00, 0x01, 0x00, 0x00, 0x00])) {
27 | return SupportedMimeTypes.ttf;
28 | }
29 |
30 | return undefined;
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/utils/async.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Waits for specific condition to be true
3 | */
4 | export const waitUntil = (conditionFn: () => boolean) => {
5 | return new Promise((resolve) => {
6 | const waitForCondition = () => {
7 | if (conditionFn()) return resolve();
8 | setTimeout(waitForCondition, 50);
9 | };
10 |
11 | waitForCondition();
12 | });
13 | };
14 |
15 | export const getSuccessfulSettled = (results: PromiseSettledResult[]) => {
16 | const values: T[] = [];
17 | results.forEach((result) => {
18 | if (result.status === 'fulfilled') {
19 | values.push(result.value);
20 | } else {
21 | console.error(result.reason);
22 | }
23 | });
24 | return values;
25 | };
26 |
27 | export const logSettledErrors = (results: PromiseSettledResult[]) => {
28 | results.forEach((result) => {
29 | if (result.status === 'rejected') {
30 | console.error(result.reason);
31 | }
32 | });
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/components/Drive/Trash/TrashToolbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Toolbar, ToolbarSeparator } from 'react-components';
3 | import { useTrashContent } from './TrashContentProvider';
4 | import { DeletePermanentlyButton, RestoreFromTrashButton } from './ToolbarButtons';
5 | import LayoutDropdown from '../ToolbarButtons/LayoutDropdown';
6 |
7 | interface Props {
8 | shareId: string;
9 | }
10 |
11 | const TrashToolbar = ({ shareId }: Props) => {
12 | const { fileBrowserControls } = useTrashContent();
13 | const { selectedItems } = fileBrowserControls;
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default TrashToolbar;
29 |
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/BackButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useNavigate from '../../../hooks/drive/useNavigate';
7 | import { LinkType } from '../../../interfaces/link';
8 |
9 | interface Props {
10 | shareId: string;
11 | parentLinkId?: string;
12 | disabled?: boolean;
13 | }
14 |
15 | const BackButton = ({ shareId, parentLinkId, disabled }: Props) => {
16 | const { navigateToLink } = useNavigate();
17 | const handleBackClick = () => {
18 | if (parentLinkId) {
19 | navigateToLink(shareId, parentLinkId, LinkType.FOLDER);
20 | }
21 | };
22 |
23 | return (
24 | }
30 | />
31 | );
32 | };
33 |
34 | export default BackButton;
35 |
--------------------------------------------------------------------------------
/src/app/hooks/drive/useNavigate.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useHistory, useLocation } from 'react-router-dom';
3 | import { LinkType } from '../../interfaces/link';
4 | import { toLinkURLType } from '../../components/Drive/helpers';
5 |
6 | function useNavigate() {
7 | const history = useHistory();
8 | const location = useLocation();
9 |
10 | const navigateToLink = useCallback(
11 | (shareId: string, linkId: string, type: LinkType) => {
12 | history.push(`/${shareId}/${toLinkURLType(type)}/${linkId}?r=${location.pathname}`);
13 | },
14 | [history, location.pathname]
15 | );
16 |
17 | const navigateToRoot = useCallback(() => {
18 | history.push(`/`);
19 | }, [history]);
20 |
21 | const navigateToSharedURLs = useCallback(() => {
22 | history.push(`/shared-urls`);
23 | }, [history]);
24 |
25 | return {
26 | navigateToLink,
27 | navigateToRoot,
28 | navigateToSharedURLs,
29 | };
30 | }
31 |
32 | export default useNavigate;
33 |
--------------------------------------------------------------------------------
/src/app/components/SharedLinks/ToolbarButtons/StopSharingButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { useSharedLinksContent } from '../SharedLinksContentProvider';
8 |
9 | interface Props {
10 | shareId: string;
11 | disabled?: boolean;
12 | }
13 |
14 | const StopSharingButton = ({ shareId, disabled }: Props) => {
15 | const { openStopSharing } = useToolbarActions();
16 | const { fileBrowserControls } = useSharedLinksContent();
17 | const { selectedItems } = fileBrowserControls;
18 |
19 | return (
20 | }
24 | onClick={() => openStopSharing(shareId, selectedItems)}
25 | data-testid="toolbar-button-stop-sharing"
26 | />
27 | );
28 | };
29 |
30 | export default StopSharingButton;
31 |
--------------------------------------------------------------------------------
/src/app/components/Drive/Trash/ToolbarButtons/DeletePermanentlyButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import { useTrashContent } from '../TrashContentProvider';
7 | import useToolbarActions from '../../../../hooks/drive/useToolbarActions';
8 |
9 | interface Props {
10 | shareId: string;
11 | disabled?: boolean;
12 | }
13 |
14 | const DeletePermanentlyButton = ({ shareId, disabled }: Props) => {
15 | const { openDeletePermanently } = useToolbarActions();
16 | const { fileBrowserControls } = useTrashContent();
17 | const { selectedItems } = fileBrowserControls;
18 |
19 | return (
20 | }
24 | onClick={() => openDeletePermanently(shareId, selectedItems)}
25 | data-testid="toolbar-delete"
26 | />
27 | );
28 | };
29 |
30 | export default DeletePermanentlyButton;
31 |
--------------------------------------------------------------------------------
/src/app/containers/TrashContainer/TrashContainerView.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 | import { PrivateMainArea, useAppTitle } from 'react-components';
4 | import TrashToolbar from '../../components/Drive/Trash/TrashToolbar';
5 | import Trash from '../../components/Drive/Trash/Trash';
6 | import TrashContentProvider from '../../components/Drive/Trash/TrashContentProvider';
7 |
8 | interface Props {
9 | shareId: string;
10 | }
11 |
12 | const TrashContainerView = ({ shareId }: Props) => {
13 | useAppTitle(c('Title').t`Trash`);
14 |
15 | return (
16 |
17 |
18 |
19 | {c('Info').t`Trash`}
20 | {shareId && }
21 |
22 |
23 | );
24 | };
25 |
26 | export default TrashContainerView;
27 |
--------------------------------------------------------------------------------
/src/app/components/layout/DriveSidebar/DriveSidebarList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { SidebarList } from 'react-components';
5 | import DriveSidebarListItem from './DriveSidebarListItem';
6 | import FileRecoveryIcon from '../../FilesRecoveryModal/FileRecoveryIcon';
7 |
8 | interface Props {
9 | shareId?: string;
10 | }
11 |
12 | const DriveSidebarList = ({ shareId }: Props) => (
13 |
14 |
15 | <>
16 | {c('Link').t`My files`}
17 |
18 | >
19 |
20 |
21 | {c('Link').t`Shared`}
22 |
23 |
24 | {c('Link').t`Trash`}
25 |
26 |
27 | );
28 |
29 | export default DriveSidebarList;
30 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/HasNoFolders.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Button } from 'react-components';
5 |
6 | import noContentSvg from 'design-system/assets/img/placeholders/empty-folder.svg';
7 |
8 | interface Props {
9 | onCreate: () => void;
10 | }
11 |
12 | const HasNoFolders = ({ onCreate }: Props) => {
13 | const title = c('Title').t`You have no folders yet`;
14 | return (
15 |
16 |

17 |
{title}
18 |
{c('Info').t`Create your first folder and start moving your files.`}
19 |
20 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default HasNoFolders;
29 |
--------------------------------------------------------------------------------
/src/app/utils/file.ts:
--------------------------------------------------------------------------------
1 | import { MB } from '../constants';
2 |
3 | export const isFile = async (item: File) => {
4 | if (item.type !== '' || item.size > MB) {
5 | return true;
6 | }
7 |
8 | return new Promise((resolve, reject) => {
9 | const reader = new FileReader();
10 | reader.onload = ({ target }) => {
11 | if (!target?.result) {
12 | return reject();
13 | }
14 | resolve();
15 | };
16 | reader.onerror = reject;
17 | reader.onabort = reject;
18 | reader.readAsBinaryString(item);
19 | })
20 | .then(() => true)
21 | .catch(() => false);
22 | };
23 |
24 | export const countFilesToUpload = (
25 | files:
26 | | FileList
27 | | {
28 | path: string[];
29 | file?: File | undefined;
30 | }[]
31 | ) => {
32 | let count = 0;
33 | for (const entry of files) {
34 | const file = 'path' in entry ? entry.file : entry;
35 | if (file) {
36 | count += 1;
37 | }
38 | }
39 | return count;
40 | };
41 |
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/MoveToTrashButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton, useLoading } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { DriveFolder } from '../DriveFolderProvider';
8 | import { FileBrowserItem } from '../../FileBrowser/interfaces';
9 |
10 | interface Props {
11 | sourceFolder: DriveFolder;
12 | selectedItems: FileBrowserItem[];
13 | }
14 |
15 | const MoveToTrashButton = ({ sourceFolder, selectedItems }: Props) => {
16 | const [moveToTrashLoading, withMoveToTrashLoading] = useLoading();
17 | const { openMoveToTrash } = useToolbarActions();
18 |
19 | return (
20 | }
24 | onClick={() => withMoveToTrashLoading(openMoveToTrash(sourceFolder, selectedItems))}
25 | data-testid="toolbar-trash"
26 | />
27 | );
28 | };
29 |
30 | export default MoveToTrashButton;
31 |
--------------------------------------------------------------------------------
/src/app/containers/SharedURLsContainer/SharedURLsContainerView.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { PrivateMainArea, useAppTitle } from 'react-components';
3 | import { c } from 'ttag';
4 | import SharedLinks from '../../components/SharedLinks/SharedLinks';
5 | import SharedLinksContentProvider from '../../components/SharedLinks/SharedLinksContentProvider';
6 | import SharedLinksToolbar from '../../components/SharedLinks/SharedLinksToolbar';
7 |
8 | interface Props {
9 | shareId: string;
10 | }
11 |
12 | const SharedURLsContainerView = ({ shareId }: Props) => {
13 | useAppTitle(c('Title').t`Shared`);
14 |
15 | return (
16 |
17 |
18 |
19 | {c('Info').t`My Links`}
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default SharedURLsContainerView;
27 |
--------------------------------------------------------------------------------
/src/app/components/FilesRecoveryModal/FileRecoveryIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, Tooltip, useModals } from 'react-components';
5 |
6 | import { useDriveCache } from '../DriveCache/DriveCacheProvider';
7 | import FilesRecoveryModal from './FilesRecoveryModal';
8 |
9 | interface Props {
10 | className?: string;
11 | }
12 |
13 | const FileRecoveryIcon = ({ className }: Props) => {
14 | const cache = useDriveCache();
15 | const { createModal } = useModals();
16 |
17 | return cache.sharesReadyToRestore.length ? (
18 |
19 | {
24 | e.preventDefault();
25 | e.stopPropagation();
26 |
27 | createModal();
28 | }}
29 | />
30 |
31 | ) : null;
32 | };
33 |
34 | export default FileRecoveryIcon;
35 |
--------------------------------------------------------------------------------
/src/app/components/Drive/DriveFolderProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, createContext, useContext } from 'react';
2 |
3 | export type DriveFolder = { shareId: string; linkId: string };
4 |
5 | interface DriveFolderProviderState {
6 | folder?: DriveFolder;
7 | setFolder: (folder?: DriveFolder) => void;
8 | }
9 |
10 | const DriveFolderContext = createContext(null);
11 |
12 | interface Props {
13 | children: React.ReactNode;
14 | }
15 |
16 | /**
17 | * Manages drive initialization (starting onboarding).
18 | * Stores open folder shareId and linkID for easy access.
19 | */
20 | const DriveFolderProvider = ({ children }: Props) => {
21 | const [folder, setFolder] = useState();
22 | return {children};
23 | };
24 |
25 | export const useDriveActiveFolder = () => {
26 | const state = useContext(DriveFolderContext);
27 | if (!state) {
28 | throw new Error('Trying to use uninitialized DriveFolderProvider');
29 | }
30 | return state;
31 | };
32 |
33 | export default DriveFolderProvider;
34 |
--------------------------------------------------------------------------------
/src/app/components/Drive/Trash/ToolbarButtons/RestoreFromTrashButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton, useLoading } from 'react-components';
5 |
6 | import { useTrashContent } from '../TrashContentProvider';
7 | import useToolbarActions from '../../../../hooks/drive/useToolbarActions';
8 |
9 | interface Props {
10 | shareId: string;
11 | disabled?: boolean;
12 | }
13 |
14 | const RestoreFromTrashButton = ({ shareId, disabled }: Props) => {
15 | const [restoreLoading, withRestoreLoading] = useLoading();
16 | const { restoreFromTrash } = useToolbarActions();
17 | const { fileBrowserControls } = useTrashContent();
18 | const { selectedItems } = fileBrowserControls;
19 |
20 | return (
21 | }
25 | onClick={() => withRestoreLoading(restoreFromTrash(shareId, selectedItems))}
26 | data-testid="toolbar-restore"
27 | />
28 | );
29 | };
30 |
31 | export default RestoreFromTrashButton;
32 |
--------------------------------------------------------------------------------
/src/app/components/layout/DriveSidebar/DriveSidebar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Sidebar, SidebarNav } from 'react-components';
3 | import { useDriveActiveFolder } from '../../Drive/DriveFolderProvider';
4 | import DriveSidebarFooter from './DriveSidebarFooter';
5 | import DriveSidebarList from './DriveSidebarList';
6 |
7 | interface Props {
8 | isHeaderExpanded: boolean;
9 | toggleHeaderExpanded: () => void;
10 | primary: React.ReactNode;
11 | logo: React.ReactNode;
12 | shareId?: string;
13 | }
14 |
15 | const DriveSidebar = ({ shareId, logo, primary, isHeaderExpanded, toggleHeaderExpanded }: Props) => {
16 | const { folder } = useDriveActiveFolder();
17 |
18 | return (
19 | }
25 | >
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default DriveSidebar;
34 |
--------------------------------------------------------------------------------
/src/app/components/uploads/ChunkFileReader.ts:
--------------------------------------------------------------------------------
1 | class ChunkFileReader {
2 | private blob: Blob;
3 |
4 | private chunkSize: number;
5 |
6 | private offset = 0;
7 |
8 | constructor(file: Blob, chunkSize: number) {
9 | this.blob = file;
10 | this.chunkSize = chunkSize;
11 | }
12 |
13 | isEOF() {
14 | return this.offset >= this.blob.size;
15 | }
16 |
17 | async readNextChunk() {
18 | const fileReader = new FileReader();
19 | const blob = this.blob.slice(this.offset, this.offset + this.chunkSize);
20 |
21 | return new Promise((resolve, reject) => {
22 | fileReader.onload = async (e) => {
23 | if (!e.target || e.target?.error) {
24 | return reject(e.target?.error || new Error('Cannot open file for reading'));
25 | }
26 |
27 | const result = new Uint8Array(e.target.result as ArrayBuffer);
28 | this.offset += result.byteLength;
29 | resolve(result);
30 | };
31 |
32 | fileReader.readAsArrayBuffer(blob);
33 | });
34 | }
35 | }
36 |
37 | export default ChunkFileReader;
38 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/ShareLinkButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { FileBrowserItem } from '../interfaces';
8 | import { noSelection, isMultiSelect, hasFoldersSelected } from './utils';
9 |
10 | interface Props {
11 | shareId: string;
12 | selectedItems: FileBrowserItem[];
13 | }
14 |
15 | const ShareLinkButton = ({ shareId, selectedItems }: Props) => {
16 | const { openLinkSharing } = useToolbarActions();
17 |
18 | const hasSharedLink = !!selectedItems[0]?.SharedUrl;
19 |
20 | return (
21 | }
25 | onClick={() => openLinkSharing(shareId, selectedItems[0])}
26 | data-testid="toolbar-share"
27 | />
28 | );
29 | };
30 |
31 | export default ShareLinkButton;
32 |
--------------------------------------------------------------------------------
/src/app/utils/stream.ts:
--------------------------------------------------------------------------------
1 | import { TransformStream, ReadableStream, ReadResult } from 'web-streams-polyfill';
2 |
3 | export const untilStreamEnd = async (stream: ReadableStream, action?: (data: T) => Promise) => {
4 | const reader = stream.getReader();
5 |
6 | const processResponse = async (result: ReadResult): Promise => {
7 | if (result.done) {
8 | return;
9 | }
10 |
11 | await action?.(result.value);
12 |
13 | return processResponse(await reader.read());
14 | };
15 |
16 | return processResponse(await reader.read());
17 | };
18 |
19 | export const streamToBuffer = async (stream: ReadableStream) => {
20 | const chunks: Uint8Array[] = [];
21 | await untilStreamEnd(stream, async (chunk) => {
22 | chunks.push(chunk);
23 | });
24 | return chunks;
25 | };
26 |
27 | export class ObserverStream extends TransformStream {
28 | constructor(fn?: (chunk: Uint8Array) => void) {
29 | super({
30 | transform(chunk, controller) {
31 | fn?.(chunk);
32 | controller.enqueue(chunk);
33 | },
34 | });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/interfaces/share.ts:
--------------------------------------------------------------------------------
1 | import { LinkType } from './link';
2 |
3 | export interface CreateDriveShare {
4 | AddressID: string;
5 | RootLinkID: string;
6 | Name: string;
7 | Type: number; // TODO: UNUSED - remove it when BE removes it
8 | LinkType: LinkType;
9 | PermissionsMask: number;
10 | ShareKey: string;
11 | SharePassphrase: string;
12 | SharePassphraseSignature: string;
13 | PassphraseKeyPacket: string;
14 | NameKeyPacket: string;
15 | }
16 |
17 | export interface UserShareResult {
18 | Shares: ShareMetaShort[];
19 | }
20 |
21 | export interface ShareMetaShort {
22 | ShareID: string;
23 | Type: number;
24 | LinkID: string;
25 | LinkType: LinkType;
26 | Locked: boolean;
27 | VolumeID: string;
28 | Creator: string;
29 | PermissionsMask: 0;
30 | Flags: number;
31 | BlockSize: number;
32 | PossibleKeyPackets?: { KeyPacket: string }[];
33 | }
34 |
35 | export interface ShareMeta extends ShareMetaShort {
36 | Key: string;
37 | Passphrase: string;
38 | PassphraseSignature: string;
39 | AddressID: string;
40 | RootLinkRecoveryPassphrase?: string;
41 | }
42 |
43 | export enum ShareFlags {
44 | PrimaryShare = 1,
45 | }
46 |
--------------------------------------------------------------------------------
/src/app.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/app/components/SharedLinks/ShareFileButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 | import { FloatingButton, Icon, SidebarPrimaryButton, useModals } from 'react-components';
4 | import SelectedFileToShareModal from '../SelectedFileToShareModal';
5 |
6 | interface Props {
7 | shareId: string;
8 | floating?: boolean;
9 | className?: string;
10 | }
11 |
12 | const ShareFileButton = ({ shareId, floating, className }: Props) => {
13 | const { createModal } = useModals();
14 |
15 | const onShareFile = () => {
16 | if (shareId) {
17 | createModal();
18 | }
19 | };
20 |
21 | return (
22 | <>
23 | {floating ? (
24 |
25 |
26 |
27 | ) : (
28 | {c('Action')
29 | .t`Share file`}
30 | )}
31 | >
32 | );
33 | };
34 |
35 | export default ShareFileButton;
36 |
--------------------------------------------------------------------------------
/src/app/components/layout/DriveHeader.tsx:
--------------------------------------------------------------------------------
1 | import { APPS } from 'proton-shared/lib/constants';
2 | import React from 'react';
3 | import {
4 | PrivateHeader,
5 | useActiveBreakpoint,
6 | TopNavbarListItemContactsDropdown,
7 | TopNavbarListItemSettingsDropdown,
8 | } from 'react-components';
9 | import { c } from 'ttag';
10 |
11 | interface Props {
12 | isHeaderExpanded: boolean;
13 | toggleHeaderExpanded: () => void;
14 | floatingPrimary: React.ReactNode;
15 | logo: React.ReactNode;
16 | title?: string;
17 | }
18 |
19 | const DriveHeader = ({
20 | logo,
21 | isHeaderExpanded,
22 | toggleHeaderExpanded,
23 | floatingPrimary,
24 | title = c('Title').t`Drive`,
25 | }: Props) => {
26 | const { isNarrow } = useActiveBreakpoint();
27 | return (
28 | }
32 | settingsButton={}
33 | expanded={isHeaderExpanded}
34 | onToggleExpand={toggleHeaderExpanded}
35 | isNarrow={isNarrow}
36 | floatingButton={floatingPrimary}
37 | />
38 | );
39 | };
40 |
41 | export default DriveHeader;
42 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/DetailsButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { FileBrowserItem } from '../interfaces';
8 | import { noSelection, isMultiSelect, hasFoldersSelected } from './utils';
9 |
10 | interface Props {
11 | shareId: string;
12 | selectedItems: FileBrowserItem[];
13 | }
14 |
15 | const DetailsButton = ({ shareId, selectedItems }: Props) => {
16 | const { openDetails, openFilesDetails } = useToolbarActions();
17 |
18 | return (
19 | }
23 | onClick={() => {
24 | if (selectedItems.length === 1) {
25 | openDetails(shareId, selectedItems[0]);
26 | } else if (selectedItems.length > 1) {
27 | openFilesDetails(selectedItems);
28 | }
29 | }}
30 | data-testid="toolbar-details"
31 | />
32 | );
33 | };
34 |
35 | export default DetailsButton;
36 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ToolbarButtons/PreviewButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, ToolbarButton, isPreviewAvailable } from 'react-components';
5 |
6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions';
7 | import { FileBrowserItem } from '../interfaces';
8 | import { isMultiSelect, hasFoldersSelected } from './utils';
9 |
10 | interface Props {
11 | shareId: string;
12 | selectedItems: FileBrowserItem[];
13 | }
14 |
15 | const PreviewButton = ({ shareId, selectedItems }: Props) => {
16 | const { preview } = useToolbarActions();
17 |
18 | const disabled =
19 | isMultiSelect(selectedItems) ||
20 | hasFoldersSelected(selectedItems) ||
21 | !selectedItems[0]?.MIMEType ||
22 | !isPreviewAvailable(selectedItems[0].MIMEType);
23 |
24 | return (
25 | }
29 | onClick={() => {
30 | if (selectedItems.length) {
31 | preview(shareId, selectedItems[0]);
32 | }
33 | }}
34 | data-testid="toolbar-preview"
35 | />
36 | );
37 | };
38 |
39 | export default PreviewButton;
40 |
--------------------------------------------------------------------------------
/src/app/hooks/util/useConfirm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { useModals, Alert, ConfirmModal, ErrorButton } from 'react-components';
5 |
6 | const useConfirm = () => {
7 | const { createModal } = useModals();
8 |
9 | const openConfirmModal = ({
10 | confirm,
11 | message,
12 | onCancel,
13 | onConfirm,
14 | title,
15 | canUndo = false,
16 | }: {
17 | title: string;
18 | confirm: string;
19 | message: string;
20 | onConfirm: () => any;
21 | onCancel?: () => any;
22 | canUndo?: boolean;
23 | }) => {
24 | const content = (
25 | <>
26 | {message}
27 |
28 | {!canUndo && c('Info').t`You cannot undo this action.`}
29 | >
30 | );
31 |
32 | createModal(
33 | {confirm}}
37 | onConfirm={onConfirm}
38 | onClose={onCancel}
39 | >
40 | {content}
41 |
42 | );
43 | };
44 |
45 | return { openConfirmModal };
46 | };
47 |
48 | export default useConfirm;
49 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/ListView/Cells/LocationCell.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { c } from 'ttag';
3 | import useDrive from '../../../../hooks/drive/useDrive';
4 |
5 | interface Props {
6 | shareId: string;
7 | parentLinkId: string;
8 | }
9 |
10 | const LocationCell = ({ shareId, parentLinkId }: Props) => {
11 | const [location, setLocation] = useState();
12 | const { getLinkMeta } = useDrive();
13 |
14 | useEffect(() => {
15 | const getLocationItems = async (shareId: string, linkId: string): Promise => {
16 | const { ParentLinkID, Name } = await getLinkMeta(shareId, linkId);
17 | if (!ParentLinkID) {
18 | return [c('Title').t`My files`];
19 | }
20 |
21 | const previous = await getLocationItems(shareId, ParentLinkID);
22 | return [...previous, Name];
23 | };
24 |
25 | getLocationItems(shareId, parentLinkId)
26 | .then((items: string[]) => `/${items.join('/')}`)
27 | .then(setLocation)
28 | .catch(console.error);
29 | }, [shareId, parentLinkId]);
30 |
31 | return (
32 |
33 | {location}
34 |
35 | );
36 | };
37 |
38 | export default LocationCell;
39 |
--------------------------------------------------------------------------------
/src/app/components/SharedLinks/SharedLinksToolbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Toolbar, ToolbarSeparator } from 'react-components';
3 |
4 | import { useSharedLinksContent } from './SharedLinksContentProvider';
5 | import {
6 | DetailsButton,
7 | DownloadButton,
8 | PreviewButton,
9 | RenameButton,
10 | ShareLinkButton,
11 | } from '../FileBrowser/ToolbarButtons';
12 | import StopSharingButton from './ToolbarButtons/StopSharingButton';
13 |
14 | interface Props {
15 | shareId: string;
16 | }
17 |
18 | const SharedLinksToolbar = ({ shareId }: Props) => {
19 | const { fileBrowserControls } = useSharedLinksContent();
20 | const { selectedItems } = fileBrowserControls;
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default SharedLinksToolbar;
37 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/EmptyShared.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { IllustrationPlaceholder, PrimaryButton, useModals } from 'react-components';
5 |
6 | import noLinksSvg from 'design-system/assets/img/placeholders/file-share.svg';
7 |
8 | import SelectedFileToShareModal from '../SelectedFileToShareModal';
9 |
10 | type Props = {
11 | shareId: string;
12 | };
13 |
14 | const EmptyShared = ({ shareId }: Props) => {
15 | const { createModal } = useModals();
16 |
17 | const onShareFile = () => {
18 | if (shareId) {
19 | createModal();
20 | }
21 | };
22 |
23 | return (
24 |
25 |
26 | {c('Info').t`Create links and share your files with others.`}
27 |
28 |
29 | {c('Action').t`Share file`}
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default EmptyShared;
38 |
--------------------------------------------------------------------------------
/src/app/hooks/util/useOnScrollEnd.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, MutableRefObject } from 'react';
2 | import { useElementRect } from 'react-components';
3 |
4 | const isScrollEnd = (target: HTMLElement | null, offsetRatio: number) =>
5 | target && target.scrollHeight - target.scrollTop <= target.clientHeight / offsetRatio;
6 |
7 | function useOnScrollEnd(
8 | callback: () => void,
9 | targetRef: MutableRefObject,
10 | offsetRatio = 1,
11 | deps: React.DependencyList = []
12 | ) {
13 | const boundingBox = useElementRect(targetRef);
14 |
15 | useEffect(() => {
16 | const handleScroll = ({ target }: Event) => {
17 | if (isScrollEnd(target as HTMLElement | null, offsetRatio)) {
18 | callback();
19 | }
20 | };
21 |
22 | if (targetRef.current) {
23 | targetRef.current.addEventListener('scroll', handleScroll);
24 | }
25 |
26 | return () => {
27 | if (targetRef.current) {
28 | targetRef.current.removeEventListener('scroll', handleScroll);
29 | }
30 | };
31 | }, [targetRef.current, callback]);
32 |
33 | useEffect(() => {
34 | // If initially at the end or no scrollbar execute callback
35 | if (isScrollEnd(targetRef.current, offsetRatio)) {
36 | callback();
37 | }
38 | }, [callback, boundingBox, targetRef.current, ...deps]);
39 | }
40 |
41 | export default useOnScrollEnd;
42 |
--------------------------------------------------------------------------------
/src/app/components/onboarding/DriveOnboardingModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 | import { getAppName } from 'proton-shared/lib/apps/helper';
4 | import { OnboardingContent, OnboardingModal, OnboardingStep, OnboardingStepRenderCallback } from 'react-components';
5 | import { APPS } from 'proton-shared/lib/constants';
6 |
7 | import onboardingWelcome from 'design-system/assets/img/onboarding/drive-welcome.svg';
8 |
9 | const DriveOnboardingModal = (props: any) => {
10 | const appName = getAppName(APPS.PROTONDRIVE);
11 |
12 | return (
13 |
14 | {({ onNext }: OnboardingStepRenderCallback) => (
15 |
20 | }
25 | />
26 |
27 | )}
28 |
29 | );
30 | };
31 |
32 | export default DriveOnboardingModal;
33 |
--------------------------------------------------------------------------------
/src/app/components/FilesRecoveryModal/FilesRecoveryModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { DialogModal, useLoading, useNotifications } from 'react-components';
5 |
6 | import FilesRecoveryState from './FilesRecoveryState';
7 | import useDrive from '../../hooks/drive/useDrive';
8 | import { ShareMeta } from '../../interfaces/share';
9 |
10 | interface Props {
11 | lockedShareList: {
12 | lockedShareMeta: ShareMeta;
13 | decryptedPassphrase: any;
14 | }[];
15 | onClose?: () => void;
16 | }
17 |
18 | const FilesRecoveryModal = ({ lockedShareList, onClose, ...rest }: Props) => {
19 | const modalTitleID = 'files-recovery-modal';
20 |
21 | const { restoreVolumes } = useDrive();
22 | const [recovering, withRecovering] = useLoading();
23 | const { createNotification } = useNotifications();
24 |
25 | const handleRecoveryClick = async () => {
26 | await withRecovering(
27 | restoreVolumes(lockedShareList)
28 | .then(() => {
29 | createNotification({
30 | text: c('Success').t`Recovery has started.`,
31 | });
32 | })
33 | .catch(() => onClose?.())
34 | );
35 |
36 | onClose?.();
37 | };
38 |
39 | return (
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default FilesRecoveryModal;
47 |
--------------------------------------------------------------------------------
/src/app/components/onboarding/DriveOnboardingModalNoAccess.tsx:
--------------------------------------------------------------------------------
1 | import { c } from 'ttag';
2 | import { OnboardingContent, OnboardingModal, OnboardingStep, useSettingsLink } from 'react-components';
3 | import React from 'react';
4 | import { APPS } from 'proton-shared/lib/constants';
5 | import { getAppName } from 'proton-shared/lib/apps/helper';
6 |
7 | import onboardingWelcome from 'design-system/assets/img/onboarding/drive-upgrade.svg';
8 |
9 | const DriveOnboardingModalNoAccess = (props: any) => {
10 | const goToSettings = useSettingsLink();
11 | const appName = getAppName(APPS.PROTONDRIVE);
12 |
13 | const handleBack = () => goToSettings('/dashboard');
14 |
15 | return (
16 |
17 | {() => (
18 |
23 | }
28 | />
29 |
30 | )}
31 |
32 | );
33 | };
34 |
35 | export default DriveOnboardingModalNoAccess;
36 |
--------------------------------------------------------------------------------
/src/app/components/onboarding/DriveOnboardingModalNoBeta.tsx:
--------------------------------------------------------------------------------
1 | import { c } from 'ttag';
2 | import React from 'react';
3 |
4 | import { OnboardingContent, OnboardingModal, OnboardingStep, EarlyAccessModal, useModals } from 'react-components';
5 | import { APPS } from 'proton-shared/lib/constants';
6 | import { getAppName } from 'proton-shared/lib/apps/helper';
7 | import onboardingWelcome from 'design-system/assets/img/onboarding/drive-upgrade.svg';
8 |
9 | const DriveOnboardingModalNoBeta = (props: any) => {
10 | const appName = getAppName(APPS.PROTONDRIVE);
11 | const { createModal } = useModals();
12 |
13 | return (
14 |
15 | {() => (
16 | {
20 | createModal();
21 | }}
22 | >
23 | }
28 | />
29 |
30 | )}
31 |
32 | );
33 | };
34 |
35 | export default DriveOnboardingModalNoBeta;
36 |
--------------------------------------------------------------------------------
/src/app/PrivateApp.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { StandardPrivateApp, LoaderPage, useAppTitle } from 'react-components';
3 | import {
4 | UserModel,
5 | UserSettingsModel,
6 | AddressesModel,
7 | ContactsModel,
8 | ContactEmailsModel,
9 | LabelsModel,
10 | } from 'proton-shared/lib/models';
11 | import { TtagLocaleMap } from 'proton-shared/lib/interfaces/Locale';
12 | import { openpgpConfig } from './openpgpConfig';
13 | import useUserSettings from './hooks/drive/useUserSettings';
14 | import UserSettingsProvider from './components/Drive/UserSettings/UserSettingsProvider';
15 |
16 | const getAppContainer = () => import('./containers/MainContainer');
17 |
18 | interface Props {
19 | onLogout: () => void;
20 | locales: TtagLocaleMap;
21 | }
22 |
23 | const PrivateAppInner = ({ onLogout, locales }: Props) => {
24 | const { loadUserSettings } = useUserSettings();
25 | useAppTitle('');
26 |
27 | return (
28 | }
35 | onInit={loadUserSettings}
36 | noModals
37 | app={getAppContainer}
38 | />
39 | );
40 | };
41 |
42 | const PrivateApp = (props: Props) => {
43 | return (
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default PrivateApp;
51 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/SharedURLIcon.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Icon, Tooltip, useModals } from 'react-components';
5 | import SharingModal from '../SharingModal/SharingModal';
6 | import { FileBrowserItem } from './interfaces';
7 |
8 | interface Props {
9 | shareId: string;
10 | item: FileBrowserItem;
11 | className?: string;
12 | }
13 |
14 | const SharedURLIcon = ({ shareId, item, className }: Props) => {
15 | const { createModal } = useModals();
16 |
17 | const handleOpeningModal = useCallback(
18 | (e) => {
19 | e.stopPropagation(); // To not show file preview when clicking (to not trigger other click event).
20 | e.preventDefault(); // To not show file preview when pressing enter (to disable click event).
21 | createModal();
22 | },
23 | [shareId, item]
24 | );
25 |
26 | return (
27 |
28 |
40 |
41 | );
42 | };
43 |
44 | export default SharedURLIcon;
45 |
--------------------------------------------------------------------------------
/src/app/api/link.ts:
--------------------------------------------------------------------------------
1 | import { EXPENSIVE_REQUEST_TIMEOUT } from '../constants';
2 |
3 | export const queryCheckAvailableHashes = (
4 | shareId: string,
5 | linkId: string,
6 | data: { Hashes: string[] },
7 | suppressErrors = false
8 | ) => {
9 | return {
10 | method: 'post',
11 | timeout: EXPENSIVE_REQUEST_TIMEOUT,
12 | url: `drive/shares/${shareId}/links/${linkId}/checkAvailableHashes`,
13 | suppress: suppressErrors,
14 | data,
15 | };
16 | };
17 |
18 | export const queryGetLink = (ShareID: string, LinkID: string) => ({
19 | method: 'get',
20 | url: `drive/shares/${ShareID}/links/${LinkID}`,
21 | });
22 |
23 | export const queryTrashLinks = (ShareID: string, ParentLinkID: string, LinkIDs: string[]) => ({
24 | method: 'post',
25 | url: `drive/shares/${ShareID}/folders/${ParentLinkID}/trash_multiple`,
26 | data: { LinkIDs },
27 | });
28 |
29 | export const queryDeleteTrashedLinks = (ShareID: string, LinkIDs: string[]) => ({
30 | method: 'post',
31 | url: `drive/shares/${ShareID}/trash/delete_multiple`,
32 | data: { LinkIDs },
33 | });
34 |
35 | export const queryDeleteChildrenLinks = (ShareID: string, ParentLinkID: string, LinkIDs: string[]) => ({
36 | method: 'post',
37 | url: `drive/shares/${ShareID}/folders/${ParentLinkID}/delete_multiple`,
38 | data: { LinkIDs },
39 | });
40 |
41 | export const queryRestoreLinks = (ShareID: string, LinkIDs: string[]) => ({
42 | method: 'put',
43 | url: `drive/shares/${ShareID}/trash/restore_multiple`,
44 | data: { LinkIDs },
45 | });
46 |
47 | export const queryEmptyTrashOfShare = (ShareID: string) => ({
48 | method: 'delete',
49 | url: `drive/shares/${ShareID}/trash`,
50 | });
51 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/FileBrowser.scss:
--------------------------------------------------------------------------------
1 | @import '~design-system/scss/config/';
2 |
3 | .file-browser-list-item td,
4 | .file-browser-table th {
5 | &:first-of-type {
6 | // Checkbox cell
7 | position: relative; // Because of .increase-click-surface
8 | padding-left: 1em;
9 | padding-right: 1em;
10 | width: calc( #{rem(16)} + 2em);
11 | }
12 | }
13 |
14 | .file-browser-grid-item {
15 | position: relative;
16 | background-color: var(--background-norm);
17 |
18 | &:hover,
19 | &:focus {
20 | background-color: var(--background-weak);
21 | }
22 | }
23 |
24 | .file-browser-grid-item.file-browser-grid-item--highlight {
25 | background-color: var(--background-strong);
26 | }
27 |
28 | .file-browser-grid-item--thumbnail {
29 | max-width: 100%;
30 | max-height: 100%;
31 | object-fit: contain; // To keep original proportion in Safari.
32 | }
33 |
34 | .file-browser-list-item--share {
35 | display: none;
36 | margin: -1em 0 -1em auto;
37 | }
38 |
39 | .file-browser-grid-item--share {
40 | position: absolute;
41 | top: 0.2em;
42 | right: 0.2em;
43 | background-color: var(--background-norm);
44 | border-radius: 25%;
45 | }
46 |
47 | .file-browser-grid-item--select {
48 | position: absolute;
49 | top: 0.2em;
50 | left: 0.2em;
51 | }
52 |
53 | .file-browser-grid-item--select-hover-only {
54 | display: none;
55 | }
56 |
57 | @media (hover: hover) and (pointer: fine) {
58 | .file-browser-list-item:hover .file-browser-list-item--share {
59 | display: flex;
60 | }
61 |
62 | .file-browser-grid-item:hover .file-browser-grid-item--select-hover-only {
63 | display: flex;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/.htaccess:
--------------------------------------------------------------------------------
1 | RewriteEngine On
2 |
3 | # Redirect to https if not coming from https && not forwarded from https && not curl nor any health check user-agent
4 | RewriteCond %{HTTPS} !=on
5 | RewriteCond %{HTTP:X-Forwarded-Proto} !=https
6 | RewriteCond %{HTTP_USER_AGENT} !(^kube-probe|^GoogleHC|^curl)
7 | RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
8 |
9 |
10 | # Redirect nothing to app
11 | RewriteRule ^$ /index.html [NC,L]
12 |
13 | # Hide .git stuff
14 | RewriteRule ^.*?\.git.* /index.html [NC,L]
15 |
16 | RewriteCond %{REQUEST_FILENAME} -s [OR]
17 | RewriteCond %{REQUEST_FILENAME} -l [OR]
18 | RewriteCond %{REQUEST_FILENAME} -d
19 | RewriteRule ^.*$ - [NC,L]
20 |
21 | RewriteRule ^(.*) /index.html [NC,L]
22 |
23 | # Error pages
24 | ErrorDocument 403 /assets/errors/403.html
25 |
26 |
27 | FileETag None
28 | Header unset ETag
29 | Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate"
30 | Header set Pragma "no-cache"
31 | Header set Expires "Wed, 11 Jan 1984 05:00:00 GMT"
32 |
33 |
34 |
35 | AddType application/font-woff2 .woff2
36 |
37 |
38 |
39 | AddOutputFilter INCLUDES;DEFLATE svg
40 |
41 |
42 |
43 | Header set Service-Worker-Allowed "/"
44 | Header set Service-Worker "script"
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/signatureChecks/unsafeSignatures.ts:
--------------------------------------------------------------------------------
1 | import { SupportedMimeTypes } from '../constants';
2 | import { SignatureChecker } from '../helpers';
3 |
4 | export default function unsafeSignatures({ check, sourceBuffer }: ReturnType) {
5 | if (check([0x00, 0x00, 0x01, 0x00]) || check([0x00, 0x00, 0x02, 0x00])) {
6 | return SupportedMimeTypes.ico;
7 | }
8 |
9 | if (check([0x0, 0x0, 0x1, 0xba]) || check([0x0, 0x0, 0x1, 0xb3])) {
10 | return SupportedMimeTypes.mpg;
11 | }
12 |
13 | // Every 188th byte is sync byte, which is a reasonable guess
14 | let isMp2t = true;
15 | for (let offset = 0; offset < sourceBuffer.length; offset += 188) {
16 | if (!check([0x47], { offset })) {
17 | isMp2t = false;
18 | break;
19 | }
20 | }
21 | if (isMp2t) {
22 | return SupportedMimeTypes.mp2t;
23 | }
24 |
25 | // Check for MPEG header at different starting offsets
26 | for (let start = 0; start < 2 && start < sourceBuffer.length - 16; start++) {
27 | // Check MPEG 1 or 2 Layer 3 header, or 'layer 0' for ADTS (MPEG sync-word 0xFFE)
28 | if (sourceBuffer.length >= start + 2 && check([0xff, 0xe0], { offset: start, mask: [0xff, 0xe0] })) {
29 | if (check([0x10], { offset: start + 1, mask: [0x16] })) {
30 | return SupportedMimeTypes.aac;
31 | }
32 | // mp3, mp2 or mp1
33 | if (
34 | check([0x02], { offset: start + 1, mask: [0x06] }) ||
35 | check([0x04], { offset: start + 1, mask: [0x06] }) ||
36 | check([0x06], { offset: start + 1, mask: [0x06] })
37 | ) {
38 | return SupportedMimeTypes.mpeg;
39 | }
40 | }
41 | }
42 |
43 | return undefined;
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Route, Switch } from 'react-router-dom';
3 |
4 | import { ProtonApp, StandardPublicApp, StandardSetup, ModalsChildren, ProminentContainer } from 'react-components';
5 | import locales from 'proton-shared/lib/i18n/locales';
6 | import sentry from 'proton-shared/lib/helpers/sentry';
7 |
8 | import * as config from './config';
9 | import PrivateApp from './PrivateApp';
10 | import DownloadSharedContainer from './components/DowloadShared/DownloadSharedContainer';
11 | import { DownloadProvider } from './components/downloads/DownloadProvider';
12 |
13 | import './app.scss';
14 |
15 | const PublicDriveLinkContainer = () => {
16 | return (
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | const enhancedConfig = {
25 | APP_VERSION_DISPLAY: '4.0.0-beta.16',
26 | ...config,
27 | };
28 |
29 | sentry(enhancedConfig);
30 |
31 | const App = () => {
32 | const [hasInitialAuth] = useState(() => {
33 | return !window.location.pathname.startsWith('/urls');
34 | });
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | export default App;
55 |
--------------------------------------------------------------------------------
/src/app/components/uploads/UploadButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { classnames, FloatingButton, Icon, SidebarPrimaryButton } from 'react-components';
5 |
6 | import { useDriveActiveFolder } from '../Drive/DriveFolderProvider';
7 | import useFileUploadInput from '../../hooks/drive/useFileUploadInput';
8 | import { useDownloadProvider } from '../downloads/DownloadProvider';
9 | import { useUploadProvider } from './UploadProvider';
10 |
11 | interface Props {
12 | floating?: boolean;
13 | className?: string;
14 | }
15 |
16 | const UploadButton = ({ floating, className }: Props) => {
17 | const { folder } = useDriveActiveFolder();
18 | const { inputRef: fileInput, handleClick, handleChange: handleFileChange } = useFileUploadInput();
19 |
20 | const { downloads } = useDownloadProvider();
21 | const { uploads } = useUploadProvider();
22 | const isTransferring = uploads.length > 0 || downloads.length > 0;
23 |
24 | return (
25 | <>
26 |
27 | {floating ? (
28 |
34 |
35 |
36 | ) : (
37 | {c(
38 | 'Action'
39 | ).t`New upload`}
40 | )}
41 | >
42 | );
43 | };
44 | export default UploadButton;
45 |
--------------------------------------------------------------------------------
/src/app/interfaces/transfer.ts:
--------------------------------------------------------------------------------
1 | import { LinkType } from './link';
2 |
3 | export enum TransferState {
4 | Initializing = 'initializing',
5 | Pending = 'pending',
6 | Progress = 'progress',
7 | Finalizing = 'finalizing',
8 | Done = 'done',
9 | Canceled = 'canceled',
10 | Error = 'error',
11 | NetworkError = 'networkError',
12 | Paused = 'paused',
13 | }
14 |
15 | export interface TransferProgresses {
16 | [id: string]: number;
17 | }
18 |
19 | export interface TransferMeta {
20 | filename: string;
21 | mimeType: string;
22 | size?: number;
23 | }
24 |
25 | export interface TransferSummary {
26 | size: number;
27 | progress: number;
28 | }
29 |
30 | export class TransferCancel extends Error {
31 | constructor(options: { id: string } | { message: string }) {
32 | super('id' in options ? `Transfer ${options.id} canceled` : options.message);
33 | this.name = 'TransferCancel';
34 | }
35 | }
36 |
37 | export interface PreUploadData {
38 | file: File;
39 | ShareID: string;
40 | ParentLinkID: string | Promise;
41 | }
42 |
43 | export interface Upload {
44 | id: string;
45 | meta: TransferMeta;
46 | preUploadData: PreUploadData;
47 | state: TransferState;
48 | startDate: Date;
49 | resumeState?: TransferState;
50 | error?: Error;
51 | ready?: boolean;
52 | }
53 |
54 | export interface DownloadInfo {
55 | LinkID: string;
56 | ShareID: string;
57 | }
58 |
59 | export interface Download {
60 | id: string;
61 | meta: TransferMeta;
62 | downloadInfo: DownloadInfo;
63 | state: TransferState;
64 | type: LinkType;
65 | startDate: Date;
66 | resumeState?: TransferState;
67 | error?: Error;
68 | }
69 |
70 | export interface PartialDownload extends Download {
71 | partOf: string;
72 | }
73 |
74 | export type Transfer = Upload | Download;
75 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/signatureChecks/audioSignatures.ts:
--------------------------------------------------------------------------------
1 | import { SupportedMimeTypes } from '../constants';
2 | import { SignatureChecker } from '../helpers';
3 |
4 | export default function audioSignatures({ check, checkString, sourceBuffer }: ReturnType) {
5 | if (checkString('MThd')) {
6 | return SupportedMimeTypes.midi;
7 | }
8 |
9 | if (checkString('fLaC')) {
10 | return SupportedMimeTypes.flac;
11 | }
12 |
13 | if (check([0x52, 0x49, 0x46, 0x46])) {
14 | if (check([0x57, 0x41, 0x56, 0x45], { offset: 8 })) {
15 | return SupportedMimeTypes.wav;
16 | }
17 |
18 | if (check([0x51, 0x4c, 0x43, 0x4d], { offset: 8 })) {
19 | return SupportedMimeTypes.qcp;
20 | }
21 | }
22 |
23 | if (checkString('OggS')) {
24 | if (check([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], { offset: 28 })) {
25 | return SupportedMimeTypes.opus;
26 | }
27 |
28 | if (
29 | check([0x7f, 0x46, 0x4c, 0x41, 0x43], { offset: 28 }) ||
30 | check([0x53, 0x70, 0x65, 0x65, 0x78, 0x20, 0x20], { offset: 28 }) ||
31 | check([0x01, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73], { offset: 28 })
32 | ) {
33 | return SupportedMimeTypes.oga;
34 | }
35 | }
36 |
37 | if (checkString('ftyp', { offset: 4 }) && (sourceBuffer[8] & 0x60) !== 0x00) {
38 | const brandMajor = sourceBuffer.toString('binary', 8, 12).replace('\0', ' ').trim();
39 | switch (brandMajor) {
40 | case 'M4A':
41 | return SupportedMimeTypes.m4a;
42 | case 'M4B':
43 | case 'F4A':
44 | case 'F4B':
45 | return SupportedMimeTypes.mp4a;
46 | default:
47 | return undefined;
48 | }
49 | }
50 |
51 | return undefined;
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/api/sharing.ts:
--------------------------------------------------------------------------------
1 | import { CreateSharedURL, UpdateSharedURL } from '../interfaces/sharing';
2 |
3 | export const queryInitSRPHandshake = (token: string) => {
4 | return {
5 | method: 'get',
6 | url: `drive/urls/${token}/info`,
7 | silence: true,
8 | };
9 | };
10 |
11 | export const queryGetSharedLinkPayload = (
12 | token: string,
13 | pagination?: {
14 | FromBlockIndex: number;
15 | PageSize: number;
16 | }
17 | ) => {
18 | return {
19 | method: 'post',
20 | url: `drive/urls/${token}/file`,
21 | silence: true,
22 | data: pagination,
23 | };
24 | };
25 |
26 | export const queryCreateSharedLink = (shareId: string, data: CreateSharedURL) => {
27 | return {
28 | method: 'post',
29 | url: `drive/shares/${shareId}/urls`,
30 | data,
31 | };
32 | };
33 |
34 | export const querySharedLinks = (shareId: string, params: { Page: number; PageSize?: number; Recursive?: 1 | 0 }) => {
35 | return {
36 | method: 'get',
37 | url: `drive/shares/${shareId}/urls`,
38 | params,
39 | };
40 | };
41 |
42 | export const queryUpdateSharedLink = (shareId: string, token: string, data: Partial) => {
43 | return {
44 | method: 'put',
45 | url: `drive/shares/${shareId}/urls/${token}`,
46 | data,
47 | };
48 | };
49 |
50 | export const queryDeleteSharedLink = (shareId: string, token: string) => {
51 | return {
52 | method: 'delete',
53 | url: `drive/shares/${shareId}/urls/${token}`,
54 | };
55 | };
56 |
57 | export const queryDeleteMultipleSharedLinks = (shareId: string, shareURLIds: string[]) => {
58 | return {
59 | method: 'post',
60 | url: `drive/shares/${shareId}/urls/delete_multiple`,
61 | data: {
62 | ShareURLIDs: shareURLIds,
63 | },
64 | };
65 | };
66 |
--------------------------------------------------------------------------------
/src/app/components/layout/DriveSidebar/DriveSidebarListItem.tsx:
--------------------------------------------------------------------------------
1 | import { noop } from 'proton-shared/lib/helpers/function';
2 | import { wait } from 'proton-shared/lib/helpers/promise';
3 | import React from 'react';
4 | import {
5 | SidebarListItem,
6 | SidebarListItemContent,
7 | SidebarListItemContentIcon,
8 | SidebarListItemLink,
9 | useLoading,
10 | } from 'react-components';
11 | import { useRouteMatch } from 'react-router-dom';
12 | import useDrive from '../../../hooks/drive/useDrive';
13 | import LocationAside from './ReloadSpinner';
14 |
15 | interface Props {
16 | to: string;
17 | icon: string;
18 | children: React.ReactNode;
19 | shareId?: string;
20 | }
21 | const DriveSidebarListItem = ({ to, children, icon, shareId }: Props) => {
22 | const match = useRouteMatch();
23 | const { events } = useDrive();
24 | const [refreshing, withRefreshing] = useLoading(false);
25 |
26 | const isActive = match.path === to;
27 |
28 | const left = icon ? : null;
29 | const right = isActive && shareId && ;
30 |
31 | const handleClick = () => {
32 | if (!refreshing && shareId) {
33 | withRefreshing(Promise.all([events.callAll(shareId), wait(1000)])).catch(noop);
34 | }
35 | };
36 |
37 | return (
38 |
39 | isActive}>
40 |
46 | {children}
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default DriveSidebarListItem;
54 |
--------------------------------------------------------------------------------
/src/app/components/DowloadShared/EnterPasswordInfo.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Button, Label, PasswordInput, useLoading } from 'react-components';
5 |
6 | interface Props {
7 | submitPassword: (password: string) => Promise;
8 | }
9 |
10 | const EnterPasswordInfo = ({ submitPassword }: Props) => {
11 | const [loading, withLoading] = useLoading(false);
12 | const [password, setPassword] = useState('');
13 |
14 | return (
15 | <>
16 | {c('Title').t`Enter file password to download`}
17 |
49 | >
50 | );
51 | };
52 |
53 | export default EnterPasswordInfo;
54 |
--------------------------------------------------------------------------------
/src/app/api/share.ts:
--------------------------------------------------------------------------------
1 | import { FOLDER_PAGE_SIZE, EXPENSIVE_REQUEST_TIMEOUT } from '../constants';
2 | import { MoveLink } from '../interfaces/link';
3 | import { CreateDriveShare } from '../interfaces/share';
4 |
5 | export const queryCreateShare = (volumeID: string, data: CreateDriveShare) => ({
6 | method: 'post',
7 | url: `drive/volumes/${volumeID}/shares`,
8 | data,
9 | });
10 |
11 | export const queryUserShares = (ShowAll = 1) => ({
12 | method: 'get',
13 | url: 'drive/shares',
14 | silence: true,
15 | params: { ShowAll },
16 | });
17 |
18 | export const queryShareMeta = (shareID: string) => ({
19 | method: `get`,
20 | url: `drive/shares/${shareID}`,
21 | });
22 |
23 | export const queryRenameLink = (
24 | shareID: string,
25 | linkID: string,
26 | data: { Name: string; MIMEType?: string; Hash: string; SignatureAddress: string }
27 | ) => ({
28 | method: `put`,
29 | url: `drive/shares/${shareID}/links/${linkID}/rename`,
30 | data,
31 | });
32 |
33 | export const queryTrashList = (
34 | shareID: string,
35 | { Page, PageSize = FOLDER_PAGE_SIZE }: { Page: number; PageSize?: number }
36 | ) => ({
37 | method: 'get',
38 | url: `drive/shares/${shareID}/trash`,
39 | params: { Page, PageSize },
40 | });
41 |
42 | export const queryMoveLink = (shareID: string, linkID: string, data: MoveLink) => ({
43 | method: 'put',
44 | url: `drive/shares/${shareID}/links/${linkID}/move`,
45 | data,
46 | });
47 |
48 | export const queryEvents = (shareID: string, eventID: string) => ({
49 | timeout: EXPENSIVE_REQUEST_TIMEOUT,
50 | url: `drive/shares/${shareID}/events/${eventID}`,
51 | method: 'get',
52 | });
53 |
54 | export const queryLatestEvents = (shareID: string) => ({
55 | url: `drive/shares/${shareID}/events/latest`,
56 | method: 'get',
57 | });
58 |
59 | export const queryDeleteShare = (shareID: string) => ({
60 | url: `drive/shares/${shareID}`,
61 | method: 'delete',
62 | });
63 |
--------------------------------------------------------------------------------
/src/app/interfaces/file.ts:
--------------------------------------------------------------------------------
1 | import { ReadableStream } from 'web-streams-polyfill';
2 |
3 | export enum FileRevisionState {
4 | Draft = 0,
5 | Active = 1,
6 | Inactive = 2,
7 | }
8 |
9 | export interface CreateDriveFile {
10 | Name: string;
11 | Hash: string;
12 | ParentLinkID: string;
13 | NodePassphrase: string;
14 | NodePassphraseSignature: string;
15 | SignatureAddress: string;
16 | NodeKey: string;
17 | MIMEType: string;
18 | ContentKeyPacket: string;
19 | }
20 |
21 | export interface RevisionManifest {
22 | PreviousRootHash: string;
23 | BlockHashes: {
24 | Hash: string;
25 | Index: number;
26 | }[];
27 | }
28 |
29 | export interface UpdateFileRevision {
30 | State: FileRevisionState;
31 | BlockList: { Index: number; Token: string }[];
32 | ManifestSignature: string;
33 | SignatureAddress: string;
34 | }
35 |
36 | export interface CreateFileResult {
37 | File: {
38 | ID: string;
39 | RevisionID: string;
40 | };
41 | }
42 |
43 | export interface UploadLink {
44 | Token: string;
45 | URL: string;
46 | }
47 |
48 | export interface RequestUploadResult {
49 | UploadLinks: UploadLink[];
50 | ThumbnailLink?: UploadLink;
51 | }
52 |
53 | export interface DriveFileBlock {
54 | Index: number;
55 | URL: string;
56 | EncSignature?: string;
57 | }
58 |
59 | export interface DriveFileRevision {
60 | ID: string;
61 | CreateTime: number;
62 | Size: number;
63 | Hash: string;
64 | State: number;
65 | RootHash: string;
66 | RootHashSignature: string;
67 | SignatureAddress: string;
68 | Blocks: DriveFileBlock[];
69 | }
70 |
71 | export interface DriveFileRevisionResult {
72 | Revision: DriveFileRevision;
73 | }
74 |
75 | export interface DriveFileRevisionThumbnailResult {
76 | ThumbnailLink: string;
77 | }
78 |
79 | export interface NestedFileStream {
80 | stream: ReadableStream;
81 | parentPath: string;
82 | fileName: string;
83 | }
84 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/useFileBrowserView.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback, useState } from 'react';
2 |
3 | interface Options {
4 | clearSelections: () => void;
5 | }
6 |
7 | function useFileBrowserView({ clearSelections }: Options) {
8 | const [secondaryActionActive, setSecondaryActionActive] = useState(false);
9 | const [isContextMenuOpen, setIsOpen] = useState(false);
10 | const [contextMenuPosition, setContextMenuPosition] = useState<{ top: number; left: number }>();
11 |
12 | useEffect(() => {
13 | const handleKeyDown = (e: KeyboardEvent) => {
14 | const newSecondaryActionIsActive = e.shiftKey || e.metaKey || e.ctrlKey;
15 | if (newSecondaryActionIsActive !== secondaryActionActive) {
16 | setSecondaryActionActive(newSecondaryActionIsActive);
17 | }
18 | };
19 |
20 | window.addEventListener('keydown', handleKeyDown);
21 | window.addEventListener('keyup', handleKeyDown);
22 |
23 | return () => {
24 | window.removeEventListener('keydown', handleKeyDown);
25 | window.addEventListener('keyup', handleKeyDown);
26 | };
27 | }, [secondaryActionActive]);
28 |
29 | const openContextMenu = useCallback(() => {
30 | setIsOpen(true);
31 | }, []);
32 |
33 | const closeContextMenu = useCallback(() => {
34 | setIsOpen(false);
35 | }, []);
36 |
37 | const handleContextMenu = useCallback((e: React.MouseEvent) => {
38 | e.stopPropagation();
39 | e.preventDefault();
40 |
41 | clearSelections();
42 |
43 | if (isContextMenuOpen) {
44 | closeContextMenu();
45 | }
46 |
47 | setContextMenuPosition({ top: e.clientY, left: e.clientX });
48 | }, []);
49 |
50 | return {
51 | isContextMenuOpen,
52 | handleContextMenu,
53 | openContextMenu,
54 | closeContextMenu,
55 | contextMenuPosition,
56 | secondaryActionActive,
57 | };
58 | }
59 |
60 | export default useFileBrowserView;
61 |
--------------------------------------------------------------------------------
/src/app/components/FilesDetailsModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { Row, Label, Field, DialogModal, HeaderModal, InnerModal, FooterModal, PrimaryButton } from 'react-components';
5 |
6 | import { FileBrowserItem } from './FileBrowser/interfaces';
7 | import SizeCell from './FileBrowser/ListView/Cells/SizeCell';
8 |
9 | interface Props {
10 | selectedItems: FileBrowserItem[];
11 | onClose?: () => void;
12 | }
13 |
14 | const FilesDetailsModal = ({ selectedItems, onClose, ...rest }: Props) => {
15 | const modalTitleID = 'files-details-modal';
16 | const size = selectedItems.reduce((sum, current) => sum + current.Size, 0);
17 |
18 | return (
19 |
20 |
21 | {c('Title').t`Files details`}
22 |
23 |
24 |
25 |
26 |
27 |
28 | {selectedItems.length}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {c('Action').t`Close`}
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default FilesDetailsModal;
51 |
--------------------------------------------------------------------------------
/src/app/components/FilesRecoveryModal/FilesRecoveryState.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { HeaderModal, InnerModal, FooterModal, Button, Alert, PrimaryButton } from 'react-components';
5 |
6 | import keyAndFileSvg from 'design-system/assets/img/placeholders/file-recovery.svg';
7 |
8 | interface Props {
9 | onRecovery: () => void;
10 | onClose?: () => void;
11 | recovering?: boolean;
12 | }
13 |
14 | const FilesRecoveryState = ({ onRecovery, onClose, recovering }: Props) => {
15 | const modalTitleID = 'files-recovery-modal';
16 | const title = c('Title').t`Restore your files`;
17 |
18 | return (
19 | <>
20 |
21 | {c('Title').t`File recovery process`}
22 |
23 |
24 |
25 |
26 |

27 |
28 |
29 | {c('Info').jt`Would you like to restore your files?`}
30 | {c('Info').jt`Recovery process might take some time.`}
31 |
32 |
33 |
34 |
35 |
37 |
38 | {c('Action').t`Start recovering`}
39 |
40 |
41 |
42 |
43 | >
44 | );
45 | };
46 |
47 | export default FilesRecoveryState;
48 |
--------------------------------------------------------------------------------
/src/app/components/Drive/Trash/Trash.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useRef } from 'react';
2 | import { c } from 'ttag';
3 | import EmptyTrash from '../../FileBrowser/EmptyTrash';
4 | import useOnScrollEnd from '../../../hooks/util/useOnScrollEnd';
5 | import { useTrashContent } from './TrashContentProvider';
6 | import FileBrowser from '../../FileBrowser/FileBrowser';
7 | import useUserSettings from '../../../hooks/drive/useUserSettings';
8 |
9 | interface Props {
10 | shareId: string;
11 | }
12 |
13 | function Trash({ shareId }: Props) {
14 | const { layout } = useUserSettings();
15 | const scrollAreaRef = useRef(null);
16 | const { loadNextPage, loading, initialized, complete, contents, fileBrowserControls } = useTrashContent();
17 |
18 | const {
19 | clearSelections,
20 | selectedItems,
21 | selectItem,
22 | toggleSelectItem,
23 | toggleAllSelected,
24 | toggleRange,
25 | } = fileBrowserControls;
26 |
27 | const handleScrollEnd = useCallback(() => {
28 | // Only load on scroll after initial load from backend
29 | if (initialized && !complete) {
30 | loadNextPage();
31 | }
32 | }, [initialized, complete, loadNextPage, layout]);
33 |
34 | // On content change, check scroll end (does not rebind listeners)
35 | useOnScrollEnd(handleScrollEnd, scrollAreaRef, 0.9, [contents, layout]);
36 |
37 | return complete && !contents.length && !loading ? (
38 |
39 | ) : (
40 |
55 | );
56 | }
57 |
58 | export default Trash;
59 |
--------------------------------------------------------------------------------
/src/app/components/SharedLinks/SharedLinks.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useCallback } from 'react';
2 | import { c } from 'ttag';
3 | import useUserSettings from '../../hooks/drive/useUserSettings';
4 | import FileBrowser from '../FileBrowser/FileBrowser';
5 | import { useSharedLinksContent } from './SharedLinksContentProvider';
6 | import useOnScrollEnd from '../../hooks/util/useOnScrollEnd';
7 | import EmptyShared from '../FileBrowser/EmptyShared';
8 |
9 | type Props = {
10 | shareId: string;
11 | };
12 |
13 | const SharedLinks = ({ shareId }: Props) => {
14 | const { layout } = useUserSettings();
15 | const scrollAreaRef = useRef(null);
16 | const { loadNextPage, loading, initialized, complete, contents, fileBrowserControls } = useSharedLinksContent();
17 |
18 | const {
19 | clearSelections,
20 | selectedItems,
21 | selectItem,
22 | toggleSelectItem,
23 | toggleAllSelected,
24 | toggleRange,
25 | } = fileBrowserControls;
26 |
27 | const handleScrollEnd = useCallback(() => {
28 | // Only load on scroll after initial load from backend
29 | if (initialized && !complete) {
30 | loadNextPage();
31 | }
32 | }, [initialized, complete, loadNextPage, layout]);
33 |
34 | // On content change, check scroll end (does not rebind listeners)
35 | useOnScrollEnd(handleScrollEnd, scrollAreaRef, 0.9, [contents, layout]);
36 |
37 | return complete && !contents.length && !loading ? (
38 |
39 | ) : (
40 |
55 | );
56 | };
57 |
58 | export default SharedLinks;
59 |
--------------------------------------------------------------------------------
/src/app/utils/validation.ts:
--------------------------------------------------------------------------------
1 | import { c } from 'ttag';
2 | import { MAX_NAME_LENGTH } from '../constants';
3 | import { GLOBAL_FORBIDDEN_CHARACTERS } from './link';
4 |
5 | export class ValidationError extends Error {
6 | constructor(message: string) {
7 | super(message);
8 | this.name = 'ValidationError';
9 | }
10 | }
11 |
12 | const composeValidators = (validators: ((value: T) => string | undefined)[]) => (value: T) => {
13 | for (const validator of validators) {
14 | const result = validator(value);
15 | if (result) {
16 | return result;
17 | }
18 | }
19 | return undefined;
20 | };
21 |
22 | const validateSpaceEnd = (str: string) => {
23 | return str.endsWith(' ') ? c('Validation Error').t`Name must not end with a space` : undefined;
24 | };
25 |
26 | const validateSpaceStart = (str: string) => {
27 | return str.startsWith(' ') ? c('Validation Error').t`Name must not begin with a space` : undefined;
28 | };
29 |
30 | const validateNameLength = (str: string) => {
31 | return str.length > MAX_NAME_LENGTH
32 | ? c('Validation Error').t`Name must be ${MAX_NAME_LENGTH} characters long at most`
33 | : undefined;
34 | };
35 |
36 | const validateInvalidName = (str: string) => {
37 | return ['.', '..'].includes(str) ? c('Validation Error').t`"${str}" is not a valid name` : undefined;
38 | };
39 |
40 | const validateInvalidCharacters = (str: string) => {
41 | return RegExp(GLOBAL_FORBIDDEN_CHARACTERS, 'u').test(str)
42 | ? c('Validation Error').t`Name cannot include invisible characters, / or \\.`
43 | : undefined;
44 | };
45 |
46 | const validateNameEmpty = (str: string) => {
47 | return !str ? c('Validation Error').t`Name must not be empty` : undefined;
48 | };
49 |
50 | export const validateLinkName = composeValidators([
51 | validateNameEmpty,
52 | validateNameLength,
53 | validateSpaceStart,
54 | validateSpaceEnd,
55 | validateInvalidName,
56 | validateInvalidCharacters,
57 | ]);
58 |
59 | export const validateLinkNameField = composeValidators([
60 | validateNameEmpty,
61 | validateNameLength,
62 | validateInvalidName,
63 | validateInvalidCharacters,
64 | ]);
65 |
--------------------------------------------------------------------------------
/src/app/components/AppErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useLocation } from 'react-router-dom';
3 |
4 | import {
5 | ErrorBoundary,
6 | GenericError,
7 | generateUID,
8 | InternalServerError,
9 | NotFoundError,
10 | AccessDeniedError,
11 | PrivateMainArea,
12 | } from 'react-components';
13 | import { ApiError } from 'proton-shared/lib/fetch/ApiError';
14 |
15 | import { useDriveActiveFolder } from './Drive/DriveFolderProvider';
16 | import { STATUS_CODE } from '../constants';
17 |
18 | interface Props {
19 | children: React.ReactNode;
20 | }
21 |
22 | const AppErrorBoundary = ({ children }: Props) => {
23 | const location = useLocation();
24 | const { setFolder } = useDriveActiveFolder();
25 | const [state, setState] = useState<{ id: string; error?: Error }>({
26 | id: generateUID('error-boundary'),
27 | });
28 |
29 | useEffect(() => {
30 | if (state.error) {
31 | setState({ id: generateUID('error-boundary') });
32 | }
33 | }, [location]);
34 |
35 | const handleError = (error: Error) => {
36 | setState((prev) => ({ ...prev, error }));
37 | setFolder(undefined);
38 | };
39 |
40 | const renderError = () => {
41 | const { error } = state;
42 | if (!error) {
43 | return null;
44 | }
45 |
46 | if (error instanceof ApiError) {
47 | if (error.status === STATUS_CODE.INTERNAL_SERVER_ERROR) {
48 | return ;
49 | }
50 | if (error.status === STATUS_CODE.NOT_FOUND) {
51 | return ;
52 | }
53 | if (error.status === STATUS_CODE.FORBIDDEN) {
54 | return ;
55 | }
56 | }
57 |
58 | return ;
59 | };
60 |
61 | return (
62 | {renderError()}}
66 | >
67 | {children}
68 |
69 | );
70 | };
71 |
72 | export default AppErrorBoundary;
73 |
--------------------------------------------------------------------------------
/src/app/hooks/drive/useUserSettings.ts:
--------------------------------------------------------------------------------
1 | import { useContext, useCallback } from 'react';
2 | import { useApi } from 'react-components';
3 | import { queryUpdateUserSettings, queryUserSettings } from '../../api/userSettings';
4 | import { UserSettingsContext } from '../../components/Drive/UserSettings/UserSettingsProvider';
5 | import { DEFAULT_USER_SETTINGS } from '../../constants';
6 | import { LayoutSetting, SortSetting, UserSettings } from '../../interfaces/userSettings';
7 |
8 | type UserSettingsResponse = { UserSettings: Partial };
9 |
10 | const useUserSettings = () => {
11 | const contextState = useContext(UserSettingsContext);
12 | const api = useApi();
13 |
14 | if (!contextState) {
15 | throw new Error('Trying to use uninitialized UserSettingsProvider');
16 | }
17 |
18 | const [userSettings, setUserSettings] = contextState;
19 |
20 | const loadUserSettings = async () => {
21 | const { UserSettings } = await api(queryUserSettings());
22 |
23 | const userSettingsWithDefaults = Object.entries(UserSettings).reduce((settings, [key, value]) => {
24 | (settings as any)[key] = value ?? (DEFAULT_USER_SETTINGS as any)[key];
25 | return settings;
26 | }, {} as UserSettings);
27 |
28 | setUserSettings(userSettingsWithDefaults);
29 | };
30 |
31 | const sort = userSettings.Sort;
32 | const layout = userSettings.Layout;
33 |
34 | const changeLayout = useCallback(async (Layout: LayoutSetting) => {
35 | setUserSettings((settings) => ({ ...settings, Layout }));
36 | await api(
37 | queryUpdateUserSettings({
38 | Layout,
39 | })
40 | );
41 | }, []);
42 |
43 | const changeSort = useCallback(async (Sort: SortSetting) => {
44 | setUserSettings((settings) => ({ ...settings, Sort }));
45 | await api(
46 | queryUpdateUserSettings({
47 | Sort,
48 | })
49 | );
50 | }, []);
51 |
52 | return {
53 | sort,
54 | layout,
55 | loadUserSettings,
56 | changeLayout,
57 | changeSort,
58 | };
59 | };
60 |
61 | export default useUserSettings;
62 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/EmptyFolder.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { IllustrationPlaceholder, usePopperAnchor } from 'react-components';
5 |
6 | import noContentSvg from 'design-system/assets/img/placeholders/empty-folder.svg';
7 | import UploadButton from '../uploads/UploadButton';
8 | import FolderContextMenu from './FolderContextMenu';
9 |
10 | const EmptyFolder = () => {
11 | const { anchorRef, isOpen, open, close } = usePopperAnchor();
12 | const [contextMenuPosition, setContextMenuPosition] = useState<{ top: number; left: number }>();
13 |
14 | useEffect(() => {
15 | if (!anchorRef.current) {
16 | return;
17 | }
18 |
19 | const handleContextMenu = (ev: MouseEvent) => {
20 | ev.stopPropagation();
21 | ev.preventDefault();
22 |
23 | if (isOpen) {
24 | close();
25 | }
26 |
27 | setContextMenuPosition({ top: ev.clientY, left: ev.clientX });
28 | };
29 |
30 | anchorRef.current.addEventListener('contextmenu', handleContextMenu);
31 |
32 | return () => {
33 | anchorRef.current?.removeEventListener('contextmenu', handleContextMenu);
34 | };
35 | }, [anchorRef, isOpen, close, setContextMenuPosition]);
36 |
37 | return (
38 | <>
39 |
40 |
41 | {c('Info').t`Drag and drop a file here or choose to upload.`}
42 |
43 |
44 |
45 |
46 |
47 |
54 | >
55 | );
56 | };
57 |
58 | export default EmptyFolder;
59 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/signatureChecks/videoSignatures.ts:
--------------------------------------------------------------------------------
1 | import { SupportedMimeTypes } from '../constants';
2 | import { SignatureChecker } from '../helpers';
3 |
4 | export default function videoSignatures({ check, checkString, sourceBuffer }: ReturnType) {
5 | if (check([0x52, 0x49, 0x46, 0x46]) && check([0x41, 0x56, 0x49], { offset: 8 })) {
6 | return SupportedMimeTypes.avi;
7 | }
8 |
9 | if (
10 | checkString('OggS') &&
11 | (check([0x80, 0x74, 0x68, 0x65, 0x6f, 0x72, 0x61], { offset: 28 }) ||
12 | check([0x01, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x00], { offset: 28 }))
13 | ) {
14 | return SupportedMimeTypes.ogv;
15 | }
16 |
17 | if (check([0x00, 0x00, 0x01, 0xba])) {
18 | if (check([0x21], { offset: 4, mask: [0xf1] })) {
19 | return SupportedMimeTypes.mp1s;
20 | }
21 | if (check([0x44], { offset: 4, mask: [0xc4] })) {
22 | return SupportedMimeTypes.mp2p;
23 | }
24 | }
25 |
26 | if (
27 | check([0x66, 0x72, 0x65, 0x65], { offset: 4 }) ||
28 | check([0x6d, 0x64, 0x61, 0x74], { offset: 4 }) ||
29 | check([0x6d, 0x6f, 0x6f, 0x76], { offset: 4 }) ||
30 | check([0x77, 0x69, 0x64, 0x65], { offset: 4 })
31 | ) {
32 | return SupportedMimeTypes.qt;
33 | }
34 |
35 | if (checkString('ftyp', { offset: 4 }) && (sourceBuffer[8] & 0x60) !== 0x00) {
36 | const brandMajor = sourceBuffer.toString('binary', 8, 12).replace('\0', ' ').trim();
37 | switch (brandMajor) {
38 | case 'qt':
39 | return SupportedMimeTypes.qt;
40 | case 'M4V':
41 | case 'M4VH':
42 | case 'M4VP':
43 | return SupportedMimeTypes.m4v;
44 | case 'M4P':
45 | case 'F4V':
46 | case 'F4P':
47 | return SupportedMimeTypes.mp4v;
48 | default:
49 | if (brandMajor.startsWith('3g')) {
50 | if (brandMajor.startsWith('3g2')) {
51 | return SupportedMimeTypes.v3g2;
52 | }
53 | return SupportedMimeTypes.v3gp;
54 | }
55 | return SupportedMimeTypes.mp4v;
56 | }
57 | }
58 |
59 | return undefined;
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/FileBrowser.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FileBrowserProps } from './interfaces';
3 | import ListView from './ListView/ListView';
4 | import GridView from './GridView/GridView';
5 | import { LayoutSetting } from '../../interfaces/userSettings';
6 |
7 | /**
8 | * File browser that supports grid view and list view
9 | * If only grid or list view is needed better use them directly
10 | */
11 | const FileBrowser = ({
12 | layout,
13 | loading,
14 | caption,
15 | contents,
16 | shareId,
17 | scrollAreaRef,
18 | selectedItems,
19 | type,
20 | isPreview = false,
21 | onToggleItemSelected,
22 | onToggleAllSelected,
23 | onItemClick,
24 | selectItem,
25 | clearSelections,
26 | onShiftClick,
27 | sortParams,
28 | setSorting,
29 | getDragMoveControls,
30 | }: FileBrowserProps) => {
31 | return layout === LayoutSetting.Grid && type !== 'sharing' ? (
32 |
46 | ) : (
47 |
66 | );
67 | };
68 |
69 | export default FileBrowser;
70 |
--------------------------------------------------------------------------------
/src/app/hooks/util/useDebouncedRequest.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/display-name */
2 | import React from 'react';
3 | import { renderHook } from '@testing-library/react-hooks';
4 | import { useApi, CacheProvider } from 'react-components';
5 | import createCache from 'proton-shared/lib/helpers/cache';
6 | import { Api } from 'proton-shared/lib/interfaces';
7 | import useDebouncedRequest from './useDebouncedRequest';
8 |
9 | jest.mock('react-components/hooks/useApi');
10 |
11 | const mockUseApi = useApi as jest.MockedFunction;
12 |
13 | describe('useDebouncedRequest', () => {
14 | let debouncedRequest: Api;
15 | const mockApi = jest.fn().mockImplementation((query: object) => Promise.resolve(query));
16 | mockUseApi.mockReturnValue(mockApi);
17 |
18 | beforeEach(() => {
19 | const cache = createCache();
20 | const { result } = renderHook(() => useDebouncedRequest(), {
21 | wrapper: ({ children }: { children?: React.ReactNode }) => (
22 | {children}
23 | ),
24 | });
25 | debouncedRequest = result.current;
26 | mockApi.mockClear();
27 | });
28 |
29 | it('should initially call debounced function instantly', async () => {
30 | await debouncedRequest({ test: 'test' });
31 |
32 | expect(mockApi).toHaveBeenCalledTimes(1);
33 | });
34 |
35 | it('should return initial call result if called while pending', async () => {
36 | const firstCall = debouncedRequest({ test: 'test' });
37 | const secondCall = debouncedRequest({ test: 'test' });
38 | const firstCallResult = await firstCall;
39 | const secondCallResult = await secondCall;
40 |
41 | expect(mockApi).toHaveBeenCalledTimes(1);
42 | expect(secondCallResult).toBe(firstCallResult);
43 | });
44 |
45 | it('should return new call result if called after initial call is completed', async () => {
46 | const firstCall = debouncedRequest({ test: 'test' });
47 | const firstCallResult = await firstCall;
48 | const secondCall = debouncedRequest({ test: 'test' });
49 | const secondCallResult = await secondCall;
50 |
51 | expect(mockApi).toHaveBeenCalledTimes(2);
52 | expect(secondCallResult).not.toBe(firstCallResult);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/constants.ts:
--------------------------------------------------------------------------------
1 | export enum SupportedMimeTypes {
2 | flac = 'audio/x-flac',
3 | mp2t = 'video/mp2t',
4 | gzip = 'application/gzip',
5 | bmp = 'image/bmp',
6 | tar = 'application/x-tar',
7 | rar = 'application/vnd.rar',
8 | swf = 'application/x-shockwave-flash',
9 | rtf = 'application/rtf',
10 | bzip2 = 'application/x-bzip2',
11 | eot = 'application/vnd.ms-fontobject',
12 | png = 'image/png',
13 | pdf = 'application/pdf',
14 | apng = 'image/apng',
15 | gif = 'image/gif',
16 | jpg = 'image/jpeg',
17 | ico = 'image/x-icon',
18 | tiff = 'image/tiff',
19 | mpg = 'video/mpeg',
20 | arc = 'application/x-freearc',
21 | otf = 'font/otf',
22 | ogg = 'application/ogg',
23 | ogv = 'video/ogg',
24 | oga = 'audio/ogg',
25 | opus = 'audio/opus',
26 | midi = 'audio/midi',
27 | ttf = 'font/ttf',
28 | wav = 'audio/wav',
29 | avi = 'video/x-msvideo',
30 | qcp = 'audio/qcelp',
31 | webp = 'image/webp',
32 | woff = 'font/woff',
33 | woff2 = 'font/woff2',
34 | xml = 'text/xml',
35 | x7zip = 'application/x-7z-compressed',
36 | aac = 'audio/aac',
37 | mpeg = 'audio/mpeg',
38 | zip = 'application/zip',
39 | docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
40 | pptx = 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
41 | xlsx = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
42 | epub = 'application/epub+zip',
43 | odt = 'application/vnd.oasis.opendocument.text',
44 | ods = 'application/vnd.oasis.opendocument.spreadsheet',
45 | odp = 'application/vnd.oasis.opendocument.presentation',
46 | flv = 'video/x-flv',
47 | avif = 'image/avif',
48 | heif = 'image/heif',
49 | heifs = 'image/heif-sequence',
50 | heic = 'image/heic',
51 | heics = 'image/heic-sequence',
52 | cr3 = 'image/x-canon-cr3',
53 | qt = 'video/quicktime',
54 | m4v = 'video/x-m4v',
55 | mp4v = 'video/mp4',
56 | m4a = 'audio/x-m4a',
57 | mp4a = 'audio/mp4',
58 | v3g2 = 'video/3gpp2',
59 | v3gp = 'video/3gpp',
60 | mp1s = 'video/MP1S',
61 | mp2p = 'video/MP2P',
62 | }
63 |
64 | export const EXTRA_EXTENSION_TYPES: { [ext: string]: string } = {
65 | py: 'text/x-python',
66 | ts: 'application/typescript',
67 | };
68 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/MimeTypeParser.ts:
--------------------------------------------------------------------------------
1 | import ChunkFileReader from '../../components/uploads/ChunkFileReader';
2 | import { SupportedMimeTypes } from './constants';
3 | import { mimetypeFromExtension, SignatureChecker } from './helpers';
4 | import applicationSignatures from './signatureChecks/applicationSignatures';
5 | import archiveSignatures from './signatureChecks/archiveSignatures';
6 | import audioSignatures from './signatureChecks/audioSignatures';
7 | import fontSignatures from './signatureChecks/fontSignatures';
8 | import imageSignatures from './signatureChecks/imageSignatures';
9 | import unsafeSignatures from './signatureChecks/unsafeSignatures';
10 | import videoSignatures from './signatureChecks/videoSignatures';
11 |
12 | // Many mime-types can be detected within this range
13 | const minimumBytesToCheck = 4100;
14 |
15 | function mimeTypeFromSignature(checker: ReturnType): SupportedMimeTypes | undefined {
16 | return (
17 | imageSignatures(checker) || // before audio
18 | audioSignatures(checker) || // before video
19 | videoSignatures(checker) || // before application
20 | applicationSignatures(checker) ||
21 | archiveSignatures(checker) ||
22 | fontSignatures(checker) ||
23 | unsafeSignatures(checker)
24 | );
25 | }
26 |
27 | export function mimeTypeFromBuffer(input: Uint8Array | ArrayBuffer | Buffer) {
28 | const sourceBuffer = input instanceof Buffer ? input : Buffer.from(input);
29 |
30 | if (sourceBuffer.length < 2) {
31 | return undefined;
32 | }
33 |
34 | return mimeTypeFromSignature(SignatureChecker(sourceBuffer));
35 | }
36 |
37 | export async function mimeTypeFromFile(input: File, extensionFallback = true) {
38 | const reader = new ChunkFileReader(input, minimumBytesToCheck);
39 | const defaultType = 'application/octet-stream';
40 |
41 | if (reader.isEOF()) {
42 | return defaultType;
43 | }
44 |
45 | const chunk = await reader.readNextChunk();
46 | const extension = input.name.split('.').pop();
47 | const isSVG = extension && extension.toLowerCase() === 'svg';
48 |
49 | return (
50 | (isSVG && 'image/svg+xml') ||
51 | mimeTypeFromSignature(SignatureChecker(Buffer.from(chunk))) ||
52 | (extensionFallback && mimetypeFromExtension(input.name)) ||
53 | defaultType
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/hooks/util/useQueuedFunction.ts:
--------------------------------------------------------------------------------
1 | import { noop } from 'proton-shared/lib/helpers/function';
2 | import { useCache } from 'react-components';
3 |
4 | type FunctionQueue = [number, (() => Promise)[]];
5 |
6 | /**
7 | * Puts function execution into a queue with a threshold of maximum active functions processing at once
8 | */
9 | const useQueuedFunction = () => {
10 | const cache = useCache();
11 |
12 | const queuedFunction = (fnKey: string, fn: (...args: A) => Promise, threshold = 1) => {
13 | const key = `queuedfn_${fnKey}`;
14 |
15 | if (!cache.has(key)) {
16 | cache.set(key, [0, []]);
17 | }
18 |
19 | const runNextQueued = () => {
20 | const [processing, queued]: FunctionQueue = cache.get(key);
21 |
22 | if (queued.length) {
23 | const [next, ...remaining] = queued;
24 | next().catch(noop).finally(runNextQueued);
25 | cache.set(key, [processing, remaining]);
26 | } else {
27 | cache.set(key, [processing - 1, []]);
28 | }
29 | };
30 |
31 | const run = (...args: A) => {
32 | const [processing, queued]: FunctionQueue = cache.get(key);
33 | cache.set(key, [processing + 1, queued]);
34 | const promise = fn(...args);
35 | promise.catch(noop).finally(runNextQueued);
36 | return promise;
37 | };
38 |
39 | const enqueue = (...args: A) =>
40 | new Promise((resolve) => {
41 | const [processing, queued]: FunctionQueue = cache.get(key);
42 | cache.set(key, [
43 | processing,
44 | [
45 | ...queued,
46 | () => {
47 | const promise = fn(...args);
48 | resolve(promise);
49 | return promise;
50 | },
51 | ],
52 | ]);
53 | });
54 |
55 | return (...args: A) => {
56 | const [processing]: FunctionQueue = cache.get(key);
57 | return processing < threshold ? run(...args) : enqueue(...args);
58 | };
59 | };
60 |
61 | return queuedFunction;
62 | };
63 |
64 | export default useQueuedFunction;
65 |
--------------------------------------------------------------------------------
/src/app/hooks/drive/useStatsHistory.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect } from 'react';
2 |
3 | import { TransfersStats, TransferStats } from '../../components/TransferManager/interfaces';
4 | import { Transfer, TransferProgresses, TransferState } from '../../interfaces/transfer';
5 | import { isTransferProgress } from '../../utils/transfer';
6 |
7 | const PROGRESS_UPDATE_INTERVAL = 500;
8 | const SPEED_SNAPSHOTS = 10; // How many snapshots should the speed be average of
9 |
10 | function useStatsHistory(transfers: Transfer[], getTransferProgresses: () => TransferProgresses) {
11 | const [statsHistory, setStatsHistory] = useState([]);
12 |
13 | const getTransfer = useCallback((id: string) => transfers.find((transfer) => transfer.id === id), [transfers]);
14 |
15 | const updateStats = () => {
16 | const timestamp = new Date();
17 | const transferProgresses = getTransferProgresses();
18 |
19 | setStatsHistory((prev) => {
20 | const lastStats = (id: string) => prev[0]?.stats[id] || {};
21 | const stats = Object.entries(transferProgresses).reduce(
22 | (stats, [id, progress]) => ({
23 | ...stats,
24 | [id]: {
25 | // get speed snapshot based on bytes downloaded since last update
26 | speed: lastStats(id).active
27 | ? (transferProgresses[id] - lastStats(id).progress) * (1000 / PROGRESS_UPDATE_INTERVAL)
28 | : 0,
29 | active: getTransfer(id)?.state === TransferState.Progress,
30 | progress,
31 | },
32 | }),
33 | {} as { [id: string]: TransferStats }
34 | );
35 |
36 | return [{ stats, timestamp }, ...prev.slice(0, SPEED_SNAPSHOTS - 1)];
37 | });
38 | };
39 |
40 | useEffect(() => {
41 | updateStats();
42 |
43 | const transfersInProgress = transfers.filter(isTransferProgress);
44 |
45 | if (!transfersInProgress.length) {
46 | return;
47 | }
48 |
49 | const int = setInterval(updateStats, PROGRESS_UPDATE_INTERVAL);
50 |
51 | return () => {
52 | clearInterval(int);
53 | };
54 | }, [transfers]);
55 |
56 | return statsHistory;
57 | }
58 |
59 | export default useStatsHistory;
60 |
--------------------------------------------------------------------------------
/src/app/components/Drive/ToolbarButtons/LayoutDropdown.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 | import {
4 | usePopperAnchor,
5 | Dropdown,
6 | DropdownMenu,
7 | Icon,
8 | DropdownMenuButton,
9 | ToolbarButton,
10 | DropdownCaret,
11 | } from 'react-components';
12 | import useUserSettings from '../../../hooks/drive/useUserSettings';
13 | import { LayoutSetting } from '../../../interfaces/userSettings';
14 |
15 | const LayoutDropdown = () => {
16 | const { layout, changeLayout } = useUserSettings();
17 | const { anchorRef, isOpen, toggle, close } = usePopperAnchor();
18 |
19 | const id = `dropdown-layout`;
20 |
21 | return (
22 | <>
23 | }
29 | data-testid="toolbar-layout"
30 | title={c('Title').t`Change layout`}
31 | >
32 |
33 |
34 |
35 |
36 | changeLayout(LayoutSetting.List)}
40 | >
41 |
42 | {c('Action').t`List layout`}
43 |
44 | changeLayout(LayoutSetting.Grid)}
48 | >
49 |
50 | {c('Action').t`Grid layout`}
51 |
52 |
53 |
54 | >
55 | );
56 | };
57 |
58 | export default LayoutDropdown;
59 |
--------------------------------------------------------------------------------
/src/app/api/files.ts:
--------------------------------------------------------------------------------
1 | import { CreateDriveFile, UpdateFileRevision } from '../interfaces/file';
2 | import { UPLOAD_TIMEOUT } from '../constants';
3 |
4 | export const queryCreateFile = (shareId: string, data: CreateDriveFile) => {
5 | return {
6 | method: 'post',
7 | timeout: UPLOAD_TIMEOUT,
8 | url: `drive/shares/${shareId}/files`,
9 | silence: true,
10 | data,
11 | };
12 | };
13 |
14 | export const queryFileRevision = (
15 | shareId: string,
16 | linkId: string,
17 | revisionId: number,
18 | pagination?: { FromBlockIndex: number; PageSize: number }
19 | ) => {
20 | const query = {
21 | method: 'get',
22 | url: `drive/shares/${shareId}/files/${linkId}/revisions/${revisionId}`,
23 | silence: true,
24 | };
25 |
26 | if (pagination) {
27 | return {
28 | ...query,
29 | params: pagination,
30 | };
31 | }
32 |
33 | return query;
34 | };
35 |
36 | export const queryFileRevisionThumbnail = (
37 | shareId: string,
38 | linkId: string,
39 | revisionId: number,
40 | ) => {
41 | return {
42 | method: 'get',
43 | url: `drive/shares/${shareId}/files/${linkId}/revisions/${revisionId}/thumbnail`,
44 | silence: true,
45 | };
46 | };
47 |
48 | export const queryRequestUpload = (data: {
49 | BlockList: { Hash: string; EncSignature: string; Size: number; Index: number }[];
50 | AddressID: string;
51 | ShareID: string;
52 | LinkID: string;
53 | RevisionID: string;
54 | Thumbnail?: number;
55 | ThumbnailHash?: string;
56 | ThumbnailSize?: number;
57 | }) => {
58 | return {
59 | method: 'post',
60 | url: 'drive/blocks',
61 | data,
62 | };
63 | };
64 |
65 | export const queryFileBlock = (url: string) => {
66 | return {
67 | method: 'get',
68 | output: 'stream',
69 | credentials: 'omit',
70 | url,
71 | };
72 | };
73 |
74 | export const queryUploadFileBlock = (url: string, chunk: Uint8Array) => {
75 | return {
76 | method: 'put',
77 | input: 'binary',
78 | data: new Blob([chunk]),
79 | url,
80 | };
81 | };
82 |
83 | export const queryUpdateFileRevision = (
84 | shareID: string,
85 | linkID: string,
86 | revisionID: string,
87 | data: UpdateFileRevision
88 | ) => {
89 | return {
90 | method: 'put',
91 | url: `drive/shares/${shareID}/files/${linkID}/revisions/${revisionID}`,
92 | data,
93 | };
94 | };
95 |
--------------------------------------------------------------------------------
/src/app/containers/SharedURLsContainer/SharedURLsContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useCallback } from 'react';
2 | import { RouteComponentProps } from 'react-router-dom';
3 | import { Switch, Route, Redirect } from 'react-router';
4 | import { PrivateAppContainer, useToggle, MainLogo } from 'react-components';
5 | import AppErrorBoundary from '../../components/AppErrorBoundary';
6 | import DriveHeader from '../../components/layout/DriveHeader';
7 | import DriveSidebar from '../../components/layout/DriveSidebar/DriveSidebar';
8 | import SharedURLsContainerView from './SharedURLsContainerView';
9 | import { useDriveCache } from '../../components/DriveCache/DriveCacheProvider';
10 | import ShareFileButton from '../../components/SharedLinks/ShareFileButton';
11 |
12 | const SharedURLsContainer = ({ match }: RouteComponentProps<{ shareId?: string }>) => {
13 | const cache = useDriveCache();
14 | const { state: expanded, toggle: toggleExpanded } = useToggle();
15 |
16 | const shareId = useMemo(() => {
17 | const shareId = match.params.shareId || cache.get.defaultShareMeta()?.ShareID;
18 | if (!shareId) {
19 | throw new Error('Drive is not initilized, cache has been cleared unexpectedly');
20 | }
21 | return shareId;
22 | }, [match.params.shareId]);
23 |
24 | const logo = ;
25 | const header = (
26 | }
29 | isHeaderExpanded={expanded}
30 | toggleHeaderExpanded={toggleExpanded}
31 | />
32 | );
33 |
34 | const sidebar = (
35 | }
39 | isHeaderExpanded={expanded}
40 | toggleHeaderExpanded={toggleExpanded}
41 | />
42 | );
43 |
44 | const renderContainerView = useCallback(() => , [shareId]);
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default SharedURLsContainer;
59 |
--------------------------------------------------------------------------------
/src/app/components/FolderTree/FolderTree.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { TableRowBusy } from 'react-components';
3 | import { LinkType } from '../../interfaces/link';
4 | import ExpandableRow from './ExpandableRow';
5 |
6 | export interface FolderTreeItem {
7 | linkId: string;
8 | name: string;
9 | type: LinkType;
10 | mimeType: string;
11 | children: { list: FolderTreeItem[]; complete: boolean };
12 | }
13 |
14 | interface Props {
15 | items: FolderTreeItem[];
16 | initiallyExpandedFolders: string[];
17 | selectedItemId?: string;
18 | loading?: boolean;
19 | onSelect: (LinkID: string) => void;
20 | loadChildren: (LinkID: string, loadNextPage?: boolean) => Promise;
21 | rowIsDisabled?: (item: FolderTreeItem) => boolean;
22 | }
23 |
24 | const FolderTree = ({
25 | items,
26 | initiallyExpandedFolders,
27 | selectedItemId,
28 | loading = false,
29 | onSelect,
30 | loadChildren,
31 | rowIsDisabled,
32 | }: Props) => {
33 | const generateRows = (items: FolderTreeItem[], depth = 0) => {
34 | const rows = items.map((item: FolderTreeItem) => {
35 | const { linkId, name, type, mimeType, children } = item;
36 | const disabled = rowIsDisabled ? rowIsDisabled(item) : false;
37 | const childrenRows = children.list.length ? generateRows(children.list, depth + 1) : null;
38 | const isExpanded = initiallyExpandedFolders.includes(linkId);
39 |
40 | return (
41 |
55 | {childrenRows}
56 |
57 | );
58 | });
59 |
60 | return <>{rows}>;
61 | };
62 |
63 | const rows = generateRows(items);
64 |
65 | return (
66 |
67 |
68 | {loading ? : rows}
69 |
70 |
71 | );
72 | };
73 |
74 | export default FolderTree;
75 |
--------------------------------------------------------------------------------
/src/app/components/Drive/Trash/EmptyTrashButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { c } from 'ttag';
3 | import { useNotifications, FloatingButton, SidebarPrimaryButton, Icon } from 'react-components';
4 | import useTrash from '../../../hooks/drive/useTrash';
5 | import useConfirm from '../../../hooks/util/useConfirm';
6 | import { useDriveCache } from '../../DriveCache/DriveCacheProvider';
7 | import useDrive from '../../../hooks/drive/useDrive';
8 |
9 | interface Props {
10 | shareId: string;
11 | floating?: boolean;
12 | className?: string;
13 | }
14 |
15 | const EmptyTrashButton = ({ shareId, floating, className }: Props) => {
16 | const cache = useDriveCache();
17 | const { events } = useDrive();
18 | const { emptyTrash } = useTrash();
19 | const { openConfirmModal } = useConfirm();
20 | const { createNotification } = useNotifications();
21 | const disabled = !cache.get.trashMetas(shareId).length;
22 |
23 | const handleEmptyTrashClick = () => {
24 | const title = c('Title').t`Empty trash`;
25 | const confirm = c('Action').t`Empty trash`;
26 | const message = c('Info').t`Are you sure you want to empty trash and permanently delete all the items?`;
27 |
28 | openConfirmModal({
29 | title,
30 | confirm,
31 | message,
32 | onConfirm: async () => {
33 | try {
34 | await emptyTrash(shareId);
35 | const notificationText = c('Notification').t`All items will soon be permanently deleted from trash`;
36 | createNotification({ text: notificationText });
37 | await events.callAll(shareId);
38 | } catch (e) {
39 | console.error(e);
40 | }
41 | },
42 | });
43 | };
44 |
45 | return (
46 | <>
47 | {floating ? (
48 |
54 |
55 |
56 | ) : (
57 | {c('Action').t`Empty trash`}
63 | )}
64 | >
65 | );
66 | };
67 |
68 | export default EmptyTrashButton;
69 |
--------------------------------------------------------------------------------
/src/app/containers/TrashContainer/TrashContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useCallback } from 'react';
2 | import { PrivateAppContainer, useToggle, MainLogo } from 'react-components';
3 | import { Route, Redirect, Switch, RouteComponentProps } from 'react-router-dom';
4 | import DriveHeader from '../../components/layout/DriveHeader';
5 | import DriveSidebar from '../../components/layout/DriveSidebar/DriveSidebar';
6 | import TrashContainerView from './TrashContainerView';
7 | import EmptyTrashButton from '../../components/Drive/Trash/EmptyTrashButton';
8 | import { useDriveCache } from '../../components/DriveCache/DriveCacheProvider';
9 | import { useDriveActiveFolder } from '../../components/Drive/DriveFolderProvider';
10 | import AppErrorBoundary from '../../components/AppErrorBoundary';
11 |
12 | const TrashContainer = ({ match }: RouteComponentProps<{ shareId?: string }>) => {
13 | const cache = useDriveCache();
14 | const { setFolder } = useDriveActiveFolder();
15 | const { state: expanded, toggle: toggleExpanded } = useToggle();
16 |
17 | useEffect(() => {
18 | setFolder(undefined);
19 | }, []);
20 |
21 | const shareId = useMemo(() => {
22 | const shareId = match.params.shareId || cache.get.defaultShareMeta()?.ShareID;
23 | if (!shareId) {
24 | throw new Error('Drive is not initilized, cache has been cleared unexpectedly');
25 | }
26 | return shareId;
27 | }, [match.params.shareId]);
28 |
29 | const logo = ;
30 | const header = (
31 | }
34 | isHeaderExpanded={expanded}
35 | toggleHeaderExpanded={toggleExpanded}
36 | />
37 | );
38 |
39 | const sidebar = (
40 | }
44 | isHeaderExpanded={expanded}
45 | toggleHeaderExpanded={toggleExpanded}
46 | />
47 | );
48 |
49 | const renderContainerView = useCallback(() => , [shareId]);
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default TrashContainer;
64 |
--------------------------------------------------------------------------------
/src/app/components/DriveEventManager/DriveEventManagerProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, createContext, ReactNode, useContext } from 'react';
2 | import { useApi } from 'react-components';
3 | import eventManager, { EventManager } from 'proton-shared/lib/eventManager/eventManager';
4 |
5 | import { queryEvents, queryLatestEvents } from '../../api/share';
6 | import { LinkMeta } from '../../interfaces/link';
7 | import { EVENT_TYPES } from '../../constants';
8 |
9 | export interface ShareEvent {
10 | EventType: EVENT_TYPES;
11 | Data: any;
12 | Link: LinkMeta;
13 | }
14 |
15 | interface EventManagersByShares {
16 | [shareId: string]: EventManager;
17 | }
18 |
19 | interface EventManagerProviderState {
20 | getShareEventManager: (shareId: string) => EventManager;
21 | createShareEventManager: (shareId: string) => Promise;
22 | }
23 |
24 | const EventManagerContext = createContext(null);
25 |
26 | const DriveEventManagerProvider = ({ children }: { children: ReactNode }) => {
27 | const api = useApi();
28 | const shareEventManagers = useRef({});
29 |
30 | useEffect(() => {
31 | return () => {
32 | if (shareEventManagers.current) {
33 | Object.values(shareEventManagers.current).forEach(({ stop, reset }) => {
34 | stop();
35 | reset();
36 | });
37 | }
38 | };
39 | }, []);
40 |
41 | const getShareEventManager = (shareId: string) => {
42 | return shareEventManagers.current[shareId];
43 | };
44 |
45 | const createShareEventManager = async (shareId: string) => {
46 | const { EventID } = await api<{ EventID: string }>(queryLatestEvents(shareId));
47 | shareEventManagers.current[shareId] = eventManager({
48 | api,
49 | eventID: EventID,
50 | query: (eventId: string) => queryEvents(shareId, eventId),
51 | });
52 | const manager = getShareEventManager(shareId);
53 | manager.start();
54 | return manager;
55 | };
56 |
57 | return (
58 |
64 | {children}
65 |
66 | );
67 | };
68 |
69 | export const useDriveEventManager = () => {
70 | const state = useContext(EventManagerContext);
71 | if (!state) {
72 | throw new Error('Trying to use uninitialized DriveEventManagerProvider');
73 | }
74 | return state;
75 | };
76 |
77 | export default DriveEventManagerProvider;
78 |
--------------------------------------------------------------------------------
/src/app/containers/DriveContainer/DriveContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { PrivateAppContainer, useToggle, MainLogo, TopBanners } from 'react-components';
3 | import { Route, Redirect, Switch } from 'react-router-dom';
4 | import DriveHeader from '../../components/layout/DriveHeader';
5 | import DriveSidebar from '../../components/layout/DriveSidebar/DriveSidebar';
6 | import DriveContainerView from './DriveContainerView';
7 | import UploadButton from '../../components/uploads/UploadButton';
8 | import UploadDragDrop from '../../components/uploads/UploadDragDrop/UploadDragDrop';
9 | import AppErrorBoundary from '../../components/AppErrorBoundary';
10 | import FileRecoveryBanner from '../../components/FilesRecoveryModal/FileRecoveryBanner';
11 | import PreviewContainer from '../PreviewContainer';
12 | import { LinkURLType } from '../../constants';
13 |
14 | const DriveContainer = () => {
15 | const { state: expanded, toggle: toggleExpanded } = useToggle();
16 | const [recoveryBannerVisible, setReoveryBannerVisible] = useState(true);
17 |
18 | const logo = ;
19 | const header = (
20 | }
23 | isHeaderExpanded={expanded}
24 | toggleHeaderExpanded={toggleExpanded}
25 | />
26 | );
27 |
28 | const sidebar = (
29 | }
32 | isHeaderExpanded={expanded}
33 | toggleHeaderExpanded={toggleExpanded}
34 | />
35 | );
36 |
37 | const fileRecoveryBanner = recoveryBannerVisible ? (
38 | {
40 | setReoveryBannerVisible(false);
41 | }}
42 | />
43 | ) : null;
44 |
45 | const topBanners = {fileRecoveryBanner};
46 |
47 | return (
48 |
49 | <>
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | >
60 |
61 | );
62 | };
63 |
64 | export default DriveContainer;
65 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/FolderContextMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { c } from 'ttag';
3 |
4 | import { ContextMenu, DropdownMenuButton, Icon } from 'react-components';
5 |
6 | import useToolbarActions from '../../hooks/drive/useToolbarActions';
7 | import useFileUploadInput from '../../hooks/drive/useFileUploadInput';
8 |
9 | interface Props {
10 | anchorRef: React.RefObject;
11 | isOpen: boolean;
12 | position:
13 | | {
14 | top: number;
15 | left: number;
16 | }
17 | | undefined;
18 | open: () => void;
19 | close: () => void;
20 | }
21 |
22 | const FolderContextMenu = ({ anchorRef, isOpen, position, open, close }: Props) => {
23 | const { openCreateFolder } = useToolbarActions();
24 | const { inputRef: fileInput, handleClick: uploadFile, handleChange: handleFileChange } = useFileUploadInput();
25 | const { inputRef: folderInput, handleClick: uploadFolder, handleChange: handleFolderChange } = useFileUploadInput(
26 | true
27 | );
28 |
29 | useEffect(() => {
30 | if (position) {
31 | open();
32 | }
33 | }, [position]);
34 |
35 | const menuButtons = [
36 | {
37 | name: c('Action').t`Upload file`,
38 | icon: 'file-upload',
39 | testId: 'context-menu-upload-file',
40 | action: uploadFile,
41 | },
42 | {
43 | name: c('Action').t`Upload folder`,
44 | icon: 'folder-upload',
45 | testId: 'context-menu-upload-folder',
46 | action: uploadFolder,
47 | },
48 | {
49 | name: c('Action').t`Create new folder`,
50 | icon: 'folder-new',
51 | testId: 'context-menu-create-folder',
52 | action: openCreateFolder,
53 | },
54 | ].map((button) => (
55 | e.stopPropagation()}
58 | className="flex flex-nowrap text-left"
59 | onClick={button.action}
60 | data-testid={button.testId}
61 | >
62 |
63 | {button.name}
64 |
65 | ));
66 |
67 | return (
68 | <>
69 |
70 |
71 |
72 | {menuButtons}
73 |
74 | >
75 | );
76 | };
77 |
78 | export default FolderContextMenu;
79 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/signatureChecks/imageSignatures.ts:
--------------------------------------------------------------------------------
1 | import { SupportedMimeTypes } from '../constants';
2 | import { SignatureChecker } from '../helpers';
3 |
4 | export default function imageSignatures({ check, checkString, sourceBuffer }: ReturnType) {
5 | if (checkString('WEBP', { offset: 8 })) {
6 | return SupportedMimeTypes.webp;
7 | }
8 |
9 | if (check([0x4d, 0x4d, 0x0, 0x2a]) || check([0x49, 0x49, 0x2a, 0x0])) {
10 | return SupportedMimeTypes.tiff;
11 | }
12 |
13 | if (check([0x47, 0x49, 0x46])) {
14 | return SupportedMimeTypes.gif;
15 | }
16 |
17 | if (check([0xff, 0xd8, 0xff])) {
18 | return SupportedMimeTypes.jpg;
19 | }
20 |
21 | if (check([0x42, 0x4d])) {
22 | return SupportedMimeTypes.bmp;
23 | }
24 |
25 | if (checkString('ftyp', { offset: 4 }) && (sourceBuffer[8] & 0x60) !== 0x00) {
26 | const brandMajor = sourceBuffer.toString('binary', 8, 12).replace('\0', ' ').trim();
27 | switch (brandMajor) {
28 | case 'avif':
29 | return SupportedMimeTypes.avif;
30 | case 'mif1':
31 | return SupportedMimeTypes.heif;
32 | case 'msf1':
33 | return SupportedMimeTypes.heifs;
34 | case 'heic':
35 | case 'heix':
36 | return SupportedMimeTypes.heic;
37 | case 'hevc':
38 | case 'hevx':
39 | return SupportedMimeTypes.heics;
40 | case 'crx':
41 | return SupportedMimeTypes.cr3;
42 | default:
43 | return undefined;
44 | }
45 | }
46 |
47 | if (check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
48 | let offset = 8; // start after png header
49 |
50 | do {
51 | // Read 4-byte uint data length block
52 | const dataLength = sourceBuffer.slice(offset, offset + 4).readInt32BE(0);
53 | offset += 4;
54 |
55 | if (dataLength < 0) {
56 | return;
57 | }
58 |
59 | // Read 4 -byte chunk type block
60 | const chunkType = sourceBuffer.slice(offset, offset + 4).toString('binary');
61 | offset += 4;
62 |
63 | // if acTL comes first, it's animated png, otherwise static
64 | switch (chunkType) {
65 | case 'IDAT':
66 | return SupportedMimeTypes.png;
67 | case 'acTL':
68 | return SupportedMimeTypes.apng;
69 | default:
70 | offset += dataLength + 4; // ignore data + CRC
71 | }
72 | } while (offset + 8 < sourceBuffer.length);
73 |
74 | return SupportedMimeTypes.png;
75 | }
76 |
77 | return undefined;
78 | }
79 |
--------------------------------------------------------------------------------
/src/app/utils/link.test.ts:
--------------------------------------------------------------------------------
1 | import { SharedURLFlags } from '../interfaces/sharing';
2 | import { splitGeneratedAndCustomPassword, adjustName, splitLinkName } from './link';
3 |
4 | describe('splitGeneratedAndCustomPassword', () => {
5 | it('no custom password returns only generated password', () => {
6 | expect(splitGeneratedAndCustomPassword('1234567890ab', { Flags: 0 })).toEqual(['1234567890ab', '']);
7 | });
8 |
9 | it('legacy custom password returns only custom password', () => {
10 | expect(splitGeneratedAndCustomPassword('abc', { Flags: SharedURLFlags.CustomPassword })).toEqual(['', 'abc']);
11 | });
12 |
13 | it('new custom password returns both generated and custom password', () => {
14 | expect(
15 | splitGeneratedAndCustomPassword('1234567890ababc', {
16 | Flags: SharedURLFlags.CustomPassword | SharedURLFlags.GeneratedPasswordIncluded,
17 | })
18 | ).toEqual(['1234567890ab', 'abc']);
19 | });
20 | });
21 |
22 | describe('adjustName', () => {
23 | it('should add index to a file with extension', () => {
24 | expect(adjustName(3, 'filename', 'ext')).toBe('filename (3).ext');
25 | });
26 |
27 | it('should add index to a file without extension', () => {
28 | expect(adjustName(3, 'filename')).toBe('filename (3)');
29 | expect(adjustName(3, 'filename', '')).toBe('filename (3)');
30 | expect(adjustName(3, 'filename.')).toBe('filename. (3)');
31 | expect(adjustName(3, '.filename.')).toBe('.filename. (3)');
32 | });
33 |
34 | it('should add index to a file without name', () => {
35 | expect(adjustName(3, '', 'ext')).toBe('.ext (3)');
36 | });
37 |
38 | it('should leave zero-index filename with extension unchanged', () => {
39 | expect(adjustName(0, 'filename', 'ext')).toBe('filename.ext');
40 | });
41 |
42 | it('should leave zero-index filename without extension unchanged', () => {
43 | expect(adjustName(0, 'filename')).toBe('filename');
44 | expect(adjustName(0, 'filename', '')).toBe('filename');
45 | expect(adjustName(0, 'filename.')).toBe('filename.');
46 | expect(adjustName(0, '.filename.')).toBe('.filename.');
47 | });
48 |
49 | it('should leave zero-index filename without name unchanged', () => {
50 | expect(adjustName(0, '', 'ext')).toBe('.ext');
51 | });
52 | });
53 |
54 | describe('splitLinkName', () => {
55 | it('should split file name and extension', () => {
56 | expect(splitLinkName('filename.ext')).toEqual(['filename', 'ext']);
57 | });
58 |
59 | it('should split file name without extension', () => {
60 | expect(splitLinkName('filename')).toEqual(['filename', '']);
61 | expect(splitLinkName('filename.')).toEqual(['filename.', '']);
62 | });
63 |
64 | it('should split file name without name', () => {
65 | expect(splitLinkName('.ext')).toEqual(['', 'ext']);
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/src/app/hooks/drive/useFileUploadInput.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, ChangeEvent } from 'react';
2 | import { useDriveActiveFolder } from '../../components/Drive/DriveFolderProvider';
3 | import { isTransferCancelError } from '../../utils/transfer';
4 | import useFiles from './useFiles';
5 |
6 | const useFileUploadInput = (forFolders?: boolean) => {
7 | const DS_STORE = '.DS_Store';
8 |
9 | const { uploadDriveFiles } = useFiles();
10 | const { folder: activeFolder } = useDriveActiveFolder();
11 |
12 | const inputRef = useRef(null);
13 |
14 | useEffect(() => {
15 | if (forFolders && inputRef.current) {
16 | // React types don't allow `webkitdirectory` but it exists and works
17 | inputRef.current.setAttribute('webkitdirectory', 'true');
18 | }
19 | }, [forFolders]);
20 |
21 | const getFolderItemsToUpload = (files: FileList) => {
22 | const foldersCreated = new Set();
23 | const filesToUpload: { path: string[]; file?: File }[] = [];
24 |
25 | for (let i = 0; i < files.length; i++) {
26 | const file = files[i];
27 |
28 | // Skip 'Desktop Services Store' files.
29 | if (file.name === DS_STORE) {
30 | continue;
31 | }
32 |
33 | if ('webkitRelativePath' in file) {
34 | const path = ((file as any).webkitRelativePath as string).split('/');
35 | for (let j = 1; j < path.length; j++) {
36 | const folderPath = path.slice(0, j);
37 | const folderPathStr = folderPath.join('/');
38 | if (!foldersCreated.has(folderPathStr)) {
39 | foldersCreated.add(folderPathStr);
40 | filesToUpload.push({ path: folderPath });
41 | }
42 | }
43 | filesToUpload.push({ path: path.slice(0, -1), file });
44 | } else {
45 | console.error('No relative path to determine folder structure from');
46 | }
47 | }
48 |
49 | return filesToUpload;
50 | };
51 |
52 | const handleClick = () => {
53 | if (!activeFolder || !inputRef.current) {
54 | return;
55 | }
56 |
57 | inputRef.current.value = '';
58 | inputRef.current.click();
59 | };
60 |
61 | const handleChange = (e: ChangeEvent) => {
62 | const { files } = e.target;
63 | if (!activeFolder || !files) {
64 | return;
65 | }
66 |
67 | const filesToUpload = forFolders ? getFolderItemsToUpload(files) : files;
68 | uploadDriveFiles(activeFolder.shareId, activeFolder.linkId, filesToUpload, !forFolders).catch((err) => {
69 | if (!isTransferCancelError(err)) {
70 | console.error(err);
71 | }
72 | });
73 | };
74 |
75 | return { inputRef, handleClick, handleChange };
76 | };
77 |
78 | export default useFileUploadInput;
79 |
--------------------------------------------------------------------------------
/src/app/components/FileBrowser/interfaces.tsx:
--------------------------------------------------------------------------------
1 | import { SORT_DIRECTION } from 'proton-shared/lib/constants';
2 | import { LinkType, SortParams, SortKeys, SharedUrlInfo } from '../../interfaces/link';
3 | import { LayoutSetting } from '../../interfaces/userSettings';
4 |
5 | export interface DragMoveControls {
6 | handleDragOver: (event: React.DragEvent) => void;
7 | handleDrop: (e: React.DragEvent) => void;
8 | handleDragLeave: () => void;
9 | handleDragEnter: (e: React.DragEvent) => void;
10 | dragging: boolean;
11 | setDragging: (value: boolean) => void;
12 | isActiveDropTarget: boolean;
13 | }
14 |
15 | export interface FileBrowserItem {
16 | Name: string;
17 | LinkID: string;
18 | Type: LinkType;
19 | ModifyTime: number;
20 | Trashed: number | null;
21 | MIMEType: string;
22 | Size: number;
23 | ParentLinkID: string;
24 | Location?: string;
25 | Disabled?: boolean;
26 | UrlsExpired: boolean;
27 | ShareUrlShareID?: string;
28 | SharedUrl?: SharedUrlInfo;
29 | HasThumbnail: boolean;
30 | // CachedThumbnailURL is computed URL to cached image. This is not part
31 | // of any request and not filled automatically. To get this value, use
32 | // `loadLinkThumbnail` from `useDrive`.
33 | CachedThumbnailURL?: string;
34 | }
35 |
36 | export type ItemRowColumns =
37 | | 'location'
38 | | 'modified'
39 | | 'share_created'
40 | | 'share_expires'
41 | | 'share_num_access'
42 | | 'size'
43 | | 'trashed'
44 | | 'type';
45 | export type FileBrowserLayouts = 'trash' | 'sharing' | 'drive';
46 |
47 | export interface ItemProps {
48 | style?: React.CSSProperties;
49 | item: FileBrowserItem;
50 | shareId: string;
51 | layoutType: FileBrowserLayouts;
52 | selectedItems: FileBrowserItem[];
53 | onToggleSelect: (item: string) => void;
54 | selectItem: (item: string) => void;
55 | onShiftClick?: (item: string) => void;
56 | onClick?: (item: FileBrowserItem) => void;
57 | columns: ItemRowColumns[];
58 | secondaryActionActive?: boolean;
59 | dragMoveControls?: DragMoveControls;
60 | isPreview?: boolean;
61 | }
62 |
63 | export interface FileBrowserProps {
64 | layout: LayoutSetting;
65 | loading?: boolean;
66 | scrollAreaRef: React.RefObject;
67 | shareId: string;
68 | caption?: string;
69 | contents: FileBrowserItem[];
70 | selectedItems: FileBrowserItem[];
71 | type: FileBrowserLayouts;
72 | isPreview?: boolean;
73 | sortParams?: SortParams;
74 | onToggleItemSelected: (item: string) => void;
75 | onItemClick?: (item: FileBrowserItem) => void;
76 | onShiftClick?: (item: string) => void;
77 | selectItem: (item: string) => void;
78 | clearSelections: () => void;
79 | onToggleAllSelected: () => void;
80 | setSorting?: (sortField: SortKeys, sortOrder: SORT_DIRECTION) => void;
81 | getDragMoveControls?: (item: FileBrowserItem) => DragMoveControls;
82 | }
83 |
--------------------------------------------------------------------------------
/src/app/utils/MimeTypeParser/helpers.ts:
--------------------------------------------------------------------------------
1 | import { EXTRA_EXTENSION_TYPES } from './constants';
2 |
3 | interface Options {
4 | /**
5 | * Offset in source buffer to start checking from
6 | */
7 | offset?: number;
8 |
9 | mask?: number[];
10 | }
11 |
12 | export function SignatureChecker(sourceBuffer: Buffer) {
13 | /**
14 | * Checks if source buffer, starting from offset, contains bytes in `signature`.
15 | * Used to check file header bytes for specific mime types.
16 | * @param signature - hexadecimal bytes in signature to match
17 | * @param options - checker options
18 | */
19 | const check = (signature: number[], { offset = 0, mask }: Options = {}) => {
20 | if (offset + signature.length > sourceBuffer.length) {
21 | return false;
22 | }
23 |
24 | for (const [index, signatureByte] of signature.entries()) {
25 | const sourceByte = sourceBuffer[index + offset];
26 | const maskedByte = mask ? signatureByte !== (mask[index] & sourceByte) : sourceByte;
27 |
28 | if (signatureByte !== maskedByte) {
29 | return false;
30 | }
31 | }
32 |
33 | return true;
34 | };
35 |
36 | /**
37 | * Checks if source buffer, starting from offset, contains bytes matching ascii characters in `signature`.
38 | * Used to check file header bytes for specific mime types.
39 | * @param signature - ascii characters to match in signature
40 | * @param options - checker options
41 | */
42 | const checkString = (signature: string, options?: Options) => {
43 | const bytes = [...signature].map((character) => character.charCodeAt(0));
44 | return check(bytes, options);
45 | };
46 |
47 | return {
48 | sourceBuffer,
49 | check,
50 | checkString,
51 | };
52 | }
53 |
54 | /**
55 | Checks whether the TAR checksum is valid.
56 | @param {Buffer} buffer - The TAR header `[offset ... offset + 512]`.
57 | @param {number} offset - TAR header offset.
58 | @returns {boolean} `true` if the TAR checksum is valid, otherwise `false`.
59 | */
60 | export function isTarHeaderChecksumValid(buffer: Buffer) {
61 | const readSum = parseInt(buffer.toString('utf8', 148, 154).replace(/\0.*$/, '').trim(), 8); // Read sum in header
62 |
63 | // eslint-disable-next-line no-restricted-globals
64 | if (isNaN(readSum)) {
65 | return false;
66 | }
67 |
68 | let sum = 8 * 0x20; // Initialize signed bit sum
69 |
70 | for (let i = 0; i < 148; i++) {
71 | sum += buffer[i];
72 | }
73 |
74 | for (let i = 156; i < 512; i++) {
75 | sum += buffer[i];
76 | }
77 |
78 | return readSum === sum;
79 | }
80 |
81 | export async function mimetypeFromExtension(filename: string) {
82 | const { lookup } = await import('mime-types');
83 | const extension = filename.split('.').pop();
84 | return (
85 | (extension && EXTRA_EXTENSION_TYPES[extension.toLowerCase()]) || lookup(filename) || 'application/octet-stream'
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/app/interfaces/link.ts:
--------------------------------------------------------------------------------
1 | import { SORT_DIRECTION } from 'proton-shared/lib/constants';
2 | import { FileRevisionState } from './file';
3 |
4 | export enum LinkType {
5 | FOLDER = 1,
6 | FILE = 2,
7 | }
8 |
9 | export type SharedUrlInfo = {
10 | CreateTime: number;
11 | ExpireTime: number | null;
12 | ShareUrlID: string;
13 | Token: string;
14 | };
15 |
16 | interface FileProperties {
17 | ContentKeyPacket: string;
18 | ActiveRevision: {
19 | ID: number;
20 | CreateTime: number;
21 | Size: number;
22 | ManifestSignature: string;
23 | SignatureAddress: string;
24 | State: FileRevisionState;
25 | Thumbnail: number;
26 | ThumbnailDownloadUrl: string;
27 | } | null;
28 | }
29 |
30 | interface FolderProperties {
31 | NodeHashKey: string;
32 | }
33 |
34 | interface DriveLink {
35 | LinkID: string;
36 | ParentLinkID: string;
37 | Type: LinkType;
38 | Name: string;
39 | EncryptedName: string;
40 | Size: number;
41 | MIMEType: string;
42 | Hash: string;
43 | CreateTime: number;
44 | ModifyTime: number;
45 | Trashed: number | null;
46 | State: number;
47 | NodeKey: string;
48 | NodePassphrase: string;
49 | NodePassphraseSignature: string;
50 | SignatureAddress: string;
51 | Attributes: number;
52 | Permissions: number;
53 | FileProperties: FileProperties | null;
54 | FolderProperties: FolderProperties | null;
55 | Shared: number;
56 | UrlsExpired: boolean;
57 | ShareIDs: string[];
58 | ShareUrls: SharedUrlInfo[];
59 | // CachedThumbnailURL is computed URL to cached image. This is not part
60 | // of any request and not filled automatically. To get this value, use
61 | // `loadLinkThumbnail` from `useDrive`.
62 | CachedThumbnailURL: string;
63 | ThumbnailIsLoading: boolean;
64 | }
65 |
66 | export interface FileLinkMeta extends DriveLink {
67 | Type: LinkType.FILE;
68 | FileProperties: FileProperties;
69 | FolderProperties: null;
70 | }
71 |
72 | export interface FolderLinkMeta extends DriveLink {
73 | Type: LinkType.FOLDER;
74 | FolderProperties: FolderProperties;
75 | FileProperties: null;
76 | }
77 |
78 | export type LinkMeta = FileLinkMeta | FolderLinkMeta;
79 |
80 | export const isFolderLinkMeta = (link: LinkMeta): link is FolderLinkMeta => link.Type === LinkType.FOLDER;
81 |
82 | export interface LinkMetaResult {
83 | Link: LinkMeta;
84 | }
85 |
86 | export interface LinkChildrenResult {
87 | Links: LinkMeta[];
88 | }
89 |
90 | export interface HashCheckResult {
91 | AvailableHashes: string[];
92 | }
93 |
94 | export interface MoveLink {
95 | Name: string;
96 | Hash: string;
97 | ParentLinkID: string;
98 | NodePassphrase: string;
99 | NodePassphraseSignature: string;
100 | SignatureAddress: string;
101 | }
102 |
103 | export type SortKeys = keyof Pick;
104 |
105 | export type SortParams = { sortField: SortKeys; sortOrder: SORT_DIRECTION };
106 |
--------------------------------------------------------------------------------
/src/app/containers/DriveContainer/DriveContainerView.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useMemo, useRef } from 'react';
2 | import { RouteComponentProps } from 'react-router';
3 | import { c } from 'ttag';
4 |
5 | import { Toolbar, PrivateMainArea, useAppTitle } from 'react-components';
6 |
7 | import { useDriveActiveFolder } from '../../components/Drive/DriveFolderProvider';
8 | import { useDriveCache } from '../../components/DriveCache/DriveCacheProvider';
9 | import Drive from '../../components/Drive/Drive';
10 | import DriveContentProvider from '../../components/Drive/DriveContentProvider';
11 | import DriveToolbar from '../../components/Drive/DriveToolbar';
12 | import DriveBreadcrumbs from '../../components/DriveBreadcrumbs';
13 | import { LinkURLType } from '../../constants';
14 | import useNavigate from '../../hooks/drive/useNavigate';
15 |
16 | function DriveContainerView({ match }: RouteComponentProps<{ shareId?: string; type?: LinkURLType; linkId?: string }>) {
17 | const lastFolderRef = useRef<{
18 | shareId: string;
19 | linkId: string;
20 | }>();
21 | const cache = useDriveCache();
22 | const [, setError] = useState();
23 | const { setFolder } = useDriveActiveFolder();
24 | const { navigateToRoot } = useNavigate();
25 |
26 | const folder = useMemo(() => {
27 | const { shareId, type, linkId } = match.params;
28 |
29 | if (!shareId && !type && !linkId) {
30 | const meta = cache.get.defaultShareMeta();
31 |
32 | if (meta) {
33 | return { shareId: meta.ShareID, linkId: meta.LinkID };
34 | }
35 | setError(() => {
36 | throw new Error('Drive is not initilized, cache has been cleared unexpectedly');
37 | });
38 | } else if (!shareId || !type || !linkId) {
39 | console.error('Missing parameters, should be none or shareId/type/linkId');
40 | navigateToRoot();
41 | } else if (type === LinkURLType.FOLDER) {
42 | return { shareId, linkId };
43 | }
44 | return lastFolderRef.current;
45 | }, [match.params]);
46 |
47 | // In case we open preview, folder doesn't need to change
48 | lastFolderRef.current = folder;
49 |
50 | useEffect(() => {
51 | const { type } = match.params;
52 |
53 | if (folder) {
54 | setFolder(folder);
55 | } else if (type !== LinkURLType.FILE) {
56 | navigateToRoot();
57 | }
58 | }, [folder, match.params]);
59 |
60 | useAppTitle(c('Title').t`My files`);
61 |
62 | return (
63 |
64 | {folder ? : }
65 |
66 |
67 | {folder && }
68 |
69 | {folder && }
70 |
71 |
72 | );
73 | }
74 |
75 | export default DriveContainerView;
76 |
--------------------------------------------------------------------------------
/src/app/components/thumbnail/image.ts:
--------------------------------------------------------------------------------
1 | import { THUMBNAIL_MAX_SIDE, THUMBNAIL_MAX_SIZE, THUMBNAIL_QUALITIES } from '../../constants';
2 |
3 | export function scaleImageFile(file: Blob): Promise {
4 | return new Promise((resolve, reject) => {
5 | const img = new Image();
6 | img.addEventListener('load', () => {
7 | scaleImage(img).then(resolve).catch(reject);
8 | });
9 | img.addEventListener('error', reject);
10 | img.src = URL.createObjectURL(file);
11 | });
12 | }
13 |
14 | async function scaleImage(img: HTMLImageElement): Promise {
15 | const canvas = document.createElement('canvas');
16 | const ctx = canvas.getContext('2d');
17 |
18 | // Null is returned only when using wrong context type.
19 | if (ctx === null) {
20 | throw new Error("Context is not available");
21 | }
22 |
23 | const [width, height] = calculateThumbnailSize(img);
24 | canvas.width = width;
25 | canvas.height = height;
26 | ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
27 |
28 | return new Uint8Array(await canvasToThumbnail(canvas));
29 | }
30 |
31 | export function calculateThumbnailSize(img: { width: number, height: number }): [width: number, height: number] {
32 | // Keep image smaller than our thumbnail as is.
33 | if (!(img.width > THUMBNAIL_MAX_SIDE || img.height > THUMBNAIL_MAX_SIDE)) {
34 | return [img.width, img.height];
35 | }
36 |
37 | // getSize returns the other side, always as non-zero integer.
38 | const getSize = (ratio: number): number => {
39 | const result = Math.round(ratio * THUMBNAIL_MAX_SIDE);
40 | return result === 0 ? 1 : result;
41 | }
42 |
43 | // Otherwise scale down based on the bigger side.
44 | if (img.width > img.height) {
45 | return [THUMBNAIL_MAX_SIDE, getSize(img.height / img.width)];
46 | }
47 | return [getSize(img.width / img.height), THUMBNAIL_MAX_SIDE];
48 | }
49 |
50 | async function canvasToThumbnail(canvas: HTMLCanvasElement): Promise {
51 | // We check clear text thumbnail size but the limit on API is for encrypted
52 | // text. To do the check on proper place would be too dificult for little
53 | // gain. The increase in size is under 10 percent, so limit it to 90% of
54 | // real limit is reasonable.
55 | const maxSize = THUMBNAIL_MAX_SIZE * 0.9;
56 |
57 | for (const quality of THUMBNAIL_QUALITIES) {
58 | const data = await canvasToArrayBuffer(canvas, 'image/jpeg', quality);
59 | if (data.byteLength < maxSize) {
60 | return data;
61 | }
62 | }
63 | throw new Error("Cannot create small enough thumbnail");
64 | }
65 |
66 | function canvasToArrayBuffer(canvas: HTMLCanvasElement, mime: string, quality: number): Promise {
67 | return new Promise((resolve, reject) => canvas.toBlob((d) => {
68 | if (!d) {
69 | reject(new Error("Blob not available"));
70 | return;
71 | }
72 |
73 | const r = new FileReader();
74 | r.addEventListener('load', () => {
75 | resolve(r.result as ArrayBuffer);
76 | });
77 | r.addEventListener('error', reject);
78 | r.readAsArrayBuffer(d);
79 | }, mime, quality));
80 | }
81 |
--------------------------------------------------------------------------------
/src/app/hooks/drive/useDriveSorting.ts:
--------------------------------------------------------------------------------
1 | import { useMultiSortedList } from 'react-components';
2 | import { useMemo } from 'react';
3 | import { SortConfig } from 'react-components/hooks/useSortedList';
4 | import { SORT_DIRECTION } from 'proton-shared/lib/constants';
5 | import { LinkMeta, SortKeys, SortParams } from '../../interfaces/link';
6 | import useUserSettings from './useUserSettings';
7 | import { SortSetting } from '../../interfaces/userSettings';
8 |
9 | const getNameSortConfig = (direction = SORT_DIRECTION.ASC) => ({
10 | key: 'Name' as SortKeys,
11 | direction,
12 | compare: (a: LinkMeta['Name'], b: LinkMeta['Name']) => a.localeCompare(b),
13 | });
14 |
15 | const fieldMap: { [key in SortSetting]: SortKeys } = {
16 | [SortSetting.ModifiedAsc]: 'ModifyTime',
17 | [SortSetting.ModifiedDesc]: 'ModifyTime',
18 | [SortSetting.NameAsc]: 'Name',
19 | [SortSetting.NameDesc]: 'Name',
20 | [SortSetting.SizeAsc]: 'Size',
21 | [SortSetting.SizeDesc]: 'Size',
22 | [SortSetting.TypeAsc]: 'MIMEType',
23 | [SortSetting.TypeDesc]: 'MIMEType',
24 | };
25 |
26 | function useDriveSorting(getList: (sortParams: SortParams) => LinkMeta[]) {
27 | const { sort, changeSort } = useUserSettings();
28 |
29 | const sortParams = useMemo((): SortParams => {
30 | const sortOrder = sort < 0 ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC;
31 | const sortField = fieldMap[sort];
32 | return {
33 | sortOrder,
34 | sortField,
35 | };
36 | }, [sort]);
37 |
38 | const getConfig = (sortField: SortKeys, direction: SORT_DIRECTION) => {
39 | const configs: {
40 | [key in SortKeys]: SortConfig[];
41 | } = {
42 | Name: [{ key: 'Type', direction: SORT_DIRECTION.ASC }, getNameSortConfig(direction)],
43 | MIMEType: [{ key: 'MIMEType', direction }, { key: 'Type', direction }, getNameSortConfig()],
44 | ModifyTime: [{ key: 'ModifyTime', direction }, getNameSortConfig()],
45 | Size: [{ key: 'Type', direction }, { key: 'Size', direction }, getNameSortConfig()],
46 | };
47 | return configs[sortField];
48 | };
49 |
50 | const { sortedList, setConfigs } = useMultiSortedList(
51 | getList(sortParams),
52 | getConfig(sortParams.sortField, sortParams.sortOrder)
53 | );
54 |
55 | const setSorting = async (sortField: SortKeys, sortOrder: SORT_DIRECTION) => {
56 | setConfigs(getConfig(sortField, sortOrder));
57 | const sortSettingValue = Object.entries(fieldMap).find(([setting, fieldName]) => {
58 | if (fieldName === sortField) {
59 | const settingValue = Number(setting);
60 | const isSameDirection =
61 | (sortOrder === SORT_DIRECTION.ASC && settingValue > 0) ||
62 | (sortOrder === SORT_DIRECTION.DESC && settingValue < 0);
63 | return isSameDirection;
64 | }
65 | return false;
66 | })?.[0];
67 |
68 | if (sortSettingValue) {
69 | await changeSort(Number(sortSettingValue));
70 | }
71 | };
72 |
73 | return {
74 | sortParams,
75 | sortedList,
76 | setSorting,
77 | };
78 | }
79 |
80 | export default useDriveSorting;
81 |
--------------------------------------------------------------------------------
/src/app/components/Drive/helpers.tsx:
--------------------------------------------------------------------------------
1 | import { c } from 'ttag';
2 | import { getUnixTime } from 'date-fns';
3 | import { LinkType, LinkMeta } from '../../interfaces/link';
4 | import { LinkURLType, fileDescriptions, EXPIRATION_DAYS } from '../../constants';
5 | import { FileBrowserItem } from '../FileBrowser/interfaces';
6 |
7 | export const selectMessageForItemList = (
8 | types: LinkType[],
9 | messages: {
10 | allFiles: string;
11 | allFolders: string;
12 | mixed: string;
13 | }
14 | ) => {
15 | const allFiles = types.every((type) => type === LinkType.FILE);
16 | const allFolders = types.every((type) => type === LinkType.FOLDER);
17 | const message = (allFiles && messages.allFiles) || (allFolders && messages.allFolders) || messages.mixed;
18 |
19 | return message;
20 | };
21 |
22 | export const mapLinksToChildren = (
23 | decryptedLinks: LinkMeta[],
24 | isDisabled: (linkId: string) => boolean
25 | ): FileBrowserItem[] => {
26 | return decryptedLinks.map(
27 | ({
28 | LinkID,
29 | Type,
30 | Name,
31 | ModifyTime,
32 | Size,
33 | MIMEType,
34 | ParentLinkID,
35 | Trashed,
36 | UrlsExpired,
37 | ShareIDs,
38 | ShareUrls,
39 | FileProperties,
40 | CachedThumbnailURL,
41 | }) => ({
42 | Name,
43 | LinkID,
44 | Type,
45 | ModifyTime,
46 | Size,
47 | MIMEType,
48 | ParentLinkID,
49 | Trashed,
50 | Disabled: isDisabled(LinkID),
51 | SharedUrl: ShareUrls[0], // Currently only one share URL is possible
52 | ShareUrlShareID: ShareIDs[0],
53 | UrlsExpired,
54 | HasThumbnail: FileProperties?.ActiveRevision?.Thumbnail === 1,
55 | CachedThumbnailURL,
56 | })
57 | );
58 | };
59 |
60 | export const toLinkURLType = (type: LinkType) => {
61 | const linkType = {
62 | [LinkType.FILE]: LinkURLType.FILE,
63 | [LinkType.FOLDER]: LinkURLType.FOLDER,
64 | }[type];
65 |
66 | if (!linkType) {
67 | throw new Error(`Type ${type} is unexpected, must be integer representing link type`);
68 | }
69 |
70 | return linkType;
71 | };
72 |
73 | export const getMimeTypeDescription = (mimeType: string) => {
74 | if (fileDescriptions[mimeType]) {
75 | return fileDescriptions[mimeType];
76 | }
77 | if (mimeType.startsWith('audio/')) {
78 | return c('Label').t`Audio file`;
79 | }
80 | if (mimeType.startsWith('video/')) {
81 | return c('Label').t`Video file`;
82 | }
83 | if (mimeType.startsWith('text/')) {
84 | return c('Label').t`Text`;
85 | }
86 | if (mimeType.startsWith('image/')) {
87 | return c('Label').t`Image`;
88 | }
89 |
90 | return c('Label').t`Unknown file`;
91 | };
92 |
93 | // Simple math instead of addDays because we don't need to compensate for DST
94 | export const getDurationInSeconds = (duration: EXPIRATION_DAYS) =>
95 | duration === EXPIRATION_DAYS.NEVER ? null : parseInt(duration, 10) * 24 * 60 * 60;
96 |
97 | export const getExpirationTime = (duration: EXPIRATION_DAYS) =>
98 | duration === EXPIRATION_DAYS.NEVER ? null : getUnixTime(new Date()) + (getDurationInSeconds(duration) || 0);
99 |
--------------------------------------------------------------------------------
/src/app/hooks/drive/useDriveCrypto.ts:
--------------------------------------------------------------------------------
1 | import { useGetAddresses, useGetAddressKeys, useNotifications } from 'react-components';
2 | import { sign as signMessage } from 'proton-shared/lib/keys/driveKeys';
3 | import { Address } from 'proton-shared/lib/interfaces/Address';
4 | import { OpenPGPKey } from 'pmcrypto';
5 | import { useCallback } from 'react';
6 | import { ADDRESS_STATUS } from 'proton-shared/lib/constants';
7 | import { c } from 'ttag';
8 | import { ShareMeta } from '../../interfaces/share';
9 | import {
10 | decryptSharePassphraseAsync,
11 | getPrimaryAddressAsync,
12 | getPrimaryAddressKeyAsync,
13 | getOwnAddressKeysAsync,
14 | } from '../../utils/drive/driveCrypto';
15 |
16 | // Special case for drive to allow users with just an external address
17 | export const getActiveAddresses = (addresses: Address[]): Address[] => {
18 | return addresses.filter(({ Status }) => Status === ADDRESS_STATUS.STATUS_ENABLED);
19 | };
20 |
21 | function useDriveCrypto() {
22 | const { createNotification } = useNotifications();
23 | const getAddressKeys = useGetAddressKeys();
24 | const getAddresses = useGetAddresses();
25 |
26 | const getPrimaryAddress = useCallback(async () => {
27 | return getPrimaryAddressAsync(getAddresses).catch((error) => {
28 | createNotification({ text: c('Error').t`No valid address found`, type: 'error' });
29 | throw error;
30 | });
31 | }, [getAddresses]);
32 |
33 | const getPrimaryAddressKey = useCallback(async () => {
34 | return getPrimaryAddressKeyAsync(getPrimaryAddress, getAddressKeys);
35 | }, [getPrimaryAddress, getAddressKeys]);
36 |
37 | const getOwnAddressKeys = useCallback(
38 | async (email: string) => getOwnAddressKeysAsync(email, getAddresses, getAddressKeys),
39 | [getAddresses, getAddressKeys]
40 | );
41 |
42 | const getVerificationKey = useCallback(
43 | async (email: string) => {
44 | const { publicKeys } = await getOwnAddressKeysAsync(email, getAddresses, getAddressKeys);
45 | return publicKeys;
46 | },
47 | [getAddresses, getAddressKeys]
48 | );
49 |
50 | const sign = useCallback(
51 | async (payload: string | Uint8Array, keys?: { privateKey: OpenPGPKey; address: Address }) => {
52 | const { privateKey, address } = keys || (await getPrimaryAddressKey());
53 | const signature = await signMessage(payload, [privateKey]);
54 | return { signature, address };
55 | },
56 | [getPrimaryAddressKey]
57 | );
58 |
59 | /**
60 | * Decrypts share passphrase. By default decrypts with the same user's keys who encrypted.
61 | * Keys can be passed explicitly if user is different, i.e. in case of sharing between users.
62 | * @param meta share metadata
63 | * @param privateKeys keys to use, when the user is not the same who encrypted
64 | */
65 | const decryptSharePassphrase = async (meta: ShareMeta, privateKeys?: OpenPGPKey[]) => {
66 | return decryptSharePassphraseAsync(
67 | meta,
68 | privateKeys || (await getOwnAddressKeys(meta.Creator)).privateKeys,
69 | getVerificationKey
70 | );
71 | };
72 |
73 | return { getPrimaryAddressKey, getVerificationKey, getPrimaryAddress, sign, decryptSharePassphrase };
74 | }
75 |
76 | export default useDriveCrypto;
77 |
--------------------------------------------------------------------------------
/src/app/components/TransferManager/TransferManager.scss:
--------------------------------------------------------------------------------
1 | @import '~design-system/scss/config/';
2 |
3 | .transfers-manager {
4 | position: fixed;
5 | right: 0;
6 | bottom: 0;
7 | z-index: 80;
8 | box-shadow: var(--shadow-lifted);
9 | color: var(--text-norm);
10 | transition: 0.15s cubic-bezier(0.22, 1, 0.36, 1);
11 |
12 | @include respond-to($breakpoint-small, 'max') {
13 | left: 0;
14 | bottom: 0;
15 | }
16 |
17 | @include respond-to($breakpoint-small, 'min') {
18 | width: 60%;
19 | max-width: 50em;
20 | margin-right: inherit;
21 | }
22 |
23 | &-heading {
24 | background: var(--background-strong);
25 | color: var(--text-norm);
26 |
27 | @include respond-to($breakpoint-small, 'min') {
28 | border-radius: $global-border-radius $global-border-radius 0 0;
29 | }
30 | }
31 |
32 | &-heading-tooltip {
33 | align-self: stretch;
34 | justify-content: stretch;
35 | align-items: stretch;
36 |
37 | &--isDisabled {
38 | pointer-events: none;
39 | }
40 | }
41 |
42 | &-list {
43 | background-color: var(--background-norm);
44 | color: var(--text-norm);
45 | transition: inherit;
46 | }
47 |
48 | &--minimized &-list {
49 | max-height: 0;
50 | visibility: hidden;
51 | }
52 |
53 | &-list-item {
54 | display: grid;
55 | grid-template-rows: 1fr auto;
56 | grid-template-areas: 'name size status controls' 'progress progress progress progress';
57 | column-gap: 1em;
58 | row-gap: 1em;
59 | align-items: center;
60 | height: 5em; // To avoid height blinking on status changing
61 |
62 | @include respond-to($breakpoint-small, 'max') {
63 | grid-template-columns: 4fr minmax(5em, 2fr) 2.5em minmax(4.5em, 1fr);
64 | }
65 |
66 | @include respond-to($breakpoint-small, 'min') {
67 | grid-template-columns: 4fr 2.5fr minmax(6em, 2fr) minmax(4.5em, 1fr);
68 | }
69 |
70 | &:not(:last-child) {
71 | @extend .border-bottom;
72 | }
73 |
74 | @each $place in (name, size, status, controls, progress) {
75 | &-#{$place} {
76 | grid-area: $place;
77 | }
78 |
79 | @if $place != controls and $place != progress {
80 | &--canceled &-#{$place} {
81 | opacity: 0.5;
82 | }
83 | }
84 | }
85 |
86 | &-icon {
87 | @include respond-to(420, 'max') {
88 | display: none;
89 | }
90 | }
91 |
92 | &-status {
93 | white-space: nowrap;
94 | font-variant-numeric: tabular-nums;
95 |
96 | @include respond-to($breakpoint-small, 'max') {
97 | svg {
98 | margin: 0;
99 | }
100 | }
101 | }
102 |
103 | &-size {
104 | white-space: nowrap;
105 | font-variant-numeric: tabular-nums;
106 | }
107 |
108 | &-controls-button {
109 | padding: 0.375em;
110 |
111 | & + & {
112 | margin-left: 0.5em;
113 | }
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Proton Drive
2 |
3 | Proton Drive built with React.
4 |
5 |
6 | >**⚠ If you use Windows plz follow this document before anything else [how to prepare Windows](https://github.com/ProtonMail/proton-shared/wiki/setup-windows)**
7 |
8 |
9 |
10 | ## Basic installation
11 |
12 | > :warning: if you are a proton dev, you will need the file `appConfig.json`
13 |
14 | To set up the project, follow the steps below:
15 |
16 | 1. Clone the repository
17 | 2. `$ npm ci`
18 | 3. `$ npm start`
19 |
20 | It's going to create a server available on https://localhost:8080
21 |
22 | cf:
23 |
24 | ```sh
25 | $ npm start
26 |
27 | > proton-drive@4.0.0-beta.5 start /tmp/proton-drive
28 | > proton-pack dev-server $npm_package_config_publicPathFlag --appMode=standalone
29 |
30 | [proton-pack] Missing file appConfig.json.
31 | [proton-pack] [DEPREACTION NOTICE] Please rename your file env.json to appConfig.json.
32 | [proton-pack] Missing file env.json.
33 | [proton-pack] ✓ generated /tmp/proton-drive/src/app/config.ts
34 | ➙ Dev server: http://localhost:8081/drive/
35 | ➙ Dev server: http://192.168.1.88:8081/drive/
36 | ➙ API: https://mail.protonmail.com/api
37 |
38 |
39 | ℹ 「wds」: Project is running at http://localhost/
40 | ℹ 「wds」: webpack output is served from /drive/
41 | ℹ 「wds」: Content not from webpack is served from /tmp/proton-drive/dist
42 | ℹ 「wds」: 404s will fallback to /drive/
43 | ℹ 「wdm」: 3196 modules
44 | ℹ 「wdm」: Compiled successfully.
45 | ```
46 |
47 | > Here on the port 8081 as the 8080 was not available. We auto detect what is available.
48 |
49 |
50 | ## Commands
51 |
52 | - `$ npm start`
53 |
54 | Run develop server with a login page (mode standalone). It's going to run a server on the port **8080** if available.
55 | > If it is not available we auto detect what is available
56 |
57 | - `$ npm test`
58 |
59 | Run the tests
60 |
61 | - `$ npm run lint`
62 |
63 | Lint the sources via eslint
64 |
65 | - `$ npm run pretty`
66 |
67 | Prettier sources (we have a hook post commit to run it)
68 |
69 | - `$ npm run check-types`
70 |
71 | Validate TS types
72 |
73 | - `$ npm run bundle`
74 |
75 | Create a bundle ready to deploy (prepare app + build + minify)
76 |
77 | [more informations](https://github.com/ProtonMail/proton-bundler)
78 |
79 | - `$ npm run build`
80 |
81 | Build the app (build + minify). Bundle will run this command.
82 |
83 | - `$ npm run build:standalone`
84 |
85 | Same as the previous one BUT with a login page. When we deploy live,the login state is on another app.But when we only deploy this app when we dev, we need to be able to login.
86 |
87 | - `$ npm run deploy` and `$ npm run deploy:standalone`
88 |
89 | It's to deploy to a branch `deploy-branch`. A bundle based on `build` or `build:standalone`.
90 |
91 | Flags:
92 | - `--api `: type of api to use for deploy ex: blue,dev,proxy,prod
93 | - `--branch `: target for the subdomain to deploy
94 |
95 | [more informations](https://github.com/ProtonMail/proton-bundler)
96 |
97 | - `$ npm run i18n:validate**`
98 |
99 | Validate translations (context, format etc.)
100 |
101 | ## Create a new version
102 |
103 | We use the command [npm version](https://docs.npmjs.com/cli/version)
104 |
105 | ## Help us to translate the project
106 |
107 | You can help us to translate the application on [crowdin](https://crowdin.com/project/protonmail)
108 |
109 |
--------------------------------------------------------------------------------