Merge branch 'master' into stripe-integration
This commit is contained in:
commit
2b5d881f99
53 changed files with 2421 additions and 884 deletions
|
@ -5,6 +5,7 @@
|
|||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build-analyze": "ANALYZE=true next build",
|
||||
"postbuild": "next-on-netlify",
|
||||
"start": "next start"
|
||||
},
|
||||
|
@ -21,6 +22,7 @@
|
|||
"formik": "^2.1.5",
|
||||
"heic2any": "^0.0.3",
|
||||
"http-proxy-middleware": "^1.0.5",
|
||||
"is-electron": "^2.2.0",
|
||||
"libsodium-wrappers": "^0.7.8",
|
||||
"localforage": "^1.9.0",
|
||||
"next": "9.5.3",
|
||||
|
|
BIN
public/vault.png
BIN
public/vault.png
Binary file not shown.
Before Width: | Height: | Size: 48 KiB |
|
@ -1,36 +1,61 @@
|
|||
import React, { MouseEventHandler } from 'react';
|
||||
import React from 'react';
|
||||
import { Button, Modal } from 'react-bootstrap';
|
||||
import constants from 'utils/strings/constants';
|
||||
|
||||
interface Props {
|
||||
callback?: Map<string, Function>;
|
||||
action: string;
|
||||
show: boolean;
|
||||
onHide: MouseEventHandler<HTMLElement>;
|
||||
export enum CONFIRM_ACTION {
|
||||
LOGOUT,
|
||||
DELETE,
|
||||
SESSION_EXPIRED,
|
||||
DOWNLOAD_APP,
|
||||
CANCEL_SUBSCRIPTION,
|
||||
}
|
||||
function ConfirmDialog(props: Props) {
|
||||
const { callback, action, ...rest } = props;
|
||||
|
||||
const CONFIRM_ACTION_VALUES = [
|
||||
{ text: 'LOGOUT', type: 'danger' },
|
||||
{ text: 'DELETE', type: 'danger' },
|
||||
{ text: 'SESSION_EXPIRED', type: 'primary' },
|
||||
{ text: 'DOWNLOAD_APP', type: 'success' },
|
||||
{ text: 'CANCEL_SUBSCRIPTION', type: 'danger' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
callback: any;
|
||||
action: CONFIRM_ACTION;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
function ConfirmDialog({ callback, action, ...props }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
{...rest}
|
||||
{...props}
|
||||
size="lg"
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
backdrop={
|
||||
action == CONFIRM_ACTION.SESSION_EXPIRED ? 'static' : 'true'
|
||||
}
|
||||
>
|
||||
<Modal.Body style={{ padding: '24px' }}>
|
||||
<Modal.Title id="contained-modal-title-vcenter">
|
||||
{constants[`${String(action).toUpperCase()}_WARNING`]}
|
||||
{
|
||||
constants[
|
||||
`${CONFIRM_ACTION_VALUES[action]?.text}_MESSAGE`
|
||||
]
|
||||
}
|
||||
</Modal.Title>
|
||||
</Modal.Body>
|
||||
<Modal.Footer style={{ borderTop: 'none' }}>
|
||||
<Button variant="secondary" onClick={props.onHide}>
|
||||
{constants.CLOSE}
|
||||
</Button>
|
||||
{action && (
|
||||
<Button variant="danger" onClick={callback.get(action)}>
|
||||
{constants[String(action).toUpperCase()]}
|
||||
{action != CONFIRM_ACTION.SESSION_EXPIRED && (
|
||||
<Button variant="outline-secondary" onClick={props.onHide}>
|
||||
{constants.CANCEL}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={`outline-${CONFIRM_ACTION_VALUES[action]?.type}`}
|
||||
onClick={callback}
|
||||
>
|
||||
{constants[CONFIRM_ACTION_VALUES[action]?.text]}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
|
|
20
src/components/Delete.tsx
Normal file
20
src/components/Delete.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function Delete(props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={props.height}
|
||||
viewBox={props.viewBox}
|
||||
width={props.width}
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none"/><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Delete.defaultProps = {
|
||||
height: 24,
|
||||
width: 24,
|
||||
viewBox: '0 0 24 24',
|
||||
};
|
|
@ -19,7 +19,8 @@ const DropDiv = styled.div`
|
|||
|
||||
const Overlay = styled.div<{ isDragActive: boolean }>`
|
||||
border-width: 8px;
|
||||
|
||||
left: 0;
|
||||
top: 0;
|
||||
outline: none;
|
||||
transition: border 0.24s ease-in-out;
|
||||
height: 100%;
|
||||
|
@ -41,6 +42,7 @@ const Overlay = styled.div<{ isDragActive: boolean }>`
|
|||
type Props = React.PropsWithChildren<{
|
||||
getRootProps: any;
|
||||
getInputProps: any;
|
||||
showCollectionSelector;
|
||||
}>;
|
||||
|
||||
export default function FullScreenDropZone(props: Props) {
|
||||
|
@ -48,7 +50,15 @@ export default function FullScreenDropZone(props: Props) {
|
|||
const onDragEnter = () => setIsDragActive(true);
|
||||
const onDragLeave = () => setIsDragActive(false);
|
||||
return (
|
||||
<DropDiv {...props.getRootProps()} onDragEnter={onDragEnter}>
|
||||
<DropDiv
|
||||
{...props.getRootProps({
|
||||
onDragEnter,
|
||||
onDrop: (e) => {
|
||||
e.preventDefault();
|
||||
props.showCollectionSelector();
|
||||
},
|
||||
})}
|
||||
>
|
||||
<input {...props.getInputProps()} />
|
||||
{isDragActive && (
|
||||
<Overlay
|
||||
|
|
61
src/components/MessageDialog.tsx
Normal file
61
src/components/MessageDialog.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import { Button, Modal } from 'react-bootstrap';
|
||||
import constants from 'utils/strings/constants';
|
||||
|
||||
export interface MessageAttributes {
|
||||
title?: string;
|
||||
staticBackdrop?: boolean;
|
||||
close?: { text?: string; variant?: string };
|
||||
proceed?: { text: string; action: any };
|
||||
}
|
||||
interface Props {
|
||||
show: boolean;
|
||||
children?: any;
|
||||
onHide: () => void;
|
||||
attributes?: MessageAttributes;
|
||||
}
|
||||
export function MessageDialog({ attributes, children, ...props }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
size="lg"
|
||||
centered
|
||||
backdrop={attributes?.staticBackdrop ? 'static' : 'true'}
|
||||
>
|
||||
<Modal.Body>
|
||||
{attributes?.title && (
|
||||
<Modal.Title>
|
||||
<strong>{attributes.title}</strong>
|
||||
</Modal.Title>
|
||||
)}
|
||||
{children && (
|
||||
<>
|
||||
<hr /> {children}
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
{attributes && (
|
||||
<Modal.Footer style={{ borderTop: 'none' }}>
|
||||
{attributes.close && (
|
||||
<Button
|
||||
variant={`outline-${
|
||||
attributes.close?.variant ?? 'secondary'
|
||||
}`}
|
||||
onClick={props.onHide}
|
||||
>
|
||||
{attributes.close?.text ?? constants.OK}
|
||||
</Button>
|
||||
)}
|
||||
{attributes.proceed && (
|
||||
<Button
|
||||
variant="outline-success"
|
||||
onClick={attributes.proceed.action}
|
||||
>
|
||||
{attributes.proceed.text}
|
||||
</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -1,12 +1,11 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
const Navbar = styled.div`
|
||||
padding: 8px 12px;
|
||||
font-size: 20px;
|
||||
line-height: 2rem;
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
min-height: 56px;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.7);
|
||||
|
|
117
src/components/PassphraseForm.tsx
Normal file
117
src/components/PassphraseForm.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import Container from 'components/Container';
|
||||
import styled from 'styled-components';
|
||||
import Card from 'react-bootstrap/Card';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { useRouter } from 'next/router';
|
||||
import * as Yup from 'yup';
|
||||
import { KeyAttributes } from 'types';
|
||||
import CryptoWorker, { setSessionKeys } from 'utils/crypto';
|
||||
import { Spinner } from 'react-bootstrap';
|
||||
import { propTypes } from 'react-bootstrap/esm/Image';
|
||||
|
||||
interface formValues {
|
||||
passphrase: string;
|
||||
}
|
||||
interface Props {
|
||||
callback: (passphrase: string, setFieldError) => void;
|
||||
fieldType: string;
|
||||
title: string;
|
||||
placeholder: string;
|
||||
buttonText: string;
|
||||
alternateOption: { text: string; click: () => void };
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
export default function PassPhraseForm(props: Props) {
|
||||
const [loading, SetLoading] = useState(false);
|
||||
const submitForm = async (
|
||||
values: formValues,
|
||||
{ setFieldError }: FormikHelpers<formValues>
|
||||
) => {
|
||||
SetLoading(true);
|
||||
await props.callback(values.passphrase, setFieldError);
|
||||
SetLoading(false);
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
<Card
|
||||
style={{ minWidth: '320px', padding: '40px 30px' }}
|
||||
className="text-center"
|
||||
>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ marginBottom: '24px' }}>
|
||||
{props.title}
|
||||
</Card.Title>
|
||||
<Formik<formValues>
|
||||
initialValues={{ passphrase: '' }}
|
||||
onSubmit={submitForm}
|
||||
validationSchema={Yup.object().shape({
|
||||
passphrase: Yup.string().required(
|
||||
constants.REQUIRED
|
||||
),
|
||||
})}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
touched,
|
||||
errors,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
}) => (
|
||||
<Form noValidate onSubmit={handleSubmit}>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type={props.fieldType}
|
||||
placeholder={props.placeholder}
|
||||
value={values.passphrase}
|
||||
onChange={handleChange('passphrase')}
|
||||
onBlur={handleBlur('passphrase')}
|
||||
isInvalid={Boolean(
|
||||
touched.passphrase &&
|
||||
errors.passphrase
|
||||
)}
|
||||
disabled={loading}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.passphrase}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Button block type="submit" disabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner animation="border" />
|
||||
) : (
|
||||
props.buttonText
|
||||
)}
|
||||
</Button>
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={props.alternateOption.click}
|
||||
>
|
||||
{props.alternateOption.text}
|
||||
</Button>
|
||||
<Button variant="link" onClick={props.back}>
|
||||
{constants.GO_BACK}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
135
src/components/PasswordForm.tsx
Normal file
135
src/components/PasswordForm.tsx
Normal file
|
@ -0,0 +1,135 @@
|
|||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import Container from 'components/Container';
|
||||
import styled from 'styled-components';
|
||||
import Card from 'react-bootstrap/Card';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import { Spinner } from 'react-bootstrap';
|
||||
|
||||
interface Props {
|
||||
callback: (passphrase: any, setFieldError: any) => Promise<void>;
|
||||
buttonText: string;
|
||||
back: () => void;
|
||||
}
|
||||
interface formValues {
|
||||
passphrase: string;
|
||||
confirm: string;
|
||||
}
|
||||
function SetPassword(props: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const onSubmit = async (
|
||||
values: formValues,
|
||||
{ setFieldError }: FormikHelpers<formValues>
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { passphrase, confirm } = values;
|
||||
if (passphrase === confirm) {
|
||||
await props.callback(passphrase, setFieldError);
|
||||
} else {
|
||||
setFieldError('confirm', constants.PASSPHRASE_MATCH_ERROR);
|
||||
}
|
||||
} catch (e) {
|
||||
setFieldError(
|
||||
'passphrase',
|
||||
`${constants.UNKNOWN_ERROR} ${e.message}`
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
<Card style={{ maxWidth: '540px', padding: '20px' }}>
|
||||
<Card.Body>
|
||||
<div
|
||||
className="text-center"
|
||||
style={{ marginBottom: '40px' }}
|
||||
>
|
||||
<p>{constants.ENTER_ENC_PASSPHRASE}</p>
|
||||
{constants.PASSPHRASE_DISCLAIMER()}
|
||||
</div>
|
||||
<Formik<formValues>
|
||||
initialValues={{ passphrase: '', confirm: '' }}
|
||||
validationSchema={Yup.object().shape({
|
||||
passphrase: Yup.string().required(
|
||||
constants.REQUIRED
|
||||
),
|
||||
confirm: Yup.string().required(constants.REQUIRED),
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
touched,
|
||||
errors,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
}) => (
|
||||
<Form noValidate onSubmit={handleSubmit}>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type="password"
|
||||
placeholder={constants.PASSPHRASE_HINT}
|
||||
value={values.passphrase}
|
||||
onChange={handleChange('passphrase')}
|
||||
onBlur={handleBlur('passphrase')}
|
||||
isInvalid={Boolean(
|
||||
touched.passphrase &&
|
||||
errors.passphrase
|
||||
)}
|
||||
autoFocus={true}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.passphrase}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type="password"
|
||||
placeholder={
|
||||
constants.PASSPHRASE_CONFIRM
|
||||
}
|
||||
value={values.confirm}
|
||||
onChange={handleChange('confirm')}
|
||||
onBlur={handleBlur('confirm')}
|
||||
isInvalid={Boolean(
|
||||
touched.confirm && errors.confirm
|
||||
)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.confirm}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Button
|
||||
type="submit"
|
||||
block
|
||||
disabled={loading}
|
||||
style={{ marginTop: '28px' }}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner animation="border" />
|
||||
) : (
|
||||
props.buttonText
|
||||
)}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<div className="text-center" style={{ marginTop: '20px' }}>
|
||||
<Button variant="link" onClick={props.back}>
|
||||
{constants.GO_BACK}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
export default SetPassword;
|
87
src/components/RecoveryKeyModal.tsx
Normal file
87
src/components/RecoveryKeyModal.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Spinner } from 'react-bootstrap';
|
||||
import { downloadAsFile } from 'utils/common';
|
||||
import { getRecoveryKey } from 'utils/crypto';
|
||||
import { setJustSignedUp } from 'utils/storage';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { MessageDialog } from './MessageDialog';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
somethingWentWrong: any;
|
||||
}
|
||||
function RecoveryKeyModal({ somethingWentWrong, ...props }: Props) {
|
||||
const [recoveryKey, setRecoveryKey] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!props.show) {
|
||||
return;
|
||||
}
|
||||
const main = async () => {
|
||||
const recoveryKey = await getRecoveryKey();
|
||||
if (!recoveryKey) {
|
||||
somethingWentWrong();
|
||||
props.onHide();
|
||||
}
|
||||
setRecoveryKey(recoveryKey);
|
||||
};
|
||||
main();
|
||||
}, [props.show]);
|
||||
|
||||
function onSaveClick() {
|
||||
downloadAsFile(constants.RECOVERY_KEY_FILENAME, recoveryKey);
|
||||
onClose();
|
||||
}
|
||||
function onClose() {
|
||||
props.onHide();
|
||||
setJustSignedUp(false);
|
||||
}
|
||||
return (
|
||||
<MessageDialog
|
||||
show={props.show}
|
||||
onHide={onClose}
|
||||
attributes={{
|
||||
title: constants.DOWNLOAD_RECOVERY_KEY,
|
||||
close: {
|
||||
text: constants.SAVE_LATER,
|
||||
variant: 'danger',
|
||||
},
|
||||
staticBackdrop: true,
|
||||
proceed: {
|
||||
text: constants.SAVE,
|
||||
action: onSaveClick,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<p>{constants.RECOVERY_KEY_DESCRIPTION}</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#1a1919',
|
||||
height: '150px',
|
||||
padding: '40px',
|
||||
color: 'white',
|
||||
margin: '20px 0',
|
||||
}}
|
||||
>
|
||||
{recoveryKey ? (
|
||||
<div
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
minWidth: '30%',
|
||||
}}
|
||||
>
|
||||
{recoveryKey}
|
||||
</div>
|
||||
) : (
|
||||
<Spinner animation="border" />
|
||||
)}
|
||||
</div>
|
||||
<p>{constants.KEY_NOT_STORED_DISCLAIMER}</p>
|
||||
</MessageDialog>
|
||||
);
|
||||
}
|
||||
export default RecoveryKeyModal;
|
|
@ -1,11 +1,10 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { slide as Menu } from 'react-burger-menu';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import { CONFIRM_ACTION } from 'components/ConfirmDialog';
|
||||
import Spinner from 'react-bootstrap/Spinner';
|
||||
import billingService, { Subscription } from 'services/billingService';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { logoutUser } from 'services/userService';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { getToken } from 'utils/common/key';
|
||||
import { getEndpoint } from 'utils/common/apiUtil';
|
||||
|
@ -19,20 +18,23 @@ import {
|
|||
isSubscribed,
|
||||
} from 'utils/billingUtil';
|
||||
|
||||
enum Action {
|
||||
logout = 'logout',
|
||||
cancelSubscription = 'cancel_subscription',
|
||||
}
|
||||
import exportService from 'services/exportService';
|
||||
import { file } from 'services/fileService';
|
||||
import isElectron from 'is-electron';
|
||||
import { collection } from 'services/collectionService';
|
||||
import { useRouter } from 'next/router';
|
||||
import RecoveryKeyModal from './RecoveryKeyModal';
|
||||
import { justSignedUp } from 'utils/storage';
|
||||
|
||||
interface Props {
|
||||
setNavbarIconView;
|
||||
files: file[];
|
||||
collections: collection[];
|
||||
setConfirmAction: any;
|
||||
somethingWentWrong: any;
|
||||
setPlanModalView;
|
||||
setBannerMessage;
|
||||
}
|
||||
export default function Sidebar(props: Props) {
|
||||
const [confirmModalView, setConfirmModalView] = useState(false);
|
||||
function closeConfirmModal() {
|
||||
setConfirmModalView(false);
|
||||
}
|
||||
const [usage, SetUsage] = useState<string>(null);
|
||||
const [action, setAction] = useState<string>(null);
|
||||
const [user, setUser] = useState(null);
|
||||
|
@ -42,6 +44,7 @@ export default function Sidebar(props: Props) {
|
|||
setSubscription(getUserSubscription());
|
||||
}, []);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [modalView, setModalView] = useState(justSignedUp());
|
||||
useEffect(() => {
|
||||
const main = async () => {
|
||||
if (!isOpen) {
|
||||
|
@ -55,39 +58,20 @@ export default function Sidebar(props: Props) {
|
|||
main();
|
||||
}, [isOpen]);
|
||||
|
||||
const logout = async () => {
|
||||
setConfirmModalView(false);
|
||||
setIsOpen(false);
|
||||
props.setNavbarIconView(false);
|
||||
logoutUser();
|
||||
};
|
||||
|
||||
const cancelSubscription = async () => {
|
||||
try {
|
||||
await billingService.cancelSubscription();
|
||||
props.setBannerMessage({
|
||||
message: constants.SUBSCRIPTION_CANCEL_SUCCESS,
|
||||
variant: 'secondary',
|
||||
});
|
||||
} catch (e) {
|
||||
props.setBannerMessage({
|
||||
message: constants.SUBSCRIPTION_CANCEL_FAILED,
|
||||
variant: 'danger',
|
||||
});
|
||||
}
|
||||
setConfirmModalView(false);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
let callback = new Map<string, Function>();
|
||||
callback.set(Action.logout, logout);
|
||||
callback.set(Action.cancelSubscription, cancelSubscription);
|
||||
function openFeedbackURL() {
|
||||
const feedbackURL: string =
|
||||
getEndpoint() + '/users/feedback?token=' + getToken();
|
||||
var win = window.open(feedbackURL, '_blank');
|
||||
win.focus();
|
||||
}
|
||||
function exportFiles() {
|
||||
if (isElectron()) {
|
||||
exportService.exportFiles(props.files, props.collections);
|
||||
} else {
|
||||
props.setConfirmAction(CONFIRM_ACTION.DOWNLOAD_APP);
|
||||
}
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
|
@ -138,8 +122,9 @@ export default function Sidebar(props: Props) {
|
|||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAction(Action.cancelSubscription);
|
||||
setConfirmModalView(true);
|
||||
props.setConfirmAction(
|
||||
CONFIRM_ACTION.CANCEL_SUBSCRIPTION
|
||||
);
|
||||
}}
|
||||
style={{ marginLeft: '10px' }}
|
||||
>
|
||||
|
@ -196,31 +181,54 @@ export default function Sidebar(props: Props) {
|
|||
<a
|
||||
href="mailto:contact@ente.io"
|
||||
style={{ textDecoration: 'inherit', color: 'inherit' }}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
support
|
||||
</a>
|
||||
</h5>
|
||||
<>
|
||||
<ConfirmDialog
|
||||
show={confirmModalView}
|
||||
onHide={closeConfirmModal}
|
||||
callback={callback}
|
||||
action={action}
|
||||
/>
|
||||
<h5
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: '#F96C6C',
|
||||
marginTop: '30px',
|
||||
}}
|
||||
onClick={() => {
|
||||
setAction(Action.logout);
|
||||
setConfirmModalView(true);
|
||||
}}
|
||||
>
|
||||
logout
|
||||
</h5>
|
||||
</>
|
||||
|
||||
<RecoveryKeyModal
|
||||
show={modalView}
|
||||
onHide={() => setModalView(false)}
|
||||
somethingWentWrong={props.somethingWentWrong}
|
||||
/>
|
||||
<h5
|
||||
style={{ cursor: 'pointer', marginTop: '30px' }}
|
||||
onClick={() => setModalView(true)}
|
||||
>
|
||||
{constants.DOWNLOAD_RECOVERY_KEY}
|
||||
</h5>
|
||||
<h5
|
||||
style={{ cursor: 'pointer', marginTop: '30px' }}
|
||||
onClick={() => router.push('changePassword')}
|
||||
>
|
||||
{constants.CHANGE_PASSWORD}
|
||||
</h5>
|
||||
<h5
|
||||
style={{ cursor: 'pointer', marginTop: '30px' }}
|
||||
onClick={exportFiles}
|
||||
>
|
||||
{constants.EXPORT}
|
||||
</h5>
|
||||
<div
|
||||
style={{
|
||||
height: '1px',
|
||||
marginTop: '40px',
|
||||
background: '#242424',
|
||||
width: '100%',
|
||||
}}
|
||||
></div>
|
||||
<h5
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: '#F96C6C',
|
||||
marginTop: '30px',
|
||||
}}
|
||||
onClick={() => props.setConfirmAction(CONFIRM_ACTION.LOGOUT)}
|
||||
>
|
||||
logout
|
||||
</h5>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
|||
import 'photoswipe/dist/photoswipe.css';
|
||||
|
||||
import UploadButton from 'pages/gallery/components/UploadButton';
|
||||
import FullScreenDropZone from 'components/FullScreenDropZone';
|
||||
import { sentryInit } from '../utils/sentry';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
|
@ -122,6 +121,23 @@ const GlobalStyles = createGlobalStyle`
|
|||
.btn-success:disabled {
|
||||
background-color: #69b383;
|
||||
}
|
||||
.btn-outline-success {
|
||||
color: #2dc262;
|
||||
border-color: #2dc262;
|
||||
border-width: 2px;
|
||||
}
|
||||
.btn-outline-success:hover {
|
||||
background: #2dc262;
|
||||
}
|
||||
.btn-outline-danger {
|
||||
border-width: 2px;
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
border-width: 2px;
|
||||
}
|
||||
.btn-outline-primary {
|
||||
border-width: 2px;
|
||||
}
|
||||
.card {
|
||||
background-color: #242424;
|
||||
color: #fff;
|
||||
|
@ -134,7 +150,8 @@ const GlobalStyles = createGlobalStyle`
|
|||
margin-top: 50px;
|
||||
}
|
||||
.alert-success {
|
||||
background-color: #c4ffd6;
|
||||
background-color: #a9f7ff;
|
||||
color: #000000;
|
||||
}
|
||||
.alert-primary {
|
||||
background-color: #c4ffd6;
|
||||
|
@ -147,12 +164,15 @@ const GlobalStyles = createGlobalStyle`
|
|||
position: fixed;
|
||||
width: 28px;
|
||||
height: 20px;
|
||||
left: 36px;
|
||||
margin-top: 30px;
|
||||
top:25px;
|
||||
left: 32px;
|
||||
}
|
||||
.bm-burger-bars {
|
||||
background: #bdbdbd;
|
||||
}
|
||||
.bm-menu-wrap {
|
||||
top:0px;
|
||||
}
|
||||
.bm-menu {
|
||||
background: #131313;
|
||||
padding: 2.5em 1.5em 0;
|
||||
|
@ -162,6 +182,9 @@ const GlobalStyles = createGlobalStyle`
|
|||
.bm-cross {
|
||||
background: #fff;
|
||||
}
|
||||
.bg-upload-progress-bar {
|
||||
background-color: #2dc262;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
|
@ -172,7 +195,6 @@ const Image = styled.img`
|
|||
const FlexContainer = styled.div`
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin: 16px;
|
||||
`;
|
||||
|
||||
export interface BannerMessage {
|
||||
|
@ -183,23 +205,17 @@ export interface BannerMessage {
|
|||
sentryInit();
|
||||
export default function App({ Component, pageProps, err }) {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [navbarIconView, setNavbarIconView] = useState(false);
|
||||
const [uploadModalView, setUploadModalView] = useState(false);
|
||||
const [planModalView, setPlanModalView] = useState(false);
|
||||
const [bannerMessage, setBannerMessage] = useState<BannerMessage>(null);
|
||||
const [collectionSelectorView, setCollectionSelectorView] = useState(false);
|
||||
|
||||
function closeUploadModal() {
|
||||
setUploadModalView(false);
|
||||
function closeCollectionSelector() {
|
||||
setCollectionSelectorView(false);
|
||||
}
|
||||
function showUploadModal() {
|
||||
setUploadModalView(true);
|
||||
function showCollectionSelector() {
|
||||
setCollectionSelectorView(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const user = getData(LS_KEYS.USER);
|
||||
setUser(user);
|
||||
console.log(
|
||||
`%c${constants.CONSOLE_WARNING_STOP}`,
|
||||
'color: red; font-size: 52px;'
|
||||
|
@ -213,38 +229,27 @@ export default function App({ Component, pageProps, err }) {
|
|||
});
|
||||
|
||||
router.events.on('routeChangeComplete', () => {
|
||||
const user = getData(LS_KEYS.USER);
|
||||
setUser(user);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onDropAccepted = useCallback(() => {
|
||||
showUploadModal();
|
||||
if (acceptedFiles != null && !collectionSelectorView) {
|
||||
showCollectionSelector();
|
||||
}
|
||||
}, []);
|
||||
const { getRootProps, getInputProps, open, acceptedFiles } = useDropzone({
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
onDropAccepted,
|
||||
accept: 'image/*, video/*, application/json, ',
|
||||
onDropAccepted,
|
||||
});
|
||||
return (
|
||||
<FullScreenDropZone
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{constants.TITLE}</title>
|
||||
<script async src={`https://sa.ente.io/latest.js`} />
|
||||
</Head>
|
||||
<GlobalStyles />
|
||||
<div style={{ display: navbarIconView ? 'block' : 'none' }}>
|
||||
<Sidebar
|
||||
setNavbarIconView={setNavbarIconView}
|
||||
setPlanModalView={setPlanModalView}
|
||||
setBannerMessage={setBannerMessage}
|
||||
/>
|
||||
</div>
|
||||
<Navbar>
|
||||
<FlexContainer>
|
||||
<Image
|
||||
|
@ -253,7 +258,6 @@ export default function App({ Component, pageProps, err }) {
|
|||
src="/icon.svg"
|
||||
/>
|
||||
</FlexContainer>
|
||||
{navbarIconView && <UploadButton openFileUploader={open} />}
|
||||
</Navbar>
|
||||
{loading ? (
|
||||
<Container>
|
||||
|
@ -263,19 +267,16 @@ export default function App({ Component, pageProps, err }) {
|
|||
</Container>
|
||||
) : (
|
||||
<Component
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
openFileUploader={open}
|
||||
acceptedFiles={acceptedFiles}
|
||||
uploadModalView={uploadModalView}
|
||||
showUploadModal={showUploadModal}
|
||||
closeUploadModal={closeUploadModal}
|
||||
setNavbarIconView={setNavbarIconView}
|
||||
planModalView={planModalView}
|
||||
setPlanModalView={setPlanModalView}
|
||||
bannerMessage={bannerMessage}
|
||||
setBannerMessage={setBannerMessage}
|
||||
collectionSelectorView={collectionSelectorView}
|
||||
showCollectionSelector={showCollectionSelector}
|
||||
closeCollectionSelector={closeCollectionSelector}
|
||||
err={err}
|
||||
/>
|
||||
)}
|
||||
</FullScreenDropZone>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
79
src/pages/changePassword/index.tsx
Normal file
79
src/pages/changePassword/index.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getKey, SESSION_KEYS, setKey } from 'utils/storage/sessionStorage';
|
||||
import { B64EncryptionResult } from 'services/uploadService';
|
||||
import CryptoWorker, {
|
||||
setSessionKeys,
|
||||
generateAndSaveIntermediateKeyAttributes,
|
||||
} from 'utils/crypto';
|
||||
import { getActualKey } from 'utils/common/key';
|
||||
import { logoutUser, setKeys, UpdatedKey } from 'services/userService';
|
||||
import PasswordForm from 'components/PasswordForm';
|
||||
|
||||
export interface KEK {
|
||||
key: string;
|
||||
opsLimit: number;
|
||||
memLimit: number;
|
||||
}
|
||||
|
||||
export default function Generate() {
|
||||
const [token, setToken] = useState<string>();
|
||||
const router = useRouter();
|
||||
const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
|
||||
|
||||
useEffect(() => {
|
||||
const user = getData(LS_KEYS.USER);
|
||||
if (!user?.token) {
|
||||
router.push('/');
|
||||
} else {
|
||||
setToken(user.token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (passphrase, setFieldError) => {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
const key: string = await getActualKey();
|
||||
const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
|
||||
const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
|
||||
let kek: KEK;
|
||||
try {
|
||||
kek = await cryptoWorker.deriveSensitiveKey(passphrase, kekSalt);
|
||||
} catch (e) {
|
||||
setFieldError('confirm', constants.PASSWORD_GENERATION_FAILED);
|
||||
return;
|
||||
}
|
||||
const encryptedKeyAttributes: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
key,
|
||||
kek.key
|
||||
);
|
||||
const updatedKey: UpdatedKey = {
|
||||
kekSalt,
|
||||
encryptedKey: encryptedKeyAttributes.encryptedData,
|
||||
keyDecryptionNonce: encryptedKeyAttributes.nonce,
|
||||
opsLimit: kek.opsLimit,
|
||||
memLimit: kek.memLimit,
|
||||
};
|
||||
|
||||
await setKeys(token, updatedKey);
|
||||
|
||||
const updatedKeyAttributes = Object.assign(keyAttributes, updatedKey);
|
||||
await generateAndSaveIntermediateKeyAttributes(
|
||||
passphrase,
|
||||
updatedKeyAttributes,
|
||||
key
|
||||
);
|
||||
|
||||
setSessionKeys(key);
|
||||
router.push('/gallery');
|
||||
};
|
||||
|
||||
return (
|
||||
<PasswordForm
|
||||
callback={onSubmit}
|
||||
buttonText={constants.CHANGE_PASSWORD}
|
||||
back={() => router.push('/gallery')}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,33 +1,22 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import Container from 'components/Container';
|
||||
import styled from 'styled-components';
|
||||
import Card from 'react-bootstrap/Card';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
|
||||
import constants from 'utils/strings/constants';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { useRouter } from 'next/router';
|
||||
import * as Yup from 'yup';
|
||||
import { keyAttributes } from 'types';
|
||||
import { setKey, SESSION_KEYS, getKey } from 'utils/storage/sessionStorage';
|
||||
import CryptoWorker from 'utils/crypto/cryptoWorker';
|
||||
import { KeyAttributes } from 'types';
|
||||
import { SESSION_KEYS, getKey } from 'utils/storage/sessionStorage';
|
||||
import CryptoWorker, {
|
||||
generateAndSaveIntermediateKeyAttributes,
|
||||
setSessionKeys,
|
||||
} from 'utils/crypto';
|
||||
import { logoutUser } from 'services/userService';
|
||||
|
||||
const Image = styled.img`
|
||||
width: 200px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
interface formValues {
|
||||
passphrase: string;
|
||||
}
|
||||
import { isFirstLogin } from 'utils/storage';
|
||||
import PassPhraseForm from 'components/PassphraseForm';
|
||||
|
||||
export default function Credentials() {
|
||||
const router = useRouter();
|
||||
const [keyAttributes, setKeyAttributes] = useState<keyAttributes>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
|
||||
|
||||
useEffect(() => {
|
||||
router.prefetch('/gallery');
|
||||
|
@ -45,35 +34,33 @@ export default function Credentials() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const verifyPassphrase = async (
|
||||
values: formValues,
|
||||
{ setFieldError }: FormikHelpers<formValues>
|
||||
) => {
|
||||
setLoading(true);
|
||||
const verifyPassphrase = async (passphrase, setFieldError) => {
|
||||
try {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
const { passphrase } = values;
|
||||
const kek: string = await cryptoWorker.deriveKey(
|
||||
passphrase,
|
||||
keyAttributes.kekSalt
|
||||
keyAttributes.kekSalt,
|
||||
keyAttributes.opsLimit,
|
||||
keyAttributes.memLimit
|
||||
);
|
||||
|
||||
if (await cryptoWorker.verifyHash(keyAttributes.kekHash, kek)) {
|
||||
try {
|
||||
const key: string = await cryptoWorker.decryptB64(
|
||||
keyAttributes.encryptedKey,
|
||||
keyAttributes.keyDecryptionNonce,
|
||||
kek
|
||||
);
|
||||
const sessionKeyAttributes = await cryptoWorker.encryptToB64(
|
||||
key
|
||||
);
|
||||
const sessionKey = sessionKeyAttributes.key;
|
||||
const sessionNonce = sessionKeyAttributes.nonce;
|
||||
const encryptionKey = sessionKeyAttributes.encryptedData;
|
||||
setKey(SESSION_KEYS.ENCRYPTION_KEY, { encryptionKey });
|
||||
setData(LS_KEYS.SESSION, { sessionKey, sessionNonce });
|
||||
if (isFirstLogin()) {
|
||||
generateAndSaveIntermediateKeyAttributes(
|
||||
passphrase,
|
||||
keyAttributes,
|
||||
key
|
||||
);
|
||||
}
|
||||
setSessionKeys(key);
|
||||
router.push('/gallery');
|
||||
} else {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setFieldError('passphrase', constants.INCORRECT_PASSPHRASE);
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -82,72 +69,20 @@ export default function Credentials() {
|
|||
`${constants.UNKNOWN_ERROR} ${e.message}`
|
||||
);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* <Image alt="vault" src="/vault.png" /> */}
|
||||
<Card
|
||||
style={{ minWidth: '320px', padding: '40px 30px' }}
|
||||
className="text-center"
|
||||
>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ marginBottom: '24px' }}>
|
||||
{constants.ENTER_PASSPHRASE}
|
||||
</Card.Title>
|
||||
<Formik<formValues>
|
||||
initialValues={{ passphrase: '' }}
|
||||
onSubmit={verifyPassphrase}
|
||||
validationSchema={Yup.object().shape({
|
||||
passphrase: Yup.string().required(
|
||||
constants.REQUIRED
|
||||
),
|
||||
})}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
touched,
|
||||
errors,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
}) => (
|
||||
<Form noValidate onSubmit={handleSubmit}>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type="password"
|
||||
placeholder={
|
||||
constants.RETURN_PASSPHRASE_HINT
|
||||
}
|
||||
value={values.passphrase}
|
||||
onChange={handleChange('passphrase')}
|
||||
onBlur={handleBlur('passphrase')}
|
||||
isInvalid={Boolean(
|
||||
touched.passphrase &&
|
||||
errors.passphrase
|
||||
)}
|
||||
disabled={loading}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.passphrase}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Button block type="submit" disabled={loading}>
|
||||
{constants.VERIFY_PASSPHRASE}
|
||||
</Button>
|
||||
<br />
|
||||
<div>
|
||||
<a href="#" onClick={logoutUser}>
|
||||
{constants.LOGOUT}
|
||||
</a>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
<PassPhraseForm
|
||||
callback={verifyPassphrase}
|
||||
title={constants.ENTER_PASSPHRASE}
|
||||
placeholder={constants.RETURN_PASSPHRASE_HINT}
|
||||
buttonText={constants.VERIFY_PASSPHRASE}
|
||||
fieldType="password"
|
||||
alternateOption={{
|
||||
text: constants.FORGOT_PASSWORD,
|
||||
click: () => router.push('/recover'),
|
||||
}}
|
||||
back={logoutUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Card, Form, Modal } from 'react-bootstrap';
|
||||
import styled from 'styled-components';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { CollectionIcon } from './CollectionSelector';
|
||||
|
||||
const ImageContainer = styled.div`
|
||||
min-height: 192px;
|
||||
max-width: 192px;
|
||||
border: 1px solid #555;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 42px;
|
||||
`;
|
||||
|
||||
export default function AddCollection({
|
||||
uploadFiles,
|
||||
autoFilledName,
|
||||
triggerCreateCollectionOpen,
|
||||
}) {
|
||||
const [createCollectionView, setCreateCollectionView] = useState(false);
|
||||
const [albumName, setAlbumName] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const handleChange = (event) => {
|
||||
setAlbumName(event.target.value);
|
||||
};
|
||||
useEffect(() => {
|
||||
setAlbumName(autoFilledName);
|
||||
setCreateCollectionView(triggerCreateCollectionOpen);
|
||||
setTimeout(() => {
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}, 1);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
await uploadFiles(null, albumName);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<CollectionIcon
|
||||
style={{ margin: '10px' }}
|
||||
onClick={() => setCreateCollectionView(true)}
|
||||
>
|
||||
<Card>
|
||||
<ImageContainer>+</ImageContainer>
|
||||
<Card.Text style={{ textAlign: 'center' }}>
|
||||
{constants.CREATE_COLLECTION}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</CollectionIcon>
|
||||
<Modal
|
||||
show={createCollectionView}
|
||||
onHide={() => setCreateCollectionView(false)}
|
||||
centered
|
||||
backdrop="static"
|
||||
style={{ background: 'rgba(0, 0, 0, 0.8)' }}
|
||||
dialogClassName="ente-modal"
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{constants.CREATE_COLLECTION}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group controlId="formBasicEmail">
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={constants.ALBUM_NAME}
|
||||
value={albumName}
|
||||
onChange={handleChange}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button
|
||||
variant="success"
|
||||
type="submit"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{constants.CREATE}
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
29
src/pages/gallery/components/AddCollectionButton.tsx
Normal file
29
src/pages/gallery/components/AddCollectionButton.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { Card } from 'react-bootstrap';
|
||||
import styled from 'styled-components';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { CollectionIcon } from './CollectionSelector';
|
||||
|
||||
const ImageContainer = styled.div`
|
||||
min-height: 192px;
|
||||
max-width: 192px;
|
||||
border: 1px solid #555;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 42px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default function AddCollectionButton({ showChoiceModal }) {
|
||||
return (
|
||||
<CollectionIcon style={{ margin: '10px' }} onClick={showChoiceModal}>
|
||||
<Card>
|
||||
<ImageContainer>+</ImageContainer>
|
||||
<Card.Text style={{ textAlign: 'center' }}>
|
||||
{constants.CREATE_COLLECTION}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</CollectionIcon>
|
||||
);
|
||||
}
|
16
src/pages/gallery/components/AlertBanner.tsx
Normal file
16
src/pages/gallery/components/AlertBanner.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import Alert from 'react-bootstrap/Alert';
|
||||
|
||||
export default function AlertBanner({ bannerMessage }) {
|
||||
return (
|
||||
<Alert
|
||||
variant={'danger'}
|
||||
style={{
|
||||
display: bannerMessage ? 'block' : 'none',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{bannerMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
83
src/pages/gallery/components/ChoiceModal.tsx
Normal file
83
src/pages/gallery/components/ChoiceModal.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
import { Button, Modal } from 'react-bootstrap';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { UPLOAD_STRATEGY } from './Upload';
|
||||
|
||||
interface Props {
|
||||
uploadFiles;
|
||||
show;
|
||||
onHide;
|
||||
showCollectionCreateModal;
|
||||
setTriggerFocus;
|
||||
}
|
||||
function ChoiceModal({
|
||||
uploadFiles,
|
||||
showCollectionCreateModal,
|
||||
setTriggerFocus,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
size="lg"
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
>
|
||||
<Modal.Body style={{ padding: '24px' }}>
|
||||
<Modal.Header
|
||||
style={{
|
||||
borderColor: 'rgb(16, 176, 2)',
|
||||
fontSize: '20px',
|
||||
marginBottom: '20px',
|
||||
border: 'none',
|
||||
}}
|
||||
id="contained-modal-title-vcenter"
|
||||
closeButton
|
||||
>
|
||||
{constants.UPLOAD_STRATEGY_CHOICE}
|
||||
</Modal.Header>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
paddingBottom: '20px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline-success"
|
||||
onClick={() => {
|
||||
props.onHide();
|
||||
setTriggerFocus((prev) => !prev);
|
||||
showCollectionCreateModal();
|
||||
}}
|
||||
style={{
|
||||
padding: '12px',
|
||||
paddingLeft: '24px',
|
||||
paddingRight: '24px'
|
||||
}}
|
||||
>
|
||||
{constants.UPLOAD_STRATEGY_SINGLE_COLLECTION}
|
||||
</Button>
|
||||
<strong>
|
||||
{constants.OR}
|
||||
</strong>
|
||||
<Button
|
||||
variant="outline-success"
|
||||
onClick={() =>
|
||||
uploadFiles(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER)
|
||||
}
|
||||
style={{
|
||||
padding: '12px',
|
||||
paddingLeft: '24px',
|
||||
paddingRight: '24px'
|
||||
}}
|
||||
>
|
||||
{constants.UPLOAD_STRATEGY_COLLECTION_PER_FOLDER}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
export default ChoiceModal;
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Card, Modal } from 'react-bootstrap';
|
||||
import AddCollection from './AddCollection';
|
||||
import { Card, Modal, Spinner } from 'react-bootstrap';
|
||||
import AddCollectionButton from './AddCollectionButton';
|
||||
import PreviewCard from './PreviewCard';
|
||||
import constants from 'utils/strings/constants';
|
||||
import styled from 'styled-components';
|
||||
|
@ -16,18 +16,35 @@ export const CollectionIcon = styled.div`
|
|||
outline: none;
|
||||
`;
|
||||
|
||||
function CollectionSelector({
|
||||
collectionAndItsLatestFile,
|
||||
uploadFiles,
|
||||
uploadModalView,
|
||||
closeUploadModal,
|
||||
suggestedCollectionName,
|
||||
}) {
|
||||
const CollectionIcons: JSX.Element[] = collectionAndItsLatestFile?.map(
|
||||
const LoadingOverlay = styled.div`
|
||||
left: 0;
|
||||
top: 0;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 9;
|
||||
`;
|
||||
interface Props {
|
||||
collectionAndItsLatestFile;
|
||||
uploadFiles;
|
||||
collectionSelectorView;
|
||||
closeCollectionSelector;
|
||||
showNextModal;
|
||||
loading;
|
||||
}
|
||||
function CollectionSelector(props: Props) {
|
||||
const CollectionIcons: JSX.Element[] = props.collectionAndItsLatestFile?.map(
|
||||
(item) => (
|
||||
<CollectionIcon
|
||||
key={item.collection.id}
|
||||
onClick={async () => await uploadFiles(item.collection)}
|
||||
onClick={async () => await props.uploadFiles(item.collection)}
|
||||
>
|
||||
<Card>
|
||||
<PreviewCard
|
||||
|
@ -45,15 +62,13 @@ function CollectionSelector({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
show={uploadModalView}
|
||||
onHide={closeUploadModal}
|
||||
show={props.collectionSelectorView}
|
||||
onHide={props.closeCollectionSelector}
|
||||
dialogClassName="modal-90w"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title style={{ marginLeft: '12px' }}>
|
||||
{constants.SELECT_COLLECTION}
|
||||
</Modal.Title>
|
||||
<Modal.Title>{constants.SELECT_COLLECTION}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body
|
||||
style={{
|
||||
|
@ -62,14 +77,13 @@ function CollectionSelector({
|
|||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<AddCollection
|
||||
uploadFiles={uploadFiles}
|
||||
autoFilledName={suggestedCollectionName}
|
||||
triggerCreateCollectionOpen={
|
||||
CollectionIcons && CollectionIcons.length == 0
|
||||
}
|
||||
/>
|
||||
<AddCollectionButton showChoiceModal={props.showNextModal} />
|
||||
{CollectionIcons}
|
||||
{props.loading && (
|
||||
<LoadingOverlay>
|
||||
<Spinner animation="border" />
|
||||
</LoadingOverlay>
|
||||
)}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
|
|
67
src/pages/gallery/components/CreateCollection.tsx
Normal file
67
src/pages/gallery/components/CreateCollection.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Modal, Form, Button } from 'react-bootstrap';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { UPLOAD_STRATEGY } from './Upload';
|
||||
|
||||
interface Props {
|
||||
createCollectionView;
|
||||
setCreateCollectionView;
|
||||
autoFilledName;
|
||||
uploadFiles: (strategy: UPLOAD_STRATEGY, collectionName) => void;
|
||||
triggerFocus;
|
||||
}
|
||||
export default function CreateCollection(props: Props) {
|
||||
const [albumName, setAlbumName] = useState('');
|
||||
const handleChange = (event) => {
|
||||
setAlbumName(event.target.value);
|
||||
};
|
||||
const collectionNameInputRef = useRef(null);
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
props.setCreateCollectionView(false);
|
||||
await props.uploadFiles(UPLOAD_STRATEGY.SINGLE_COLLECTION, albumName);
|
||||
};
|
||||
useEffect(() => {
|
||||
setAlbumName(props.autoFilledName);
|
||||
}, [props.autoFilledName]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
collectionNameInputRef.current?.focus();
|
||||
}, 200);
|
||||
}, [props.triggerFocus]);
|
||||
return (
|
||||
<Modal
|
||||
show={props.createCollectionView}
|
||||
onHide={() => props.setCreateCollectionView(false)}
|
||||
centered
|
||||
backdrop="static"
|
||||
style={{ background: 'rgba(0, 0, 0, 0.8)' }}
|
||||
dialogClassName="ente-modal"
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{constants.CREATE_COLLECTION}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group controlId="formBasicEmail">
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={constants.ALBUM_NAME}
|
||||
value={albumName}
|
||||
onChange={handleChange}
|
||||
ref={collectionNameInputRef}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{constants.CREATE}
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -39,56 +39,42 @@ const LoaderOverlay = styled.div`
|
|||
interface Props {
|
||||
modalView: boolean;
|
||||
closeModal: any;
|
||||
setBannerMessage;
|
||||
setDialogMessage;
|
||||
}
|
||||
function PlanSelector(props: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const subscription = getUserSubscription();
|
||||
const plans = getPlans();
|
||||
const selectPlan = async (plan) => {
|
||||
var bannerMessage;
|
||||
try {
|
||||
setLoading(true);
|
||||
if (hasPaidPlan(subscription)) {
|
||||
if (isUserRenewingPlan(plan, subscription)) {
|
||||
return;
|
||||
}
|
||||
await updateSubscription(plan);
|
||||
await billingService.updateSubscription(plan.stripeID);
|
||||
setLoading(false);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(() => resolve(null), 400)
|
||||
);
|
||||
} else {
|
||||
await billingService.buySubscription(plan.stripeID);
|
||||
}
|
||||
props.setDialogMessage({
|
||||
title: constants.SUBSCRIPTION_UPDATE_SUCCESS,
|
||||
close: { variant: 'success' },
|
||||
});
|
||||
} catch (err) {
|
||||
bannerMessage = {
|
||||
message: constants.SUBSCRIPTION_PURCHASE_FAILED,
|
||||
variant: 'danger',
|
||||
};
|
||||
props.setBannerMessage(bannerMessage);
|
||||
props.setDialogMessage({
|
||||
title: constants.SUBSCRIPTION_PURCHASE_FAILED,
|
||||
close: { variant: 'danger' },
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
props.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const updateSubscription = async (plan) => {
|
||||
try {
|
||||
await billingService.updateSubscription(plan.stripeID);
|
||||
let bannerMessage = {
|
||||
message: constants.SUBSCRIPTION_UPDATE_SUCCESS,
|
||||
variant: 'success',
|
||||
};
|
||||
setLoading(false);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(() => resolve(null), 400)
|
||||
);
|
||||
props.setBannerMessage(bannerMessage);
|
||||
} catch (err) {
|
||||
let bannerMessage = {
|
||||
message: constants.SUBSCRIPTION_PURCHASE_FAILED,
|
||||
variant: 'danger',
|
||||
};
|
||||
props.setBannerMessage(bannerMessage);
|
||||
}
|
||||
};
|
||||
const PlanIcons: JSX.Element[] = plans?.map((plan) => (
|
||||
<PlanIcon
|
||||
key={plan.stripeID}
|
||||
|
|
|
@ -1,20 +1,75 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { SyntheticEvent, useEffect, useState } from 'react';
|
||||
import { file } from 'services/fileService';
|
||||
import styled from 'styled-components';
|
||||
import PlayCircleOutline from 'components/PlayCircleOutline';
|
||||
import DownloadManager from 'services/downloadManager';
|
||||
import { getToken } from 'utils/common/key';
|
||||
import useLongPress from 'utils/common/useLongPress';
|
||||
|
||||
interface IProps {
|
||||
data: file;
|
||||
updateUrl: (url: string) => void;
|
||||
onClick?: () => void;
|
||||
forcedEnable?: boolean;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onSelect?: (checked: boolean) => void;
|
||||
selectOnClick?: boolean;
|
||||
}
|
||||
|
||||
const Cont = styled.div<{ disabled: boolean }>`
|
||||
const Check = styled.input`
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
outline: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #fff;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
vertical-align: bottom;
|
||||
margin: 8px 8px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border-right: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: translate(-18px, 8px);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s ease;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/** checked */
|
||||
&:checked::before {
|
||||
content: '';
|
||||
background-color: #2dc262;
|
||||
border-color: #2dc262;
|
||||
color: #fff;
|
||||
}
|
||||
&:checked::after {
|
||||
opacity: 1;
|
||||
transform: translate(-18px, 10px) rotate(45deg);
|
||||
}
|
||||
|
||||
&:checked {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Cont = styled.div<{ disabled: boolean; selected: boolean }>`
|
||||
background: #222;
|
||||
display: block;
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
height: 192px;
|
||||
min-width: 100%;
|
||||
|
@ -26,6 +81,8 @@ const Cont = styled.div<{ disabled: boolean }>`
|
|||
object-fit: cover;
|
||||
max-width: 100%;
|
||||
min-height: 100%;
|
||||
flex: 1;
|
||||
${(props) => props.selected && 'border: 5px solid #2dc262;'}
|
||||
}
|
||||
|
||||
& > svg {
|
||||
|
@ -39,11 +96,24 @@ const Cont = styled.div<{ disabled: boolean }>`
|
|||
left: -25px;
|
||||
filter: drop-shadow(3px 3px 2px rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
|
||||
&:hover ${Check} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function PreviewCard(props: IProps) {
|
||||
const [imgSrc, setImgSrc] = useState<string>();
|
||||
const { data, onClick, updateUrl, forcedEnable } = props;
|
||||
const {
|
||||
data,
|
||||
onClick,
|
||||
updateUrl,
|
||||
forcedEnable,
|
||||
selectable,
|
||||
selected,
|
||||
onSelect,
|
||||
selectOnClick,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !data.msrc) {
|
||||
|
@ -58,17 +128,37 @@ export default function PreviewCard(props: IProps) {
|
|||
}, [data]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (data?.msrc || imgSrc) {
|
||||
if (selectOnClick) {
|
||||
onSelect?.(!selected);
|
||||
} else if (data?.msrc || imgSrc) {
|
||||
onClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
onSelect?.(e.target.checked);
|
||||
};
|
||||
|
||||
const longPressCallback = () => {
|
||||
onSelect(!selected);
|
||||
};
|
||||
|
||||
return (
|
||||
<Cont
|
||||
onClick={handleClick}
|
||||
disabled={!forcedEnable && !data?.msrc && !imgSrc}
|
||||
selected={selected}
|
||||
{...(selectable ? useLongPress(longPressCallback, 500) : {})}
|
||||
>
|
||||
<img src={data?.msrc || imgSrc} />
|
||||
{selectable && (
|
||||
<Check
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={handleSelect}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{(data?.msrc || imgSrc) && <img src={data?.msrc || imgSrc} />}
|
||||
{data?.metadata.fileType === 1 && <PlayCircleOutline />}
|
||||
</Cont>
|
||||
);
|
||||
|
|
|
@ -1,99 +1,214 @@
|
|||
import React, { useState } from 'react';
|
||||
import { UPLOAD_STAGES } from 'services/uploadService';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FileWithCollection, UPLOAD_STAGES } from 'services/uploadService';
|
||||
import { getToken } from 'utils/common/key';
|
||||
import CollectionSelector from './CollectionSelector';
|
||||
import UploadProgress from './UploadProgress';
|
||||
import UploadService from 'services/uploadService';
|
||||
import { createAlbum } from 'services/collectionService';
|
||||
import { ErrorBannerMessage } from 'utils/common/errorUtil';
|
||||
import CreateCollection from './CreateCollection';
|
||||
import ChoiceModal from './ChoiceModal';
|
||||
|
||||
interface Props {
|
||||
uploadModalView: any;
|
||||
closeUploadModal;
|
||||
collectionSelectorView: any;
|
||||
closeCollectionSelector;
|
||||
collectionAndItsLatestFile;
|
||||
refetchData;
|
||||
setBannerMessage;
|
||||
acceptedFiles;
|
||||
}
|
||||
|
||||
export enum UPLOAD_STRATEGY {
|
||||
SINGLE_COLLECTION,
|
||||
COLLECTION_PER_FOLDER,
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
suggestedCollectionName: string;
|
||||
multipleFolders: boolean;
|
||||
}
|
||||
export default function Upload(props: Props) {
|
||||
const [progressView, setProgressView] = useState(false);
|
||||
const [uploadStage, setUploadStage] = useState<UPLOAD_STAGES>(
|
||||
UPLOAD_STAGES.START
|
||||
);
|
||||
const [fileCounter, setFileCounter] = useState({ current: 0, total: 0 });
|
||||
const [fileProgress, setFileProgress] = useState(new Map<string, number>());
|
||||
const [percentComplete, setPercentComplete] = useState(0);
|
||||
const [uploadErrors, setUploadErrors] = useState<Error[]>([]);
|
||||
const [createCollectionView, setCreateCollectionView] = useState(false);
|
||||
const [choiceModalView, setChoiceModalView] = useState(false);
|
||||
const [
|
||||
fileAnalysisResult,
|
||||
setFileAnalysisResult,
|
||||
] = useState<AnalysisResult>(null);
|
||||
const [triggerFocus, setTriggerFocus] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!props.collectionSelectorView) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
props.collectionAndItsLatestFile &&
|
||||
props.collectionAndItsLatestFile.length == 0
|
||||
) {
|
||||
nextModal();
|
||||
}
|
||||
init();
|
||||
}, [props.acceptedFiles, props.collectionSelectorView]);
|
||||
|
||||
const init = () => {
|
||||
setProgressView(false);
|
||||
setUploadStage(UPLOAD_STAGES.START);
|
||||
setFileCounter({ current: 0, total: 0 });
|
||||
setPercentComplete(0);
|
||||
};
|
||||
|
||||
const uploadFiles = async (collection, collectionName) => {
|
||||
try {
|
||||
const token = getToken();
|
||||
setPercentComplete(0);
|
||||
setProgressView(true);
|
||||
props.closeUploadModal();
|
||||
if (!collection) {
|
||||
collection = await createAlbum(collectionName);
|
||||
}
|
||||
await UploadService.uploadFiles(
|
||||
props.acceptedFiles,
|
||||
collection,
|
||||
token,
|
||||
{
|
||||
setPercentComplete,
|
||||
setFileCounter,
|
||||
setUploadStage,
|
||||
},
|
||||
setUploadErrors
|
||||
);
|
||||
props.refetchData();
|
||||
} catch (err) {
|
||||
props.setBannerMessage({
|
||||
message: ErrorBannerMessage(err.message),
|
||||
variant: 'danger',
|
||||
});
|
||||
setProgressView(false);
|
||||
function analyseUploadFiles() {
|
||||
if (props.acceptedFiles.length == 0) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
let commonPathPrefix = '';
|
||||
if (props.acceptedFiles.length > 0) {
|
||||
commonPathPrefix = (() => {
|
||||
const paths: string[] = props.acceptedFiles.map(
|
||||
(files) => files.path
|
||||
);
|
||||
paths.sort();
|
||||
let firstPath = paths[0],
|
||||
lastPath = paths[paths.length - 1],
|
||||
L = firstPath.length,
|
||||
i = 0;
|
||||
while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++;
|
||||
return firstPath.substring(0, i);
|
||||
})();
|
||||
const paths: string[] = props.acceptedFiles.map((file) => file.path);
|
||||
paths.sort();
|
||||
let firstPath = paths[0],
|
||||
lastPath = paths[paths.length - 1],
|
||||
L = firstPath.length,
|
||||
i = 0;
|
||||
const firstFileFolder = firstPath.substr(0, firstPath.lastIndexOf('/'));
|
||||
const lastFileFolder = lastPath.substr(0, lastPath.lastIndexOf('/'));
|
||||
while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++;
|
||||
let commonPathPrefix = firstPath.substring(0, i);
|
||||
if (commonPathPrefix) {
|
||||
commonPathPrefix = commonPathPrefix.substr(
|
||||
1,
|
||||
commonPathPrefix.lastIndexOf('/') - 1
|
||||
);
|
||||
}
|
||||
return {
|
||||
suggestedCollectionName: commonPathPrefix,
|
||||
multipleFolders: firstFileFolder !== lastFileFolder,
|
||||
};
|
||||
}
|
||||
function getCollectionWiseFiles() {
|
||||
let collectionWiseFiles = new Map<string, any>();
|
||||
for (let file of props.acceptedFiles) {
|
||||
const filePath = file.path;
|
||||
const folderPath = filePath.substr(0, filePath.lastIndexOf('/'));
|
||||
const folderName = folderPath.substr(
|
||||
folderPath.lastIndexOf('/') + 1
|
||||
);
|
||||
if (!collectionWiseFiles.has(folderName)) {
|
||||
collectionWiseFiles.set(folderName, new Array<File>());
|
||||
}
|
||||
collectionWiseFiles.get(folderName).push(file);
|
||||
}
|
||||
return collectionWiseFiles;
|
||||
}
|
||||
|
||||
const uploadFilesToExistingCollection = async (collection) => {
|
||||
try {
|
||||
props.closeCollectionSelector();
|
||||
setProgressView(true);
|
||||
|
||||
let filesWithCollectionToUpload: FileWithCollection[] = props.acceptedFiles.map(
|
||||
(file) => ({
|
||||
file,
|
||||
collection,
|
||||
})
|
||||
);
|
||||
await uploadFiles(filesWithCollectionToUpload);
|
||||
} catch (e) {
|
||||
console.error('Failed to upload files to existing collections', e);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFilesToNewCollections = async (
|
||||
strategy: UPLOAD_STRATEGY,
|
||||
collectionName
|
||||
) => {
|
||||
try {
|
||||
setChoiceModalView(false);
|
||||
props.closeCollectionSelector();
|
||||
setProgressView(true);
|
||||
|
||||
if (strategy == UPLOAD_STRATEGY.SINGLE_COLLECTION) {
|
||||
let collection = await createAlbum(collectionName);
|
||||
|
||||
return await uploadFilesToExistingCollection(collection);
|
||||
}
|
||||
const collectionWiseFiles = getCollectionWiseFiles();
|
||||
let filesWithCollectionToUpload = new Array<FileWithCollection>();
|
||||
for (let [collectionName, files] of collectionWiseFiles) {
|
||||
let collection = await createAlbum(collectionName);
|
||||
for (let file of files) {
|
||||
filesWithCollectionToUpload.push({ collection, file });
|
||||
}
|
||||
}
|
||||
await uploadFiles(filesWithCollectionToUpload);
|
||||
} catch (e) {
|
||||
console.error('Failed to upload files to new collections', e);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (
|
||||
filesWithCollectionToUpload: FileWithCollection[]
|
||||
) => {
|
||||
try {
|
||||
await UploadService.uploadFiles(
|
||||
filesWithCollectionToUpload,
|
||||
{
|
||||
setPercentComplete,
|
||||
setFileCounter,
|
||||
setUploadStage,
|
||||
setFileProgress,
|
||||
},
|
||||
setUploadErrors
|
||||
);
|
||||
} catch (err) {
|
||||
props.setBannerMessage(err.message);
|
||||
} finally {
|
||||
props.refetchData();
|
||||
setProgressView(false);
|
||||
}
|
||||
};
|
||||
const nextModal = () => {
|
||||
let fileAnalysisResult = analyseUploadFiles();
|
||||
if (!fileAnalysisResult) {
|
||||
return;
|
||||
}
|
||||
setTriggerFocus((prev) => !prev);
|
||||
fileAnalysisResult.multipleFolders
|
||||
? setChoiceModalView(true)
|
||||
: setCreateCollectionView(true);
|
||||
setFileAnalysisResult(fileAnalysisResult);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<CollectionSelector
|
||||
collectionAndItsLatestFile={props.collectionAndItsLatestFile}
|
||||
uploadFiles={uploadFiles}
|
||||
uploadModalView={props.uploadModalView}
|
||||
closeUploadModal={props.closeUploadModal}
|
||||
suggestedCollectionName={commonPathPrefix}
|
||||
uploadFiles={uploadFilesToExistingCollection}
|
||||
showNextModal={nextModal}
|
||||
collectionSelectorView={props.collectionSelectorView}
|
||||
closeCollectionSelector={props.closeCollectionSelector}
|
||||
loading={props.acceptedFiles.length === 0}
|
||||
/>
|
||||
<CreateCollection
|
||||
createCollectionView={createCollectionView}
|
||||
setCreateCollectionView={setCreateCollectionView}
|
||||
autoFilledName={fileAnalysisResult?.suggestedCollectionName}
|
||||
uploadFiles={uploadFilesToNewCollections}
|
||||
triggerFocus={triggerFocus}
|
||||
/>
|
||||
<ChoiceModal
|
||||
show={choiceModalView}
|
||||
onHide={() => setChoiceModalView(false)}
|
||||
uploadFiles={uploadFilesToNewCollections}
|
||||
showCollectionCreateModal={() => setCreateCollectionView(true)}
|
||||
setTriggerFocus={setTriggerFocus}
|
||||
/>
|
||||
<UploadProgress
|
||||
now={percentComplete}
|
||||
fileCounter={fileCounter}
|
||||
uploadStage={uploadStage}
|
||||
uploadErrors={uploadErrors}
|
||||
fileProgress={fileProgress}
|
||||
show={progressView}
|
||||
closeModal={() => setProgressView(false)}
|
||||
onHide={init}
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
import React from 'react';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
|
||||
function UploadButton({ openFileUploader }) {
|
||||
return (
|
||||
<div onClick={openFileUploader} style={{ marginRight: '12px' }}>
|
||||
<div
|
||||
onClick={openFileUploader}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '30px',
|
||||
top: '20px',
|
||||
zIndex: 100,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
|
@ -1,65 +1,84 @@
|
|||
import React from 'react';
|
||||
import { Alert, Button, Modal, ProgressBar } from 'react-bootstrap';
|
||||
import { UPLOAD_STAGES } from 'services/uploadService';
|
||||
import constants from 'utils/strings/constants';
|
||||
|
||||
export default function UploadProgress({
|
||||
fileCounter,
|
||||
uploadStage,
|
||||
now,
|
||||
uploadErrors,
|
||||
closeModal,
|
||||
...props
|
||||
}) {
|
||||
interface Props {
|
||||
fileCounter;
|
||||
uploadStage;
|
||||
now;
|
||||
uploadErrors;
|
||||
closeModal;
|
||||
fileProgress: Map<string, number>;
|
||||
show;
|
||||
onHide;
|
||||
}
|
||||
export default function UploadProgress(props: Props) {
|
||||
let fileProgressStatuses = [];
|
||||
if (props.fileProgress) {
|
||||
for (let [fileName, progress] of props.fileProgress) {
|
||||
if (progress === 100) {
|
||||
continue;
|
||||
}
|
||||
fileProgressStatuses.push({ fileName, progress });
|
||||
}
|
||||
fileProgressStatuses.sort((a, b) => {
|
||||
if (b.progress !== -1 && a.progress === -1) return 1;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
show={props.show}
|
||||
onHide={props.closeModal}
|
||||
size="lg"
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
backdrop="static"
|
||||
backdrop={
|
||||
props.uploadStage !== UPLOAD_STAGES.FINISH ? 'static' : 'true'
|
||||
}
|
||||
dialogClassName="ente-modal"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Title id="contained-modal-title-vcenter">
|
||||
{constants.UPLOADING_FILES}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{now === 100 ? (
|
||||
<Alert variant="success">{constants.UPLOAD[3]}</Alert>
|
||||
<div style={{ textAlign: 'center', marginBottom: '20px', marginTop: '12px' }}>
|
||||
<h4>
|
||||
{props.uploadStage == UPLOAD_STAGES.UPLOADING
|
||||
? props.fileCounter.total > 1 &&
|
||||
constants.UPLOAD[props.uploadStage](
|
||||
props.fileCounter
|
||||
)
|
||||
: constants.UPLOAD[props.uploadStage]}
|
||||
</h4>
|
||||
</div>
|
||||
{props.now === 100 ? (
|
||||
fileProgressStatuses.length !== 0 && (
|
||||
<Alert variant="warning">
|
||||
{constants.FAILED_UPLOAD_FILE_LIST}
|
||||
</Alert>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="info">
|
||||
{constants.UPLOAD[uploadStage]}{' '}
|
||||
{fileCounter?.total != 0
|
||||
? `${fileCounter?.current} ${constants.OF} ${fileCounter?.total}`
|
||||
: ''}
|
||||
</Alert>
|
||||
<ProgressBar animated now={now} />
|
||||
</>
|
||||
<ProgressBar now={props.now} animated variant={'upload-progress-bar'} />
|
||||
)}
|
||||
{uploadErrors && uploadErrors.length > 0 && (
|
||||
<>
|
||||
<Alert variant="danger">
|
||||
<div
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100px',
|
||||
}}
|
||||
>
|
||||
{uploadErrors.map((error, index) => (
|
||||
<li key={index}>{error.message}</li>
|
||||
))}
|
||||
{fileProgressStatuses && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '10px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '200px',
|
||||
}}
|
||||
>
|
||||
{fileProgressStatuses.map(({ fileName, progress }) =>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{constants.FILE_UPLOAD_PROGRESS(fileName, progress)}
|
||||
</div>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{now === 100 && (
|
||||
<Modal.Footer>
|
||||
{props.now === 100 && (
|
||||
<Modal.Footer style={{ border: 'none' }}>
|
||||
<Button
|
||||
variant="dark"
|
||||
style={{ width: '100%' }}
|
||||
onClick={closeModal}
|
||||
onClick={props.closeModal}
|
||||
>
|
||||
{constants.CLOSE}
|
||||
</Button>
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
|
||||
import { file, syncData, localFiles } from 'services/fileService';
|
||||
import { file, syncData, localFiles, deleteFiles } from 'services/fileService';
|
||||
import PreviewCard from './components/PreviewCard';
|
||||
import { getActualKey, getToken } from 'utils/common/key';
|
||||
import styled from 'styled-components';
|
||||
import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
@ -27,12 +26,21 @@ import { Alert, Button, Jumbotron } from 'react-bootstrap';
|
|||
import billingService from 'services/billingService';
|
||||
import PlanSelector from './components/PlanSelector';
|
||||
import { isSubscribed } from 'utils/billingUtil';
|
||||
import MessageDialog from './components/MessageDialog';
|
||||
|
||||
import Delete from 'components/Delete';
|
||||
import ConfirmDialog, { CONFIRM_ACTION } from 'components/ConfirmDialog';
|
||||
import FullScreenDropZone from 'components/FullScreenDropZone';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
import UploadButton from './components/UploadButton';
|
||||
import { checkConnectivity } from 'utils/common';
|
||||
import { isFirstLogin, setIsFirstLogin } from 'utils/storage';
|
||||
import { logoutUser } from 'services/userService';
|
||||
import { MessageAttributes, MessageDialog } from 'components/MessageDialog';
|
||||
import AlertBanner from './components/AlertBanner';
|
||||
const DATE_CONTAINER_HEIGHT = 45;
|
||||
const IMAGE_CONTAINER_HEIGHT = 200;
|
||||
const NO_OF_PAGES = 2;
|
||||
|
||||
const A_DAY = 24 * 60 * 60 * 1000;
|
||||
enum ITEM_TYPE {
|
||||
TIME = 'TIME',
|
||||
TILE = 'TILE',
|
||||
|
@ -99,32 +107,39 @@ const ListContainer = styled.div<{ columns: number }>`
|
|||
}
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const DateContainer = styled.div`
|
||||
padding-top: 15px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
getRootProps;
|
||||
getInputProps;
|
||||
openFileUploader;
|
||||
acceptedFiles;
|
||||
uploadModalView;
|
||||
closeUploadModal;
|
||||
setNavbarIconView;
|
||||
collectionSelectorView;
|
||||
closeCollectionSelector;
|
||||
showCollectionSelector;
|
||||
err;
|
||||
planModalView;
|
||||
setPlanModalView;
|
||||
bannerMessage;
|
||||
setBannerMessage;
|
||||
}
|
||||
|
||||
const DeleteBtn = styled.button`
|
||||
border: none;
|
||||
background-color: #ff6666;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
export type selectedState = {
|
||||
[k: number]: boolean;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export default function Gallery(props: Props) {
|
||||
const router = useRouter();
|
||||
const [collections, setCollections] = useState<collection[]>([]);
|
||||
|
@ -137,8 +152,14 @@ export default function Gallery(props: Props) {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(0);
|
||||
const fetching: { [k: number]: boolean } = {};
|
||||
const [bannerMessage, setBannerMessage] = useState<string>(null);
|
||||
const [sinceTime, setSinceTime] = useState(0);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(false);
|
||||
const [selected, setSelected] = useState<selectedState>({ count: 0 });
|
||||
const [confirmAction, setConfirmAction] = useState<CONFIRM_ACTION>(null);
|
||||
const [dialogMessage, setDialogMessage] = useState<MessageAttributes>();
|
||||
const [planModalView, setPlanModalView] = useState(false);
|
||||
|
||||
const loadingBar = useRef(null);
|
||||
useEffect(() => {
|
||||
const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
|
||||
|
@ -147,7 +168,8 @@ export default function Gallery(props: Props) {
|
|||
return;
|
||||
}
|
||||
const main = async () => {
|
||||
setIsFirstLoad((await getCollectionUpdationTime()) == 0);
|
||||
setIsFirstLoad(isFirstLogin());
|
||||
setIsFirstLogin(false);
|
||||
const data = await localFiles();
|
||||
const collections = await getLocalCollections();
|
||||
const nonEmptyCollections = getNonEmptyCollections(
|
||||
|
@ -167,30 +189,40 @@ export default function Gallery(props: Props) {
|
|||
setIsFirstLoad(false);
|
||||
};
|
||||
main();
|
||||
props.setNavbarIconView(true);
|
||||
}, []);
|
||||
|
||||
const syncWithRemote = async () => {
|
||||
loadingBar.current?.continuousStart();
|
||||
const collections = await syncCollections();
|
||||
const { data, isUpdated } = await syncData(collections);
|
||||
const nonEmptyCollections = getNonEmptyCollections(collections, data);
|
||||
const collectionAndItsLatestFile = await getCollectionAndItsLatestFile(
|
||||
nonEmptyCollections,
|
||||
data
|
||||
);
|
||||
const favItemIds = await getFavItemIds(data);
|
||||
await billingService.updatePlans();
|
||||
await billingService.syncSubscription();
|
||||
|
||||
setCollections(nonEmptyCollections);
|
||||
if (isUpdated) {
|
||||
setData(data);
|
||||
try {
|
||||
checkConnectivity();
|
||||
loadingBar.current?.continuousStart();
|
||||
const collections = await syncCollections();
|
||||
const { data, isUpdated } = await syncData(collections);
|
||||
await billingService.updatePlans();
|
||||
await billingService.syncSubscription();
|
||||
const nonEmptyCollections = getNonEmptyCollections(
|
||||
collections,
|
||||
data
|
||||
);
|
||||
const collectionAndItsLatestFile = await getCollectionAndItsLatestFile(
|
||||
nonEmptyCollections,
|
||||
data
|
||||
);
|
||||
const favItemIds = await getFavItemIds(data);
|
||||
setCollections(nonEmptyCollections);
|
||||
if (isUpdated) {
|
||||
setData(data);
|
||||
}
|
||||
setCollectionAndItsLatestFile(collectionAndItsLatestFile);
|
||||
setFavItemIds(favItemIds);
|
||||
setSinceTime(new Date().getTime());
|
||||
} catch (e) {
|
||||
setBannerMessage(e.message);
|
||||
if (e.message === constants.SESSION_EXPIRED_MESSAGE) {
|
||||
setConfirmAction(CONFIRM_ACTION.SESSION_EXPIRED);
|
||||
}
|
||||
} finally {
|
||||
loadingBar.current?.complete();
|
||||
}
|
||||
setCollectionAndItsLatestFile(collectionAndItsLatestFile);
|
||||
setFavItemIds(favItemIds);
|
||||
setSinceTime(new Date().getTime());
|
||||
loadingBar.current?.complete();
|
||||
};
|
||||
|
||||
const updateUrl = (index: number) => (url: string) => {
|
||||
|
@ -252,6 +284,14 @@ export default function Gallery(props: Props) {
|
|||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSelect = (id: number) => (checked: boolean) => {
|
||||
setSelected({
|
||||
...selected,
|
||||
[id]: checked,
|
||||
count: checked ? selected.count + 1 : selected.count - 1,
|
||||
});
|
||||
};
|
||||
|
||||
const getThumbnail = (file: file[], index: number) => {
|
||||
return (
|
||||
<PreviewCard
|
||||
|
@ -259,6 +299,10 @@ export default function Gallery(props: Props) {
|
|||
data={file[index]}
|
||||
updateUrl={updateUrl(file[index].dataIndex)}
|
||||
onClick={onThumbnailClick(index)}
|
||||
selectable
|
||||
onSelect={handleSelect(file[index].id)}
|
||||
selected={selected[file[index].id]}
|
||||
selectOnClick={selected.count > 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -342,9 +386,52 @@ export default function Gallery(props: Props) {
|
|||
first.getDate() === second.getDate()
|
||||
);
|
||||
};
|
||||
const confirmCallbacks = new Map<CONFIRM_ACTION, Function>([
|
||||
[
|
||||
CONFIRM_ACTION.DELETE,
|
||||
async function () {
|
||||
await deleteFiles(selected);
|
||||
syncWithRemote();
|
||||
setConfirmAction(null);
|
||||
setSelected({ count: 0 });
|
||||
},
|
||||
],
|
||||
[CONFIRM_ACTION.SESSION_EXPIRED, logoutUser],
|
||||
[CONFIRM_ACTION.LOGOUT, logoutUser],
|
||||
[
|
||||
CONFIRM_ACTION.DOWNLOAD_APP,
|
||||
function () {
|
||||
var win = window.open(constants.APP_DOWNLOAD_URL, '_blank');
|
||||
win.focus();
|
||||
setConfirmAction(null);
|
||||
},
|
||||
],
|
||||
[
|
||||
CONFIRM_ACTION.CANCEL_SUBSCRIPTION,
|
||||
async function () {
|
||||
try {
|
||||
await billingService.cancelSubscription();
|
||||
setDialogMessage({
|
||||
title: constants.SUBSCRIPTION_CANCEL_SUCCESS,
|
||||
close: { variant: 'success' },
|
||||
});
|
||||
} catch (e) {
|
||||
setDialogMessage({
|
||||
title: constants.SUBSCRIPTION_CANCEL_FAILED,
|
||||
close: { variant: 'danger' },
|
||||
});
|
||||
}
|
||||
setConfirmAction(null);
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FullScreenDropZone
|
||||
getRootProps={props.getRootProps}
|
||||
getInputProps={props.getInputProps}
|
||||
showCollectionSelector={props.showCollectionSelector}
|
||||
>
|
||||
<LoadingBar color="#2dc262" ref={loadingBar} />
|
||||
{isFirstLoad && (
|
||||
<div className="text-center">
|
||||
|
@ -353,25 +440,33 @@ export default function Gallery(props: Props) {
|
|||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<MessageDialog
|
||||
bannerMessage={props.bannerMessage}
|
||||
onHide={() => props.setBannerMessage(null)}
|
||||
/>
|
||||
{!isSubscribed() && (
|
||||
<Button
|
||||
id="checkout"
|
||||
variant="success"
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => props.setPlanModalView(true)}
|
||||
onClick={() => setPlanModalView(true)}
|
||||
>
|
||||
{constants.SUBSCRIBE}
|
||||
</Button>
|
||||
)}
|
||||
<PlanSelector
|
||||
modalView={props.planModalView}
|
||||
closeModal={() => props.setPlanModalView(false)}
|
||||
setBannerMessage={props.setBannerMessage}
|
||||
modalView={planModalView}
|
||||
closeModal={() => setPlanModalView(false)}
|
||||
setDialogMessage={setDialogMessage}
|
||||
/>
|
||||
<AlertBanner bannerMessage={bannerMessage} />
|
||||
<ConfirmDialog
|
||||
show={confirmAction !== null}
|
||||
onHide={() => setConfirmAction(null)}
|
||||
callback={confirmCallbacks.get(confirmAction)}
|
||||
action={confirmAction}
|
||||
/>
|
||||
<MessageDialog
|
||||
show={dialogMessage != null}
|
||||
onHide={() => setDialogMessage(null)}
|
||||
attributes={dialogMessage}
|
||||
/>
|
||||
<Collections
|
||||
collections={collections}
|
||||
|
@ -379,20 +474,48 @@ export default function Gallery(props: Props) {
|
|||
selectCollection={selectCollection}
|
||||
/>
|
||||
<Upload
|
||||
uploadModalView={props.uploadModalView}
|
||||
closeUploadModal={props.closeUploadModal}
|
||||
collectionSelectorView={props.collectionSelectorView}
|
||||
closeCollectionSelector={props.closeCollectionSelector}
|
||||
collectionAndItsLatestFile={collectionAndItsLatestFile}
|
||||
refetchData={syncWithRemote}
|
||||
setBannerMessage={props.setBannerMessage}
|
||||
setBannerMessage={setBannerMessage}
|
||||
acceptedFiles={props.acceptedFiles}
|
||||
/>
|
||||
<Sidebar
|
||||
files={data}
|
||||
collections={collections}
|
||||
setConfirmAction={setConfirmAction}
|
||||
somethingWentWrong={() =>
|
||||
setDialogMessage({
|
||||
title: constants.UNKNOWN_ERROR,
|
||||
close: { variant: 'danger' },
|
||||
})
|
||||
}
|
||||
setPlanModalView={setPlanModalView}
|
||||
setBannerMessage={setBannerMessage}
|
||||
/>
|
||||
<UploadButton openFileUploader={props.openFileUploader} />
|
||||
{!isFirstLoad && data.length == 0 ? (
|
||||
<Jumbotron>
|
||||
<Image alt="vault" src="/vault.png" />
|
||||
<Button variant="success" onClick={props.openFileUploader}>
|
||||
<div
|
||||
style={{
|
||||
height: '60%',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline-success"
|
||||
onClick={props.openFileUploader}
|
||||
style={{
|
||||
paddingLeft: '32px',
|
||||
paddingRight: '32px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{constants.UPLOAD_FIRST_PHOTO}
|
||||
</Button>
|
||||
</Jumbotron>
|
||||
</div>
|
||||
) : filteredData.length ? (
|
||||
<Container>
|
||||
<AutoSizer>
|
||||
|
@ -433,9 +556,19 @@ export default function Gallery(props: Props) {
|
|||
);
|
||||
timeStampList.push({
|
||||
itemType: ITEM_TYPE.TIME,
|
||||
date: dateTimeFormat.format(
|
||||
currentDate
|
||||
),
|
||||
date: isSameDay(
|
||||
new Date(currentDate),
|
||||
new Date()
|
||||
)
|
||||
? 'Today'
|
||||
: isSameDay(
|
||||
new Date(currentDate),
|
||||
new Date(Date.now() - A_DAY)
|
||||
)
|
||||
? 'Yesterday'
|
||||
: dateTimeFormat.format(
|
||||
currentDate
|
||||
),
|
||||
});
|
||||
timeStampList.push({
|
||||
itemType: ITEM_TYPE.TILE,
|
||||
|
@ -551,6 +684,13 @@ export default function Gallery(props: Props) {
|
|||
{constants.INSTALL_MOBILE_APP()}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
{selected.count && (
|
||||
<DeleteBtn
|
||||
onClick={() => setConfirmAction(CONFIRM_ACTION.DELETE)}
|
||||
>
|
||||
<Delete />
|
||||
</DeleteBtn>
|
||||
)}
|
||||
</FullScreenDropZone>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,37 +1,30 @@
|
|||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import Container from 'components/Container';
|
||||
import styled from 'styled-components';
|
||||
import Card from 'react-bootstrap/Card';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import { putAttributes } from 'services/userService';
|
||||
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
|
||||
import { logoutUser, putAttributes } from 'services/userService';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getKey, SESSION_KEYS, setKey } from 'utils/storage/sessionStorage';
|
||||
import { getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
|
||||
import { B64EncryptionResult } from 'services/uploadService';
|
||||
import CryptoWorker from 'utils/crypto/cryptoWorker';
|
||||
import CryptoWorker, {
|
||||
setSessionKeys,
|
||||
generateAndSaveIntermediateKeyAttributes,
|
||||
} from 'utils/crypto';
|
||||
import PasswordForm from 'components/PasswordForm';
|
||||
import { KeyAttributes } from 'types';
|
||||
import { setJustSignedUp } from 'utils/storage';
|
||||
|
||||
const Image = styled.img`
|
||||
width: 200px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
interface formValues {
|
||||
passphrase: string;
|
||||
confirm: string;
|
||||
export interface KEK {
|
||||
key: string;
|
||||
opsLimit: number;
|
||||
memLimit: number;
|
||||
}
|
||||
|
||||
export default function Generate() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [token, setToken] = useState<string>();
|
||||
const router = useRouter();
|
||||
const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
|
||||
|
||||
useEffect(() => {
|
||||
const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
|
||||
router.prefetch('/gallery');
|
||||
const user = getData(LS_KEYS.USER);
|
||||
if (!user?.token) {
|
||||
|
@ -43,142 +36,72 @@ export default function Generate() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (
|
||||
values: formValues,
|
||||
{ setFieldError }: FormikHelpers<formValues>
|
||||
) => {
|
||||
setLoading(true);
|
||||
const onSubmit = async (passphrase, setFieldError) => {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
const masterKey: string = await cryptoWorker.generateEncryptionKey();
|
||||
const recoveryKey: string = await cryptoWorker.generateEncryptionKey();
|
||||
const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
|
||||
let kek: KEK;
|
||||
try {
|
||||
const { passphrase, confirm } = values;
|
||||
if (passphrase === confirm) {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
const key: string = await cryptoWorker.generateMasterKey();
|
||||
const kekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
|
||||
const kek: string = await cryptoWorker.deriveKey(
|
||||
passphrase,
|
||||
kekSalt
|
||||
);
|
||||
const kekHash: string = await cryptoWorker.hash(kek);
|
||||
const encryptedKeyAttributes: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
key,
|
||||
kek
|
||||
);
|
||||
const keyPair = await cryptoWorker.generateKeyPair();
|
||||
const encryptedKeyPairAttributes: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
keyPair.privateKey,
|
||||
key
|
||||
);
|
||||
|
||||
const keyAttributes = {
|
||||
kekSalt,
|
||||
kekHash: kekHash,
|
||||
encryptedKey: encryptedKeyAttributes.encryptedData,
|
||||
keyDecryptionNonce: encryptedKeyAttributes.nonce,
|
||||
publicKey: keyPair.publicKey,
|
||||
encryptedSecretKey:
|
||||
encryptedKeyPairAttributes.encryptedData,
|
||||
secretKeyDecryptionNonce: encryptedKeyPairAttributes.nonce,
|
||||
};
|
||||
await putAttributes(
|
||||
token,
|
||||
getData(LS_KEYS.USER).name,
|
||||
keyAttributes
|
||||
);
|
||||
setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
|
||||
|
||||
const sessionKeyAttributes = await cryptoWorker.encryptToB64(
|
||||
key
|
||||
);
|
||||
const sessionKey = sessionKeyAttributes.key;
|
||||
const sessionNonce = sessionKeyAttributes.nonce;
|
||||
const encryptionKey = sessionKeyAttributes.encryptedData;
|
||||
setKey(SESSION_KEYS.ENCRYPTION_KEY, { encryptionKey });
|
||||
setData(LS_KEYS.SESSION, { sessionKey, sessionNonce });
|
||||
router.push('/gallery');
|
||||
} else {
|
||||
setFieldError('confirm', constants.PASSPHRASE_MATCH_ERROR);
|
||||
}
|
||||
kek = await cryptoWorker.deriveSensitiveKey(passphrase, kekSalt);
|
||||
} catch (e) {
|
||||
setFieldError(
|
||||
'passphrase',
|
||||
`${constants.UNKNOWN_ERROR} ${e.message}`
|
||||
);
|
||||
setFieldError('confirm', constants.PASSWORD_GENERATION_FAILED);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
const masterKeyEncryptedWithKek: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
masterKey,
|
||||
kek.key
|
||||
);
|
||||
const masterKeyEncryptedWithRecoveryKey: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
masterKey,
|
||||
recoveryKey
|
||||
);
|
||||
const recoveryKeyEncryptedWithMasterKey: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
recoveryKey,
|
||||
masterKey
|
||||
);
|
||||
|
||||
const keyPair = await cryptoWorker.generateKeyPair();
|
||||
const encryptedKeyPairAttributes: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
keyPair.privateKey,
|
||||
masterKey
|
||||
);
|
||||
|
||||
const keyAttributes: KeyAttributes = {
|
||||
kekSalt,
|
||||
encryptedKey: masterKeyEncryptedWithKek.encryptedData,
|
||||
keyDecryptionNonce: masterKeyEncryptedWithKek.nonce,
|
||||
publicKey: keyPair.publicKey,
|
||||
encryptedSecretKey: encryptedKeyPairAttributes.encryptedData,
|
||||
secretKeyDecryptionNonce: encryptedKeyPairAttributes.nonce,
|
||||
opsLimit: kek.opsLimit,
|
||||
memLimit: kek.memLimit,
|
||||
masterKeyEncryptedWithRecoveryKey:
|
||||
masterKeyEncryptedWithRecoveryKey.encryptedData,
|
||||
masterKeyDecryptionNonce: masterKeyEncryptedWithRecoveryKey.nonce,
|
||||
recoveryKeyEncryptedWithMasterKey:
|
||||
recoveryKeyEncryptedWithMasterKey.encryptedData,
|
||||
recoveryKeyDecryptionNonce: recoveryKeyEncryptedWithMasterKey.nonce,
|
||||
};
|
||||
await putAttributes(token, getData(LS_KEYS.USER).name, keyAttributes);
|
||||
await generateAndSaveIntermediateKeyAttributes(
|
||||
passphrase,
|
||||
keyAttributes,
|
||||
masterKey
|
||||
);
|
||||
|
||||
setSessionKeys(masterKey);
|
||||
setJustSignedUp(true);
|
||||
router.push('/gallery');
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* <Image alt="vault" src="/vault.png" style={{ paddingBottom: '40px' }} /> */}
|
||||
<Card style={{ maxWidth: '540px', padding: '20px' }}>
|
||||
<Card.Body>
|
||||
<div className="text-center" style={{ marginBottom: '40px' }}>
|
||||
<p>{constants.ENTER_ENC_PASSPHRASE}</p>
|
||||
{constants.PASSPHRASE_DISCLAIMER()}
|
||||
</div>
|
||||
<Formik<formValues>
|
||||
initialValues={{ passphrase: '', confirm: '' }}
|
||||
validationSchema={Yup.object().shape({
|
||||
passphrase: Yup.string().required(
|
||||
constants.REQUIRED
|
||||
),
|
||||
confirm: Yup.string().required(constants.REQUIRED),
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
touched,
|
||||
errors,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
}) => (
|
||||
<Form noValidate onSubmit={handleSubmit}>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type="password"
|
||||
placeholder={constants.PASSPHRASE_HINT}
|
||||
value={values.passphrase}
|
||||
onChange={handleChange('passphrase')}
|
||||
onBlur={handleBlur('passphrase')}
|
||||
isInvalid={Boolean(
|
||||
touched.passphrase &&
|
||||
errors.passphrase
|
||||
)}
|
||||
autoFocus={true}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.passphrase}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type="password"
|
||||
placeholder={
|
||||
constants.PASSPHRASE_CONFIRM
|
||||
}
|
||||
value={values.confirm}
|
||||
onChange={handleChange('confirm')}
|
||||
onBlur={handleBlur('confirm')}
|
||||
isInvalid={Boolean(
|
||||
touched.confirm && errors.confirm
|
||||
)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.confirm}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Button type="submit" block disabled={loading} style={{ marginTop: '28px' }}>
|
||||
{constants.SET_PASSPHRASE}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
<>
|
||||
<PasswordForm
|
||||
callback={onSubmit}
|
||||
buttonText={constants.SET_PASSPHRASE}
|
||||
back={logoutUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
70
src/pages/recover/index.tsx
Normal file
70
src/pages/recover/index.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import constants from 'utils/strings/constants';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { useRouter } from 'next/router';
|
||||
import { KeyAttributes } from 'types';
|
||||
import CryptoWorker, { setSessionKeys } from 'utils/crypto';
|
||||
import PassPhraseForm from 'components/PassphraseForm';
|
||||
import { MessageDialog } from 'components/MessageDialog';
|
||||
|
||||
export default function Recover() {
|
||||
const router = useRouter();
|
||||
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
|
||||
const [messageDialogView, SetMessageDialogView] = useState(false);
|
||||
useEffect(() => {
|
||||
router.prefetch('/gallery');
|
||||
const user = getData(LS_KEYS.USER);
|
||||
const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
|
||||
if (!user?.token) {
|
||||
router.push('/');
|
||||
} else if (!keyAttributes) {
|
||||
router.push('/generate');
|
||||
} else {
|
||||
setKeyAttributes(keyAttributes);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const recover = async (recoveryKey: string, setFieldError) => {
|
||||
try {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
let masterKey: string = await cryptoWorker.decryptB64(
|
||||
keyAttributes.masterKeyEncryptedWithRecoveryKey,
|
||||
keyAttributes.masterKeyDecryptionNonce,
|
||||
await cryptoWorker.fromHex(recoveryKey)
|
||||
);
|
||||
setSessionKeys(masterKey);
|
||||
router.push('/changePassword');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setFieldError('passphrase', constants.INCORRECT_RECOVERY_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PassPhraseForm
|
||||
callback={recover}
|
||||
fieldType="text"
|
||||
title={constants.RECOVER_ACCOUNT}
|
||||
placeholder={constants.RETURN_RECOVERY_KEY_HINT}
|
||||
buttonText={constants.RECOVER}
|
||||
alternateOption={{
|
||||
text: constants.NO_RECOVERY_KEY,
|
||||
click: () => SetMessageDialogView(true),
|
||||
}}
|
||||
back={router.back}
|
||||
/>
|
||||
<MessageDialog
|
||||
show={messageDialogView}
|
||||
onHide={() => SetMessageDialogView(false)}
|
||||
attributes={{
|
||||
title: constants.SORRY,
|
||||
close: {},
|
||||
}}
|
||||
>
|
||||
{constants.NO_RECOVERY_KEY_MESSAGE}
|
||||
</MessageDialog>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -14,7 +14,9 @@ import {
|
|||
getOtt,
|
||||
logoutUser,
|
||||
clearFiles,
|
||||
isTokenValid,
|
||||
} from 'services/userService';
|
||||
import { setIsFirstLogin } from 'utils/storage';
|
||||
|
||||
const Image = styled.img`
|
||||
width: 350px;
|
||||
|
@ -33,16 +35,23 @@ export default function Verify() {
|
|||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.prefetch('/credentials');
|
||||
router.prefetch('/generate');
|
||||
const user = getData(LS_KEYS.USER);
|
||||
if (!user?.email) {
|
||||
router.push('/');
|
||||
} else if (user.token) {
|
||||
router.push('/credentials');
|
||||
} else {
|
||||
setEmail(user.email);
|
||||
}
|
||||
const main = async () => {
|
||||
router.prefetch('/credentials');
|
||||
router.prefetch('/generate');
|
||||
const user = getData(LS_KEYS.USER);
|
||||
if (!user?.email) {
|
||||
router.push('/');
|
||||
} else if (user.token) {
|
||||
if (await isTokenValid()) {
|
||||
router.push('/credentials');
|
||||
} else {
|
||||
logoutUser();
|
||||
}
|
||||
} else {
|
||||
setEmail(user.email);
|
||||
}
|
||||
};
|
||||
main();
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (
|
||||
|
@ -62,13 +71,14 @@ export default function Verify() {
|
|||
keyAttributes && setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
|
||||
subscription && setData(LS_KEYS.SUBSCRIPTION, subscription);
|
||||
clearFiles();
|
||||
setIsFirstLogin(true);
|
||||
if (resp.data.keyAttributes?.encryptedKey) {
|
||||
router.push('/credentials');
|
||||
} else {
|
||||
router.push('/generate');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) {
|
||||
if (e?.status === 401) {
|
||||
setFieldError('ott', constants.INVALID_CODE);
|
||||
} else {
|
||||
setFieldError('ott', `${constants.UNKNOWN_ERROR} ${e.message}`);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { clearData } from 'utils/storage/localStorage';
|
||||
|
||||
interface IHTTPHeaders {
|
||||
[headerKey: string]: any;
|
||||
|
@ -12,8 +13,25 @@ interface IQueryPrams {
|
|||
* Service to manage all HTTP calls.
|
||||
*/
|
||||
class HTTPService {
|
||||
constructor() {
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
return Promise.resolve(response);
|
||||
},
|
||||
(err) => {
|
||||
if (!err.response) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
const response = err.response;
|
||||
if (response?.status === 401) {
|
||||
clearData();
|
||||
}
|
||||
return Promise.reject(response);
|
||||
}
|
||||
);
|
||||
}
|
||||
/**
|
||||
* header object to be appened to all api calls.
|
||||
* header object to be append to all api calls.
|
||||
*/
|
||||
private headers: IHTTPHeaders = {
|
||||
'content-type': 'application/json',
|
||||
|
@ -54,16 +72,27 @@ class HTTPService {
|
|||
/**
|
||||
* Generic HTTP request.
|
||||
* This is done so that developer can use any functionality
|
||||
* provided by axios. Here, only the set heards are spread
|
||||
* provided by axios. Here, only the set headers are spread
|
||||
* over what was sent in config.
|
||||
*/
|
||||
public request(config: AxiosRequestConfig, customConfig?: any) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
config.headers = {
|
||||
...this.headers,
|
||||
...config.headers,
|
||||
};
|
||||
return axios({ ...config, ...customConfig });
|
||||
public async request(
|
||||
config: AxiosRequestConfig,
|
||||
customConfig?: any,
|
||||
retryCounter = 2
|
||||
) {
|
||||
try {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
config.headers = {
|
||||
...this.headers,
|
||||
...config.headers,
|
||||
};
|
||||
return await axios({ ...config, ...customConfig });
|
||||
} catch (e) {
|
||||
retryCounter > 0 &&
|
||||
config.method !== 'GET' &&
|
||||
(await this.request(config, customConfig, retryCounter - 1));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,7 +2,7 @@ import { getEndpoint } from 'utils/common/apiUtil';
|
|||
import HTTPService from './HTTPService';
|
||||
const ENDPOINT = getEndpoint();
|
||||
import { getToken } from 'utils/common/key';
|
||||
import { runningInBrowser } from 'utils/common/utilFunctions';
|
||||
import { runningInBrowser } from 'utils/common/';
|
||||
import { setData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { convertBytesToGBs } from 'utils/billingUtil';
|
||||
import { loadStripe, Stripe } from '@stripe/stripe-js';
|
||||
|
|
|
@ -7,7 +7,8 @@ import HTTPService from './HTTPService';
|
|||
import { B64EncryptionResult } from './uploadService';
|
||||
import { getActualKey, getToken } from 'utils/common/key';
|
||||
import { user } from './userService';
|
||||
import CryptoWorker from 'utils/crypto/cryptoWorker';
|
||||
import CryptoWorker from 'utils/crypto';
|
||||
import { ErrorHandler } from 'utils/common/errorUtil';
|
||||
|
||||
const ENDPOINT = getEndpoint();
|
||||
|
||||
|
@ -28,7 +29,7 @@ export interface collection {
|
|||
name?: string;
|
||||
encryptedName?: string;
|
||||
nameDecryptionNonce?: string;
|
||||
type: string;
|
||||
type: CollectionType;
|
||||
attributes: collectionAttributes;
|
||||
sharees: user[];
|
||||
updationTime: number;
|
||||
|
@ -104,7 +105,8 @@ const getCollections = async (
|
|||
);
|
||||
return await Promise.all(promises);
|
||||
} catch (e) {
|
||||
console.error('getCollections failed- ', e.response);
|
||||
console.error('getCollections failed- ', e);
|
||||
ErrorHandler(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -123,9 +125,6 @@ export const syncCollections = async () => {
|
|||
const lastCollectionUpdationTime = await getCollectionUpdationTime();
|
||||
const key = await getActualKey(),
|
||||
token = getToken();
|
||||
if (!token) {
|
||||
return localCollections;
|
||||
}
|
||||
const updatedCollections =
|
||||
(await getCollections(token, lastCollectionUpdationTime, key)) ?? [];
|
||||
if (updatedCollections.length == 0) {
|
||||
|
@ -158,6 +157,7 @@ export const syncCollections = async () => {
|
|||
}
|
||||
}
|
||||
collections.sort((a, b) => b.updationTime - a.updationTime);
|
||||
collections.sort((a, b) => (b.type === CollectionType.favorites ? 1 : 0));
|
||||
await localForage.setItem(COLLECTION_UPDATION_TIME, updationTime);
|
||||
await localForage.setItem(COLLECTIONS, collections);
|
||||
return collections;
|
||||
|
@ -215,7 +215,7 @@ export const AddCollection = async (
|
|||
const worker = await new CryptoWorker();
|
||||
const encryptionKey = await getActualKey();
|
||||
const token = getToken();
|
||||
const collectionKey: string = await worker.generateMasterKey();
|
||||
const collectionKey: string = await worker.generateEncryptionKey();
|
||||
const {
|
||||
encryptedData: encryptedKey,
|
||||
nonce: keyDecryptionNonce,
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import { getToken } from 'utils/common/key';
|
||||
import { file } from './fileService';
|
||||
import HTTPService from './HTTPService';
|
||||
import { getEndpoint, getFileUrl, getThumbnailUrl } from 'utils/common/apiUtil';
|
||||
import { getFileExtension, runningInBrowser } from 'utils/common/utilFunctions';
|
||||
import CryptoWorker from 'utils/crypto/cryptoWorker';
|
||||
import { getFileUrl, getThumbnailUrl } from 'utils/common/apiUtil';
|
||||
import { getFileExtension, runningInBrowser } from 'utils/common';
|
||||
import CryptoWorker from 'utils/crypto';
|
||||
|
||||
const heic2any = runningInBrowser() && require('heic2any');
|
||||
const TYPE_HEIC = 'heic';
|
||||
|
||||
class DownloadManager {
|
||||
|
@ -60,7 +59,10 @@ class DownloadManager {
|
|||
try {
|
||||
if (!this.fileDownloads.get(file.id)) {
|
||||
const download = (async () => {
|
||||
return await this.downloadFile(file);
|
||||
const fileStream = await this.downloadFile(file);
|
||||
return URL.createObjectURL(
|
||||
await new Response(fileStream).blob()
|
||||
);
|
||||
})();
|
||||
this.fileDownloads.set(file.id, download);
|
||||
}
|
||||
|
@ -70,7 +72,7 @@ class DownloadManager {
|
|||
}
|
||||
};
|
||||
|
||||
private async downloadFile(file: file) {
|
||||
async downloadFile(file: file) {
|
||||
const worker = await new CryptoWorker();
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
|
@ -93,7 +95,14 @@ class DownloadManager {
|
|||
if (getFileExtension(file.metadata.title) === TYPE_HEIC) {
|
||||
decryptedBlob = await this.convertHEIC2JPEG(decryptedBlob);
|
||||
}
|
||||
return URL.createObjectURL(new Blob([decryptedBlob]));
|
||||
return new ReadableStream({
|
||||
async start(controller: ReadableStreamDefaultController) {
|
||||
controller.enqueue(
|
||||
new Uint8Array(await decryptedBlob.arrayBuffer())
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const resp = await fetch(getFileUrl(file.id), {
|
||||
headers: {
|
||||
|
@ -165,11 +174,12 @@ class DownloadManager {
|
|||
push();
|
||||
},
|
||||
});
|
||||
return URL.createObjectURL(await new Response(stream).blob());
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
private async convertHEIC2JPEG(fileBlob): Promise<Blob> {
|
||||
const heic2any = runningInBrowser() && require('heic2any');
|
||||
return await heic2any({
|
||||
blob: fileBlob,
|
||||
toType: 'image/jpeg',
|
||||
|
|
93
src/services/exportService.ts
Normal file
93
src/services/exportService.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { runningInBrowser } from 'utils/common';
|
||||
import { collection } from './collectionService';
|
||||
import downloadManager from './downloadManager';
|
||||
import { file } from './fileService';
|
||||
|
||||
enum ExportNotification {
|
||||
START = 'export started',
|
||||
IN_PROGRESS = 'export already in progress',
|
||||
FINISH = 'export finished',
|
||||
ABORT = 'export aborted',
|
||||
}
|
||||
class ExportService {
|
||||
ElectronAPIs: any = runningInBrowser() && window['ElectronAPIs'];
|
||||
exportInProgress: Promise<void> = null;
|
||||
abortExport: boolean = false;
|
||||
|
||||
async exportFiles(files: file[], collections: collection[]) {
|
||||
if (this.exportInProgress) {
|
||||
this.ElectronAPIs.sendNotification(ExportNotification.IN_PROGRESS);
|
||||
return this.exportInProgress;
|
||||
}
|
||||
this.exportInProgress = this.fileExporter(files, collections);
|
||||
return this.exportInProgress;
|
||||
}
|
||||
async fileExporter(files: file[], collections: collection[]) {
|
||||
try {
|
||||
const dir = await this.ElectronAPIs.selectRootDirectory();
|
||||
if (!dir) {
|
||||
// directory selector closed
|
||||
return;
|
||||
}
|
||||
const exportedFiles: Set<string> = await this.ElectronAPIs.getExportedFiles(
|
||||
dir
|
||||
);
|
||||
this.ElectronAPIs.showOnTray(`starting export`);
|
||||
this.ElectronAPIs.registerStopExportListener(
|
||||
() => (this.abortExport = true)
|
||||
);
|
||||
const collectionIDMap = new Map<number, string>();
|
||||
for (let collection of collections) {
|
||||
let collectionFolderPath = `${dir}/${
|
||||
collection.id
|
||||
}_${this.sanitizeName(collection.name)}`;
|
||||
await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
|
||||
collectionFolderPath
|
||||
);
|
||||
collectionIDMap.set(collection.id, collectionFolderPath);
|
||||
}
|
||||
this.ElectronAPIs.sendNotification(ExportNotification.START);
|
||||
for (let [index, file] of files.entries()) {
|
||||
if (this.abortExport) {
|
||||
break;
|
||||
}
|
||||
const uid = `${file.id}_${this.sanitizeName(
|
||||
file.metadata.title
|
||||
)}`;
|
||||
const filePath =
|
||||
collectionIDMap.get(file.collectionID) + '/' + uid;
|
||||
if (!exportedFiles.has(filePath)) {
|
||||
await this.downloadAndSave(file, filePath);
|
||||
this.ElectronAPIs.updateExportRecord(dir, filePath);
|
||||
}
|
||||
this.ElectronAPIs.showOnTray(
|
||||
`exporting file ${index + 1} / ${files.length}`
|
||||
);
|
||||
}
|
||||
this.ElectronAPIs.sendNotification(
|
||||
this.abortExport
|
||||
? ExportNotification.ABORT
|
||||
: ExportNotification.FINISH
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.exportInProgress = null;
|
||||
this.ElectronAPIs.showOnTray();
|
||||
this.abortExport = false;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAndSave(file: file, path) {
|
||||
const fileStream = await downloadManager.downloadFile(file);
|
||||
this.ElectronAPIs.saveStreamToDisk(path, fileStream);
|
||||
this.ElectronAPIs.saveFileToDisk(
|
||||
`${path}.json`,
|
||||
JSON.stringify(file.metadata, null, 2)
|
||||
);
|
||||
}
|
||||
private sanitizeName(name) {
|
||||
return name.replaceAll('/', '_').replaceAll(' ', '_');
|
||||
}
|
||||
}
|
||||
export default new ExportService();
|
|
@ -4,8 +4,10 @@ import localForage from 'utils/storage/localForage';
|
|||
|
||||
import { collection } from './collectionService';
|
||||
import { DataStream, MetadataObject } from './uploadService';
|
||||
import CryptoWorker from 'utils/crypto/cryptoWorker';
|
||||
import CryptoWorker from 'utils/crypto';
|
||||
import { getToken } from 'utils/common/key';
|
||||
import { selectedState } from 'pages/gallery';
|
||||
import { ErrorHandler } from 'utils/common/errorUtil';
|
||||
|
||||
const ENDPOINT = getEndpoint();
|
||||
const DIFF_LIMIT: number = 2500;
|
||||
|
@ -151,6 +153,7 @@ export const getFiles = async (
|
|||
return await Promise.all(promises);
|
||||
} catch (e) {
|
||||
console.error('Get files failed', e);
|
||||
ErrorHandler(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -165,3 +168,28 @@ const removeDeletedCollectionFiles = async (
|
|||
files = files.filter((file) => syncedCollectionIds.has(file.collectionID));
|
||||
return files;
|
||||
};
|
||||
|
||||
export const deleteFiles = async (clickedFiles: selectedState) => {
|
||||
try {
|
||||
let filesToDelete = [];
|
||||
for (let [key, val] of Object.entries(clickedFiles)) {
|
||||
if (typeof val === 'boolean' && val) {
|
||||
filesToDelete.push(Number(key));
|
||||
}
|
||||
}
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
await HTTPService.post(
|
||||
`${ENDPOINT}/files/delete`,
|
||||
{ fileIDs: filesToDelete },
|
||||
null,
|
||||
{
|
||||
'X-Auth-Token': token,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('delete failed');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -4,11 +4,12 @@ import EXIF from 'exif-js';
|
|||
import { fileAttribute } from './fileService';
|
||||
import { collection } from './collectionService';
|
||||
import { FILE_TYPE } from 'pages/gallery';
|
||||
import { checkConnectivity } from 'utils/common/utilFunctions';
|
||||
import { checkConnectivity } from 'utils/common';
|
||||
import { ErrorHandler } from 'utils/common/errorUtil';
|
||||
import CryptoWorker from 'utils/crypto/cryptoWorker';
|
||||
import CryptoWorker from 'utils/crypto';
|
||||
import * as convert from 'xml-js';
|
||||
import { ENCRYPTION_CHUNK_SIZE } from 'utils/crypto/libsodium';
|
||||
import { ENCRYPTION_CHUNK_SIZE } from 'types';
|
||||
import { getToken } from 'utils/common/key';
|
||||
const ENDPOINT = getEndpoint();
|
||||
|
||||
const THUMBNAIL_HEIGHT = 720;
|
||||
|
@ -18,12 +19,27 @@ const MIN_THUMBNAIL_SIZE = 50000;
|
|||
const MAX_CONCURRENT_UPLOADS = 4;
|
||||
const TYPE_IMAGE = 'image';
|
||||
const TYPE_VIDEO = 'video';
|
||||
const TYPE_HEIC = 'HEIC';
|
||||
const TYPE_JSON = 'json';
|
||||
const SOUTH_DIRECTION = 'S';
|
||||
const WEST_DIRECTION = 'W';
|
||||
const MIN_STREAM_FILE_SIZE = 20 * 1024 * 1024;
|
||||
const CHUNKS_COMBINED_FOR_UPLOAD = 2;
|
||||
const CHUNKS_COMBINED_FOR_UPLOAD = 5;
|
||||
const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random();
|
||||
const NULL_LOCATION: Location = { latitude: null, longitude: null };
|
||||
|
||||
interface Location {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
interface ParsedEXIFData {
|
||||
location: Location;
|
||||
creationTime: number;
|
||||
}
|
||||
export interface FileWithCollection {
|
||||
file: File;
|
||||
collection: collection;
|
||||
}
|
||||
export interface DataStream {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
chunkCount: number;
|
||||
|
@ -76,6 +92,7 @@ interface ProcessedFile {
|
|||
file: fileAttribute;
|
||||
thumbnail: fileAttribute;
|
||||
metadata: fileAttribute;
|
||||
filename: string;
|
||||
}
|
||||
interface BackupedFile extends ProcessedFile {}
|
||||
|
||||
|
@ -98,16 +115,15 @@ class UploadService {
|
|||
private perFileProgress: number;
|
||||
private filesCompleted: number;
|
||||
private totalFileCount: number;
|
||||
private fileProgress: Map<string, number>;
|
||||
private metadataMap: Map<string, Object>;
|
||||
private filesToBeUploaded: File[];
|
||||
private filesToBeUploaded: FileWithCollection[];
|
||||
private progressBarProps;
|
||||
private uploadErrors: Error[];
|
||||
private setUploadErrors;
|
||||
|
||||
public async uploadFiles(
|
||||
receivedFiles: File[],
|
||||
collection: collection,
|
||||
token: string,
|
||||
filesWithCollectionToUpload: FileWithCollection[],
|
||||
progressBarProps,
|
||||
setUploadErrors
|
||||
) {
|
||||
|
@ -116,22 +132,29 @@ class UploadService {
|
|||
progressBarProps.setUploadStage(UPLOAD_STAGES.START);
|
||||
|
||||
this.filesCompleted = 0;
|
||||
this.fileProgress = new Map<string, number>();
|
||||
this.uploadErrors = [];
|
||||
this.setUploadErrors = setUploadErrors;
|
||||
this.metadataMap = new Map<string, object>();
|
||||
this.progressBarProps = progressBarProps;
|
||||
|
||||
let metadataFiles: File[] = [];
|
||||
let actualFiles: File[] = [];
|
||||
receivedFiles.forEach((file) => {
|
||||
let actualFiles: FileWithCollection[] = [];
|
||||
filesWithCollectionToUpload.forEach((fileWithCollection) => {
|
||||
let file = fileWithCollection.file;
|
||||
if (file?.name.substr(0, 1) == '.') {
|
||||
//ignore files with name starting with .
|
||||
return;
|
||||
}
|
||||
if (
|
||||
file.type.substr(0, 5) === TYPE_IMAGE ||
|
||||
file.type.substr(0, 5) === TYPE_VIDEO
|
||||
file.type.substr(0, 5) === TYPE_VIDEO ||
|
||||
(file.type.length === 0 && file.name.endsWith(TYPE_HEIC))
|
||||
) {
|
||||
actualFiles.push(file);
|
||||
actualFiles.push(fileWithCollection);
|
||||
}
|
||||
if (file.name.slice(-4) == TYPE_JSON) {
|
||||
metadataFiles.push(file);
|
||||
metadataFiles.push(fileWithCollection.file);
|
||||
}
|
||||
});
|
||||
this.totalFileCount = actualFiles.length;
|
||||
|
@ -148,7 +171,7 @@ class UploadService {
|
|||
progressBarProps.setUploadStage(UPLOAD_STAGES.UPLOADING);
|
||||
this.changeProgressBarProps();
|
||||
try {
|
||||
await this.fetchUploadURLs(token);
|
||||
await this.fetchUploadURLs();
|
||||
} catch (e) {
|
||||
console.error('error fetching uploadURLs', e);
|
||||
ErrorHandler(e);
|
||||
|
@ -163,9 +186,7 @@ class UploadService {
|
|||
this.uploader(
|
||||
await new CryptoWorker(),
|
||||
new FileReader(),
|
||||
this.filesToBeUploaded.pop(),
|
||||
collection,
|
||||
token
|
||||
this.filesToBeUploaded.pop()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -183,10 +204,11 @@ class UploadService {
|
|||
private async uploader(
|
||||
worker: any,
|
||||
reader: FileReader,
|
||||
rawFile: File,
|
||||
collection: collection,
|
||||
token: string
|
||||
fileWithCollection: FileWithCollection
|
||||
) {
|
||||
let { file: rawFile, collection } = fileWithCollection;
|
||||
this.fileProgress.set(rawFile.name, 0);
|
||||
this.changeProgressBarProps();
|
||||
try {
|
||||
let file: FileInMemory = await this.readFile(reader, rawFile);
|
||||
let {
|
||||
|
@ -198,8 +220,7 @@ class UploadService {
|
|||
collection.key
|
||||
);
|
||||
let backupedFile: BackupedFile = await this.uploadToBucket(
|
||||
encryptedFile,
|
||||
token
|
||||
encryptedFile
|
||||
);
|
||||
file = null;
|
||||
encryptedFile = null;
|
||||
|
@ -210,9 +231,10 @@ class UploadService {
|
|||
);
|
||||
encryptedKey = null;
|
||||
backupedFile = null;
|
||||
await this.uploadFile(uploadFile, token);
|
||||
await this.uploadFile(uploadFile);
|
||||
uploadFile = null;
|
||||
this.filesCompleted++;
|
||||
this.fileProgress.set(rawFile.name, 100);
|
||||
this.changeProgressBarProps();
|
||||
} catch (e) {
|
||||
console.error('file upload failed with error', e);
|
||||
|
@ -221,26 +243,32 @@ class UploadService {
|
|||
`Uploading Failed for File - ${rawFile.name}`
|
||||
);
|
||||
this.uploadErrors.push(error);
|
||||
this.fileProgress.set(rawFile.name, -1);
|
||||
}
|
||||
if (this.filesToBeUploaded.length > 0) {
|
||||
await this.uploader(
|
||||
worker,
|
||||
reader,
|
||||
this.filesToBeUploaded.pop(),
|
||||
collection,
|
||||
token
|
||||
);
|
||||
await this.uploader(worker, reader, this.filesToBeUploaded.pop());
|
||||
}
|
||||
}
|
||||
|
||||
private changeProgressBarProps() {
|
||||
const { setPercentComplete, setFileCounter } = this.progressBarProps;
|
||||
const {
|
||||
setPercentComplete,
|
||||
setFileCounter,
|
||||
setFileProgress,
|
||||
} = this.progressBarProps;
|
||||
setFileCounter({
|
||||
current: this.filesCompleted + 1,
|
||||
finished: this.filesCompleted,
|
||||
total: this.totalFileCount,
|
||||
});
|
||||
setPercentComplete(this.filesCompleted * this.perFileProgress);
|
||||
let percentComplete = 0;
|
||||
if (this.fileProgress) {
|
||||
for (let [_, progress] of this.fileProgress) {
|
||||
percentComplete += (this.perFileProgress * progress) / 100;
|
||||
}
|
||||
}
|
||||
setPercentComplete(percentComplete);
|
||||
this.setUploadErrors(this.uploadErrors);
|
||||
setFileProgress(this.fileProgress);
|
||||
}
|
||||
|
||||
private async readFile(reader: FileReader, receivedFile: File) {
|
||||
|
@ -261,6 +289,13 @@ class UploadService {
|
|||
default:
|
||||
fileType = FILE_TYPE.OTHERS;
|
||||
}
|
||||
if (
|
||||
fileType === FILE_TYPE.OTHERS &&
|
||||
receivedFile.type.length === 0 &&
|
||||
receivedFile.name.endsWith(TYPE_HEIC)
|
||||
) {
|
||||
fileType = FILE_TYPE.IMAGE;
|
||||
}
|
||||
|
||||
const { location, creationTime } = await this.getExifData(
|
||||
reader,
|
||||
|
@ -331,6 +366,7 @@ class UploadService {
|
|||
file: encryptedFiledata,
|
||||
thumbnail: encryptedThumbnail,
|
||||
metadata: encryptedMetadata,
|
||||
filename: file.metadata.title,
|
||||
},
|
||||
fileKey: encryptedKey,
|
||||
};
|
||||
|
@ -374,32 +410,35 @@ class UploadService {
|
|||
};
|
||||
}
|
||||
|
||||
private async uploadToBucket(
|
||||
file: ProcessedFile,
|
||||
token: string
|
||||
): Promise<BackupedFile> {
|
||||
private async uploadToBucket(file: ProcessedFile): Promise<BackupedFile> {
|
||||
try {
|
||||
if (isDataStream(file.file.encryptedData)) {
|
||||
const { chunkCount, stream } = file.file.encryptedData;
|
||||
const uploadPartCount = Math.ceil(
|
||||
chunkCount / CHUNKS_COMBINED_FOR_UPLOAD
|
||||
);
|
||||
const filePartUploadURLs = await this.fetchMultipartUploadURLs(
|
||||
token,
|
||||
Math.ceil(chunkCount / CHUNKS_COMBINED_FOR_UPLOAD)
|
||||
uploadPartCount
|
||||
);
|
||||
file.file.objectKey = await this.putFileInParts(
|
||||
filePartUploadURLs,
|
||||
stream
|
||||
stream,
|
||||
file.filename,
|
||||
uploadPartCount
|
||||
);
|
||||
} else {
|
||||
const fileUploadURL = await this.getUploadURL(token);
|
||||
const fileUploadURL = await this.getUploadURL();
|
||||
file.file.objectKey = await this.putFile(
|
||||
fileUploadURL,
|
||||
file.file.encryptedData
|
||||
file.file.encryptedData,
|
||||
file.filename
|
||||
);
|
||||
}
|
||||
const thumbnailUploadURL = await this.getUploadURL(token);
|
||||
const thumbnailUploadURL = await this.getUploadURL();
|
||||
file.thumbnail.objectKey = await this.putFile(
|
||||
thumbnailUploadURL,
|
||||
file.thumbnail.encryptedData as Uint8Array
|
||||
file.thumbnail.encryptedData as Uint8Array,
|
||||
null
|
||||
);
|
||||
delete file.file.encryptedData;
|
||||
delete file.thumbnail.encryptedData;
|
||||
|
@ -425,8 +464,12 @@ class UploadService {
|
|||
return uploadFile;
|
||||
}
|
||||
|
||||
private async uploadFile(uploadFile: uploadFile, token) {
|
||||
private async uploadFile(uploadFile: uploadFile) {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const response = await HTTPService.post(
|
||||
`${ENDPOINT}/files`,
|
||||
uploadFile,
|
||||
|
@ -446,6 +489,8 @@ class UploadService {
|
|||
const metadataJSON: object = await new Promise(
|
||||
(resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onabort = () => reject('file reading was aborted');
|
||||
reader.onerror = () => reject('file reading has failed');
|
||||
reader.onload = () => {
|
||||
let result =
|
||||
typeof reader.result !== 'string'
|
||||
|
@ -458,20 +503,34 @@ class UploadService {
|
|||
);
|
||||
|
||||
const metaDataObject = {};
|
||||
metaDataObject['creationTime'] =
|
||||
metadataJSON['photoTakenTime']['timestamp'] * 1000000;
|
||||
metaDataObject['modificationTime'] =
|
||||
metadataJSON['modificationTime']['timestamp'] * 1000000;
|
||||
|
||||
if (!metadataJSON) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
metadataJSON['photoTakenTime'] &&
|
||||
metadataJSON['photoTakenTime']['timestamp']
|
||||
) {
|
||||
metaDataObject['creationTime'] =
|
||||
metadataJSON['photoTakenTime']['timestamp'] * 1000000;
|
||||
}
|
||||
if (
|
||||
metadataJSON['modificationTime'] &&
|
||||
metadataJSON['modificationTime']['timestamp']
|
||||
) {
|
||||
metaDataObject['modificationTime'] =
|
||||
metadataJSON['modificationTime']['timestamp'] * 1000000;
|
||||
}
|
||||
let locationData = null;
|
||||
if (
|
||||
metadataJSON['geoData']['latitude'] != 0.0 ||
|
||||
metadataJSON['geoData']['longitude'] != 0.0
|
||||
metadataJSON['geoData'] &&
|
||||
(metadataJSON['geoData']['latitude'] != 0.0 ||
|
||||
metadataJSON['geoData']['longitude'] != 0.0)
|
||||
) {
|
||||
locationData = metadataJSON['geoData'];
|
||||
} else if (
|
||||
metadataJSON['geoDataExif']['latitude'] != 0.0 ||
|
||||
metadataJSON['geoDataExif']['longitude'] != 0.0
|
||||
metadataJSON['geoDataExif'] &&
|
||||
(metadataJSON['geoDataExif']['latitude'] != 0.0 ||
|
||||
metadataJSON['geoDataExif']['longitude'] != 0.0)
|
||||
) {
|
||||
locationData = metadataJSON['geoDataExif'];
|
||||
}
|
||||
|
@ -481,6 +540,7 @@ class UploadService {
|
|||
}
|
||||
this.metadataMap.set(metadataJSON['title'], metaDataObject);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
@ -492,7 +552,10 @@ class UploadService {
|
|||
let canvas = document.createElement('canvas');
|
||||
let canvas_CTX = canvas.getContext('2d');
|
||||
let imageURL = null;
|
||||
if (file.type.match(TYPE_IMAGE)) {
|
||||
if (
|
||||
file.type.match(TYPE_IMAGE) ||
|
||||
(file.type.length == 0 && file.name.endsWith(TYPE_HEIC))
|
||||
) {
|
||||
let image = new Image();
|
||||
imageURL = URL.createObjectURL(file);
|
||||
image.setAttribute('src', imageURL);
|
||||
|
@ -517,7 +580,7 @@ class UploadService {
|
|||
await new Promise(async (resolve) => {
|
||||
let video = document.createElement('video');
|
||||
imageURL = URL.createObjectURL(file);
|
||||
video.addEventListener('loadeddata', function () {
|
||||
video.addEventListener('timeupdate', function () {
|
||||
const thumbnailWidth =
|
||||
(video.videoWidth * THUMBNAIL_HEIGHT) /
|
||||
video.videoHeight;
|
||||
|
@ -535,19 +598,14 @@ class UploadService {
|
|||
});
|
||||
video.preload = 'metadata';
|
||||
video.src = imageURL;
|
||||
// Load video in Safari / IE11
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.play();
|
||||
video.currentTime = 3;
|
||||
setTimeout(() => resolve(null), 4000);
|
||||
});
|
||||
}
|
||||
URL.revokeObjectURL(imageURL);
|
||||
if (canvas.toDataURL().length == 0) {
|
||||
throw new Error('');
|
||||
}
|
||||
let thumbnailBlob: Blob = file,
|
||||
attempts = 0;
|
||||
let quality = 1;
|
||||
let thumbnailBlob = null,
|
||||
attempts = 0,
|
||||
quality = 1;
|
||||
|
||||
do {
|
||||
attempts++;
|
||||
|
@ -561,9 +619,7 @@ class UploadService {
|
|||
quality
|
||||
);
|
||||
});
|
||||
if (!thumbnailBlob) {
|
||||
thumbnailBlob = file;
|
||||
}
|
||||
thumbnailBlob = thumbnailBlob ?? new Blob([]);
|
||||
} while (
|
||||
thumbnailBlob.size > MIN_THUMBNAIL_SIZE &&
|
||||
attempts <= MAX_ATTEMPTS
|
||||
|
@ -633,16 +689,20 @@ class UploadService {
|
|||
}
|
||||
}
|
||||
|
||||
private async getUploadURL(token: string) {
|
||||
private async getUploadURL() {
|
||||
if (this.uploadURLs.length == 0) {
|
||||
await this.fetchUploadURLs(token);
|
||||
await this.fetchUploadURLs();
|
||||
}
|
||||
return this.uploadURLs.pop();
|
||||
}
|
||||
|
||||
private async fetchUploadURLs(token: string): Promise<void> {
|
||||
private async fetchUploadURLs(): Promise<void> {
|
||||
try {
|
||||
if (!this.uploadURLFetchInProgress) {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
this.uploadURLFetchInProgress = HTTPService.get(
|
||||
`${ENDPOINT}/files/upload-urls`,
|
||||
{
|
||||
|
@ -666,10 +726,13 @@ class UploadService {
|
|||
}
|
||||
|
||||
private async fetchMultipartUploadURLs(
|
||||
token: string,
|
||||
count: number
|
||||
): Promise<MultipartUploadURLs> {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const response = await HTTPService.get(
|
||||
`${ENDPOINT}/files/multipart-upload-urls`,
|
||||
{
|
||||
|
@ -687,10 +750,17 @@ class UploadService {
|
|||
|
||||
private async putFile(
|
||||
fileUploadURL: UploadURL,
|
||||
file: Uint8Array
|
||||
file: Uint8Array,
|
||||
filename: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await HTTPService.put(fileUploadURL.url, file);
|
||||
await HTTPService.put(
|
||||
fileUploadURL.url,
|
||||
file,
|
||||
null,
|
||||
null,
|
||||
this.trackUploadProgress(filename)
|
||||
);
|
||||
return fileUploadURL.objectKey;
|
||||
} catch (e) {
|
||||
console.error('putFile to dataStore failed ', e);
|
||||
|
@ -700,34 +770,40 @@ class UploadService {
|
|||
|
||||
private async putFileInParts(
|
||||
multipartUploadURLs: MultipartUploadURLs,
|
||||
file: ReadableStream<Uint8Array>
|
||||
file: ReadableStream<Uint8Array>,
|
||||
filename: string,
|
||||
uploadPartCount: number
|
||||
) {
|
||||
let streamEncryptedFileReader = file.getReader();
|
||||
let percentPerPart = Math.round(
|
||||
RANDOM_PERCENTAGE_PROGRESS_FOR_PUT() / uploadPartCount
|
||||
);
|
||||
const resParts = [];
|
||||
for (const [
|
||||
index,
|
||||
fileUploadURL,
|
||||
] of multipartUploadURLs.partURLs.entries()) {
|
||||
let {
|
||||
done: done1,
|
||||
value: chunk1,
|
||||
} = await streamEncryptedFileReader.read();
|
||||
if (done1) {
|
||||
break;
|
||||
let combinedChunks = [];
|
||||
for (let i = 0; i < CHUNKS_COMBINED_FOR_UPLOAD; i++) {
|
||||
let {
|
||||
done,
|
||||
value: chunk,
|
||||
} = await streamEncryptedFileReader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
for (let index = 0; index < chunk.length; index++) {
|
||||
combinedChunks.push(chunk[index]);
|
||||
}
|
||||
}
|
||||
let {
|
||||
done: done2,
|
||||
value: chunk2,
|
||||
} = await streamEncryptedFileReader.read();
|
||||
let uploadChunk: Uint8Array;
|
||||
if (!done2) {
|
||||
uploadChunk = new Uint8Array(chunk1.length + chunk2.length);
|
||||
uploadChunk.set(chunk1);
|
||||
uploadChunk.set(chunk2, chunk1.length);
|
||||
} else {
|
||||
uploadChunk = chunk1;
|
||||
}
|
||||
const response = await HTTPService.put(fileUploadURL, uploadChunk);
|
||||
let uploadChunk = Uint8Array.from(combinedChunks);
|
||||
const response = await HTTPService.put(
|
||||
fileUploadURL,
|
||||
uploadChunk,
|
||||
null,
|
||||
null,
|
||||
this.trackUploadProgress(filename, percentPerPart, index)
|
||||
);
|
||||
resParts.push({
|
||||
PartNumber: index + 1,
|
||||
ETag: response.headers.etag,
|
||||
|
@ -744,15 +820,34 @@ class UploadService {
|
|||
return multipartUploadURLs.objectKey;
|
||||
}
|
||||
|
||||
private trackUploadProgress(
|
||||
filename,
|
||||
percentPerPart = RANDOM_PERCENTAGE_PROGRESS_FOR_PUT(),
|
||||
index = 0
|
||||
) {
|
||||
return {
|
||||
onUploadProgress: (event) => {
|
||||
filename &&
|
||||
this.fileProgress.set(
|
||||
filename,
|
||||
Math.round(
|
||||
percentPerPart * index +
|
||||
(percentPerPart * event.loaded) / event.total
|
||||
)
|
||||
);
|
||||
this.changeProgressBarProps();
|
||||
},
|
||||
};
|
||||
}
|
||||
private async getExifData(
|
||||
reader: FileReader,
|
||||
receivedFile: File,
|
||||
fileType: FILE_TYPE
|
||||
) {
|
||||
): Promise<ParsedEXIFData> {
|
||||
try {
|
||||
if (fileType === FILE_TYPE.VIDEO) {
|
||||
// Todo extract exif data from videos
|
||||
return { location: null, creationTime: null };
|
||||
return { location: NULL_LOCATION, creationTime: null };
|
||||
}
|
||||
const exifData: any = await new Promise((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
|
@ -761,10 +856,10 @@ class UploadService {
|
|||
reader.readAsArrayBuffer(receivedFile);
|
||||
});
|
||||
if (!exifData) {
|
||||
return { location: null, creationTime: null };
|
||||
return { location: NULL_LOCATION, creationTime: null };
|
||||
}
|
||||
return {
|
||||
location: this.getLocation(exifData),
|
||||
location: this.getEXIFLocation(exifData),
|
||||
creationTime: this.getUNIXTime(exifData),
|
||||
};
|
||||
} catch (e) {
|
||||
|
@ -786,9 +881,9 @@ class UploadService {
|
|||
return date.getTime() * 1000;
|
||||
}
|
||||
|
||||
private getLocation(exifData) {
|
||||
private getEXIFLocation(exifData): Location {
|
||||
if (!exifData.GPSLatitude) {
|
||||
return null;
|
||||
return NULL_LOCATION;
|
||||
}
|
||||
|
||||
let latDegree: number, latMinute: number, latSecond: number;
|
||||
|
|
|
@ -1,11 +1,26 @@
|
|||
import HTTPService from './HTTPService';
|
||||
import { keyAttributes } from 'types';
|
||||
import { KeyAttributes } from 'types';
|
||||
import { getEndpoint } from 'utils/common/apiUtil';
|
||||
import { clearKeys } from 'utils/storage/sessionStorage';
|
||||
import router from 'next/router';
|
||||
import { clearData } from 'utils/storage/localStorage';
|
||||
import localForage from 'utils/storage/localForage';
|
||||
import { getToken } from 'utils/common/key';
|
||||
|
||||
export interface UpdatedKey {
|
||||
kekSalt: string;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
memLimit: number;
|
||||
opsLimit: number;
|
||||
}
|
||||
|
||||
export interface RecoveryKey {
|
||||
masterKeyEncryptedWithRecoveryKey: string;
|
||||
masterKeyDecryptionNonce: string;
|
||||
recoveryKeyEncryptedWithMasterKey: string;
|
||||
recoveryKeyDecryptionNonce: string;
|
||||
}
|
||||
const ENDPOINT = getEndpoint();
|
||||
|
||||
export interface user {
|
||||
|
@ -28,12 +43,28 @@ export const verifyOtt = (email: string, ott: string) => {
|
|||
export const putAttributes = (
|
||||
token: string,
|
||||
name: string,
|
||||
keyAttributes: keyAttributes
|
||||
keyAttributes: KeyAttributes
|
||||
) => {
|
||||
console.log('name ' + name);
|
||||
return HTTPService.put(
|
||||
`${ENDPOINT}/users/attributes`,
|
||||
{ name: name, keyAttributes: keyAttributes },
|
||||
{ name: name ? name : '', keyAttributes: keyAttributes },
|
||||
null,
|
||||
{
|
||||
'X-Auth-Token': token,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const setKeys = (token: string, updatedKey: UpdatedKey) => {
|
||||
return HTTPService.put(`${ENDPOINT}/users/keys`, updatedKey, null, {
|
||||
'X-Auth-Token': token,
|
||||
});
|
||||
};
|
||||
|
||||
export const SetRecoveryKey = (token: string, recoveryKey: RecoveryKey) => {
|
||||
return HTTPService.put(
|
||||
`${ENDPOINT}/users/recovery-key`,
|
||||
recoveryKey,
|
||||
null,
|
||||
{
|
||||
'X-Auth-Token': token,
|
||||
|
@ -52,3 +83,14 @@ export const logoutUser = async () => {
|
|||
export const clearFiles = async () => {
|
||||
await localForage.clear();
|
||||
};
|
||||
|
||||
export const isTokenValid = async () => {
|
||||
try {
|
||||
await HTTPService.get(`${ENDPOINT}/users/session-validity`, null, {
|
||||
'X-Auth-Token': getToken(),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
14
src/types.ts
14
src/types.ts
|
@ -1,6 +1,16 @@
|
|||
export interface keyAttributes {
|
||||
export interface KeyAttributes {
|
||||
kekSalt: string;
|
||||
kekHash: string;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
opsLimit: number;
|
||||
memLimit: number;
|
||||
publicKey: string;
|
||||
encryptedSecretKey: string;
|
||||
secretKeyDecryptionNonce: string;
|
||||
masterKeyEncryptedWithRecoveryKey: string;
|
||||
masterKeyDecryptionNonce: string;
|
||||
recoveryKeyEncryptedWithMasterKey: string;
|
||||
recoveryKeyDecryptionNonce: string;
|
||||
}
|
||||
|
||||
export const ENCRYPTION_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
|
|
|
@ -4,24 +4,15 @@ export const errorCodes = {
|
|||
ERR_STORAGE_LIMIT_EXCEEDED: '426',
|
||||
ERR_NO_ACTIVE_SUBSCRIPTION: '402',
|
||||
ERR_NO_INTERNET_CONNECTION: '1',
|
||||
ERR_SESSION_EXPIRED: '401',
|
||||
};
|
||||
|
||||
export function ErrorHandler(error) {
|
||||
if (
|
||||
error.response?.status.toString() ==
|
||||
errorCodes.ERR_STORAGE_LIMIT_EXCEEDED ||
|
||||
error.response?.status.toString() ==
|
||||
errorCodes.ERR_NO_ACTIVE_SUBSCRIPTION
|
||||
) {
|
||||
throw new Error(error.response.status);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const AXIOS_NETWORK_ERROR = 'Network Error';
|
||||
|
||||
export function ErrorBannerMessage(bannerErrorCode) {
|
||||
let errorMessage;
|
||||
switch (bannerErrorCode) {
|
||||
export function ErrorHandler(error) {
|
||||
const errorCode = error.status?.toString();
|
||||
let errorMessage = null;
|
||||
switch (errorCode) {
|
||||
case errorCodes.ERR_NO_ACTIVE_SUBSCRIPTION:
|
||||
errorMessage = constants.SUBSCRIPTION_EXPIRED;
|
||||
break;
|
||||
|
@ -31,8 +22,14 @@ export function ErrorBannerMessage(bannerErrorCode) {
|
|||
case errorCodes.ERR_NO_INTERNET_CONNECTION:
|
||||
errorMessage = constants.NO_INTERNET_CONNECTION;
|
||||
break;
|
||||
default:
|
||||
errorMessage = `Unknown Error Code - ${bannerErrorCode} Encountered`;
|
||||
case errorCodes.ERR_SESSION_EXPIRED:
|
||||
errorMessage = constants.SESSION_EXPIRED_MESSAGE;
|
||||
break;
|
||||
}
|
||||
if (error.message === AXIOS_NETWORK_ERROR) {
|
||||
errorMessage = constants.SYNC_FAILED;
|
||||
}
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return errorMessage;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
|
||||
import { errorCodes } from './errorUtil';
|
||||
|
||||
export function checkConnectivity() {
|
||||
|
@ -15,3 +16,19 @@ export function getFileExtension(fileName): string {
|
|||
export function runningInBrowser() {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
export function downloadAsFile(filename: string, content: string) {
|
||||
const file = new Blob([content], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = filename;
|
||||
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
|
||||
a.click();
|
||||
|
||||
a.remove();
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import CryptoWorker from 'utils/crypto/cryptoWorker';
|
||||
import CryptoWorker from 'utils/crypto';
|
||||
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||
import { getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
|
||||
|
||||
|
|
27
src/utils/common/useLongPress.ts
Normal file
27
src/utils/common/useLongPress.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
// https://stackoverflow.com/a/54749871/2760968
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function useLongPress(callback: () => void, ms = 300) {
|
||||
const [startLongPress, setStartLongPress] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timerId: NodeJS.Timeout;
|
||||
if (startLongPress) {
|
||||
timerId = setTimeout(callback, ms);
|
||||
} else {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, [callback, ms, startLongPress]);
|
||||
|
||||
return {
|
||||
onMouseDown: () => setStartLongPress(true),
|
||||
onMouseUp: () => setStartLongPress(false),
|
||||
onMouseLeave: () => setStartLongPress(false),
|
||||
onTouchStart: () => setStartLongPress(true),
|
||||
onTouchEnd: () => setStartLongPress(false),
|
||||
};
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import * as Comlink from 'comlink';
|
||||
import { runningInBrowser } from 'utils/common/utilFunctions';
|
||||
|
||||
const CryptoWorker: any =
|
||||
runningInBrowser() &&
|
||||
Comlink.wrap(new Worker('worker/crypto.worker.js', { type: 'module' }));
|
||||
|
||||
export default CryptoWorker;
|
110
src/utils/crypto/index.ts
Normal file
110
src/utils/crypto/index.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { KEK } from 'pages/generate';
|
||||
import { B64EncryptionResult } from 'services/uploadService';
|
||||
import { KeyAttributes } from 'types';
|
||||
import * as Comlink from 'comlink';
|
||||
import { runningInBrowser } from 'utils/common';
|
||||
import { SESSION_KEYS, setKey } from 'utils/storage/sessionStorage';
|
||||
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
|
||||
import { getActualKey, getToken } from 'utils/common/key';
|
||||
import { SetRecoveryKey } from 'services/userService';
|
||||
|
||||
const CryptoWorker: any =
|
||||
runningInBrowser() &&
|
||||
Comlink.wrap(new Worker('worker/crypto.worker.js', { type: 'module' }));
|
||||
|
||||
export async function generateAndSaveIntermediateKeyAttributes(
|
||||
passphrase,
|
||||
existingKeyAttributes,
|
||||
key
|
||||
) {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
const intermediateKekSalt: string = await cryptoWorker.generateSaltToDeriveKey();
|
||||
const intermediateKek: KEK = await cryptoWorker.deriveIntermediateKey(
|
||||
passphrase,
|
||||
intermediateKekSalt
|
||||
);
|
||||
const encryptedKeyAttributes: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
key,
|
||||
intermediateKek.key
|
||||
);
|
||||
|
||||
const updatedKeyAttributes = Object.assign(existingKeyAttributes, {
|
||||
kekSalt: intermediateKekSalt,
|
||||
encryptedKey: encryptedKeyAttributes.encryptedData,
|
||||
keyDecryptionNonce: encryptedKeyAttributes.nonce,
|
||||
opsLimit: intermediateKek.opsLimit,
|
||||
memLimit: intermediateKek.memLimit,
|
||||
});
|
||||
setData(LS_KEYS.KEY_ATTRIBUTES, updatedKeyAttributes);
|
||||
}
|
||||
|
||||
export const setSessionKeys = async (key: string) => {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
const sessionKeyAttributes = await cryptoWorker.encryptToB64(key);
|
||||
const sessionKey = sessionKeyAttributes.key;
|
||||
const sessionNonce = sessionKeyAttributes.nonce;
|
||||
const encryptionKey = sessionKeyAttributes.encryptedData;
|
||||
setKey(SESSION_KEYS.ENCRYPTION_KEY, { encryptionKey });
|
||||
setData(LS_KEYS.SESSION, { sessionKey, sessionNonce });
|
||||
};
|
||||
|
||||
export const getRecoveryKey = async () => {
|
||||
let recoveryKey = null;
|
||||
try {
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
|
||||
const keyAttributes: KeyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
|
||||
const {
|
||||
recoveryKeyEncryptedWithMasterKey,
|
||||
recoveryKeyDecryptionNonce,
|
||||
} = keyAttributes;
|
||||
const masterKey = await getActualKey();
|
||||
if (recoveryKeyEncryptedWithMasterKey) {
|
||||
recoveryKey = await cryptoWorker.decryptB64(
|
||||
recoveryKeyEncryptedWithMasterKey,
|
||||
recoveryKeyDecryptionNonce,
|
||||
masterKey
|
||||
);
|
||||
} else {
|
||||
recoveryKey = await createNewRecoveryKey();
|
||||
}
|
||||
recoveryKey = await cryptoWorker.toHex(recoveryKey);
|
||||
} catch (e) {
|
||||
console.error('getRecoveryKey failed', e);
|
||||
} finally {
|
||||
return recoveryKey;
|
||||
}
|
||||
};
|
||||
|
||||
async function createNewRecoveryKey() {
|
||||
const masterKey = await getActualKey();
|
||||
const existingAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
|
||||
|
||||
const cryptoWorker = await new CryptoWorker();
|
||||
|
||||
const recoveryKey = await cryptoWorker.generateEncryptionKey();
|
||||
const encryptedMasterKey: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
masterKey,
|
||||
recoveryKey
|
||||
);
|
||||
const encryptedRecoveryKey: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
recoveryKey,
|
||||
masterKey
|
||||
);
|
||||
const recoveryKeyAttributes = {
|
||||
masterKeyEncryptedWithRecoveryKey: encryptedMasterKey.encryptedData,
|
||||
masterKeyDecryptionNonce: encryptedMasterKey.nonce,
|
||||
recoveryKeyEncryptedWithMasterKey: encryptedRecoveryKey.encryptedData,
|
||||
recoveryKeyDecryptionNonce: encryptedRecoveryKey.nonce,
|
||||
};
|
||||
await SetRecoveryKey(getToken(), recoveryKeyAttributes);
|
||||
|
||||
const updatedKeyAttributes = Object.assign(
|
||||
existingAttributes,
|
||||
recoveryKeyAttributes
|
||||
);
|
||||
setData(LS_KEYS.KEY_ATTRIBUTES, updatedKeyAttributes);
|
||||
|
||||
return recoveryKey;
|
||||
}
|
||||
export default CryptoWorker;
|
|
@ -1,6 +1,5 @@
|
|||
import sodium, { StateAddress } from 'libsodium-wrappers';
|
||||
|
||||
export const ENCRYPTION_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
import { ENCRYPTION_CHUNK_SIZE } from 'types';
|
||||
|
||||
export async function decryptChaChaOneShot(
|
||||
data: Uint8Array,
|
||||
|
@ -255,9 +254,49 @@ export async function hash(input: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function deriveKey(passphrase: string, salt: string) {
|
||||
export async function deriveKey(
|
||||
passphrase: string,
|
||||
salt: string,
|
||||
opsLimit: number,
|
||||
memLimit: number
|
||||
) {
|
||||
await sodium.ready;
|
||||
return await toB64(
|
||||
sodium.crypto_pwhash(
|
||||
sodium.crypto_secretbox_KEYBYTES,
|
||||
await fromString(passphrase),
|
||||
await fromB64(salt),
|
||||
opsLimit,
|
||||
memLimit,
|
||||
sodium.crypto_pwhash_ALG_DEFAULT
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function deriveSensitiveKey(passphrase: string, salt: string) {
|
||||
await sodium.ready;
|
||||
const minMemLimit = sodium.crypto_pwhash_MEMLIMIT_MIN;
|
||||
let opsLimit = sodium.crypto_pwhash_OPSLIMIT_SENSITIVE;
|
||||
let memLimit = sodium.crypto_pwhash_MEMLIMIT_SENSITIVE;
|
||||
while (memLimit > minMemLimit) {
|
||||
try {
|
||||
const key = await deriveKey(passphrase, salt, opsLimit, memLimit);
|
||||
return {
|
||||
key,
|
||||
opsLimit,
|
||||
memLimit,
|
||||
};
|
||||
} catch (e) {
|
||||
opsLimit = opsLimit * 2;
|
||||
memLimit = memLimit / 2;
|
||||
}
|
||||
}
|
||||
throw null;
|
||||
}
|
||||
|
||||
export async function deriveIntermediateKey(passphrase: string, salt: string) {
|
||||
await sodium.ready;
|
||||
const key = await toB64(
|
||||
sodium.crypto_pwhash(
|
||||
sodium.crypto_secretbox_KEYBYTES,
|
||||
await fromString(passphrase),
|
||||
|
@ -267,9 +306,14 @@ export async function deriveKey(passphrase: string, salt: string) {
|
|||
sodium.crypto_pwhash_ALG_DEFAULT
|
||||
)
|
||||
);
|
||||
return {
|
||||
key: key,
|
||||
opsLimit: sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
memLimit: sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMasterKey() {
|
||||
export async function generateEncryptionKey() {
|
||||
await sodium.ready;
|
||||
return await toB64(sodium.crypto_kdf_keygen());
|
||||
}
|
||||
|
@ -317,3 +361,12 @@ export async function fromString(input: string) {
|
|||
await sodium.ready;
|
||||
return sodium.from_string(input);
|
||||
}
|
||||
export async function toHex(input: string) {
|
||||
await sodium.ready;
|
||||
return sodium.to_hex(await fromB64(input));
|
||||
}
|
||||
|
||||
export async function fromHex(input: string) {
|
||||
await sodium.ready;
|
||||
return await toB64(sodium.from_hex(input));
|
||||
}
|
||||
|
|
15
src/utils/storage/index.ts
Normal file
15
src/utils/storage/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { getData, LS_KEYS, setData } from './localStorage';
|
||||
|
||||
export const isFirstLogin = () =>
|
||||
getData(LS_KEYS.IS_FIRST_LOGIN)?.status ?? false;
|
||||
|
||||
export function setIsFirstLogin(status) {
|
||||
setData(LS_KEYS.IS_FIRST_LOGIN, { status });
|
||||
}
|
||||
|
||||
export const justSignedUp = () =>
|
||||
getData(LS_KEYS.JUST_SIGNED_UP)?.status ?? false;
|
||||
|
||||
export function setJustSignedUp(status) {
|
||||
setData(LS_KEYS.JUST_SIGNED_UP, { status });
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { runningInBrowser } from 'utils/common/utilFunctions';
|
||||
import { runningInBrowser } from 'utils/common';
|
||||
const localForage: LocalForage = runningInBrowser() && require('localforage');
|
||||
|
||||
if (runningInBrowser()) {
|
||||
|
|
|
@ -4,6 +4,8 @@ export enum LS_KEYS {
|
|||
KEY_ATTRIBUTES = 'keyAttributes',
|
||||
SUBSCRIPTION = 'subscription',
|
||||
PLANS = 'plans',
|
||||
IS_FIRST_LOGIN = 'isFirstLogin',
|
||||
JUST_SIGNED_UP = 'justSignedUp',
|
||||
}
|
||||
|
||||
export const setData = (key: LS_KEYS, value: object) => {
|
||||
|
|
|
@ -55,12 +55,21 @@ const englishConstants = {
|
|||
UPLOAD: {
|
||||
0: 'preparing to upload',
|
||||
1: 'reading google metadata files',
|
||||
2: 'uploading your files',
|
||||
3: 'files uploaded successfully!',
|
||||
2: (fileCounter) =>
|
||||
`${fileCounter.finished} / ${fileCounter.total} files backed up`,
|
||||
3: 'backup complete!',
|
||||
},
|
||||
UPLOADING_FILES: `uploading files`,
|
||||
OF: 'of',
|
||||
UPLOADING_FILES: `file upload`,
|
||||
FAILED_UPLOAD_FILE_LIST: 'upload failed for following files',
|
||||
FILE_UPLOAD_PROGRESS: (name, progress) => (
|
||||
<div id={name}>
|
||||
<strong>{name}</strong>
|
||||
{` - `}
|
||||
{progress !== -1 ? progress + '%' : 'failed'}
|
||||
</div>
|
||||
),
|
||||
SUBSCRIPTION_EXPIRED: 'your subscription has expired, please renew it',
|
||||
|
||||
STORAGE_QUOTA_EXCEEDED:
|
||||
'you have exceeded your storage quota, please upgrade your plan from the mobile app',
|
||||
INITIAL_LOAD_DELAY_WARNING: 'the first load may take some time',
|
||||
|
@ -98,14 +107,19 @@ const englishConstants = {
|
|||
</div>
|
||||
),
|
||||
LOGOUT: 'logout',
|
||||
LOGOUT_WARNING: 'sure you want to logout?',
|
||||
CANCEL_SUBSCRIPTION_WARNING: 'sure you want to cancel your subscription?',
|
||||
CANCEL_SUBSCRIPTION_MESSAGE: 'sure you want to cancel your subscription?',
|
||||
CANCEL_SUBSCRIPTION: 'cancel subscription',
|
||||
CANCEL: 'cancel',
|
||||
SUBSCRIBE: 'subscribe',
|
||||
MANAGE: 'manage',
|
||||
SUBSCRIPTION_CHANGE_DISABLED:
|
||||
'sorry, this operation is currently not supported on the web, please check your mobile app',
|
||||
LOGOUT_MESSAGE: 'sure you want to logout?',
|
||||
DOWNLOAD_APP_MESSAGE:
|
||||
'sorry, this operation is currently not supported on the web, please install the desktop app',
|
||||
DOWNLOAD_APP: 'download',
|
||||
APP_DOWNLOAD_URL: 'https://github.com/ente-io/bhari-frame/releases/',
|
||||
EXPORT: 'export data',
|
||||
SUBSCRIPTION_PLAN: 'subscription plan',
|
||||
USAGE_DETAILS: 'usage',
|
||||
FREE_SUBSCRIPTION_INFO: (expiryTime) => (
|
||||
|
@ -162,6 +176,39 @@ const englishConstants = {
|
|||
'subscription purchase failed , please try again later',
|
||||
SUBSCRIPTION_UPDATE_SUCCESS:
|
||||
'your subscription plan is successfully updated',
|
||||
DELETE_MESSAGE: 'sure you want to delete selected files?',
|
||||
DELETE: 'delete',
|
||||
UPLOAD_STRATEGY_CHOICE:
|
||||
'you are uploading multiple folders, would you like us to create',
|
||||
UPLOAD_STRATEGY_SINGLE_COLLECTION: 'a single album for everything',
|
||||
OR: 'or',
|
||||
UPLOAD_STRATEGY_COLLECTION_PER_FOLDER: 'separate albums for every folder',
|
||||
SESSION_EXPIRED_MESSAGE:
|
||||
'your session has expired, please login again to continue',
|
||||
SESSION_EXPIRED: 'login',
|
||||
SYNC_FAILED:
|
||||
'failed to sync with remote server, please refresh page to try again',
|
||||
PASSWORD_GENERATION_FAILED: `your browser was unable to generate a strong enough password that meets ente's encryption standards, please try using the mobile app or another browser`,
|
||||
CHANGE_PASSWORD: 'change password',
|
||||
GO_BACK: 'go back',
|
||||
DOWNLOAD_RECOVERY_KEY: 'recovery key',
|
||||
SAVE_LATER: 'save later',
|
||||
SAVE: 'save',
|
||||
RECOVERY_KEY_DESCRIPTION:
|
||||
'if you forget your password, the only way you can recover your data is with this key',
|
||||
KEY_NOT_STORED_DISCLAIMER:
|
||||
"we don't store this key, so please save this in a safe place",
|
||||
RECOVERY_KEY_FILENAME: 'ente-recovery-key.txt',
|
||||
FORGOT_PASSWORD: 'forgot password?',
|
||||
RECOVER_ACCOUNT: 'recover account',
|
||||
RETURN_RECOVERY_KEY_HINT: 'recovery key',
|
||||
RECOVER: 'recover',
|
||||
NO_RECOVERY_KEY: 'no recovery key?',
|
||||
INCORRECT_RECOVERY_KEY: 'incorrect recovery key',
|
||||
SORRY: 'sorry',
|
||||
NO_RECOVERY_KEY_MESSAGE:
|
||||
'due to the nature of our end-to-end encryption protocol, your data cannot be decrypted without your password or recovery key',
|
||||
OK: 'ok',
|
||||
};
|
||||
|
||||
export default englishConstants;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { runningInBrowser } from 'utils/common/utilFunctions';
|
||||
import { runningInBrowser } from 'utils/common';
|
||||
import englishConstants from './englishConstants';
|
||||
|
||||
/** Enums of supported locale */
|
||||
|
|
|
@ -76,8 +76,16 @@ export class Crypto {
|
|||
return libsodium.verifyHash(hash, input);
|
||||
}
|
||||
|
||||
async deriveKey(passphrase, salt) {
|
||||
return libsodium.deriveKey(passphrase, salt);
|
||||
async deriveKey(passphrase, salt, opsLimit, memLimit) {
|
||||
return libsodium.deriveKey(passphrase, salt, opsLimit, memLimit);
|
||||
}
|
||||
|
||||
async deriveSensitiveKey(passphrase, salt) {
|
||||
return libsodium.deriveSensitiveKey(passphrase, salt);
|
||||
}
|
||||
|
||||
async deriveIntermediateKey(passphrase, salt) {
|
||||
return libsodium.deriveIntermediateKey(passphrase, salt);
|
||||
}
|
||||
|
||||
async decryptB64(data, nonce, key) {
|
||||
|
@ -96,18 +104,14 @@ export class Crypto {
|
|||
return libsodium.encryptUTF8(data, key);
|
||||
}
|
||||
|
||||
async generateMasterKey() {
|
||||
return libsodium.generateMasterKey();
|
||||
async generateEncryptionKey() {
|
||||
return libsodium.generateEncryptionKey();
|
||||
}
|
||||
|
||||
async generateSaltToDeriveKey() {
|
||||
return libsodium.generateSaltToDeriveKey();
|
||||
}
|
||||
|
||||
async deriveKey(passphrase, salt) {
|
||||
return libsodium.deriveKey(passphrase, salt);
|
||||
}
|
||||
|
||||
async generateKeyPair() {
|
||||
return libsodium.generateKeyPair();
|
||||
}
|
||||
|
@ -127,6 +131,12 @@ export class Crypto {
|
|||
async fromB64(string) {
|
||||
return libsodium.fromB64(string);
|
||||
}
|
||||
async toHex(string) {
|
||||
return libsodium.toHex(string);
|
||||
}
|
||||
async fromHex(string) {
|
||||
return libsodium.fromHex(string);
|
||||
}
|
||||
}
|
||||
|
||||
Comlink.expose(Crypto);
|
||||
|
|
18
yarn.lock
18
yarn.lock
|
@ -2183,13 +2183,6 @@
|
|||
jsonwebtoken "^8.5.1"
|
||||
path-to-regexp "^6.1.0"
|
||||
|
||||
"@stripe/react-stripe-js@^1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.4.0.tgz#a67e72202297fc409dc2c8c4f3fb98e0b61fa06d"
|
||||
integrity sha512-Pz5QmG8PgJ3pi8gOWxlngk+ns63p2L1Ds192fn55ykZNRKfGz3G6sfssUVThHn/NAt2Hp1eCEsy/hvlKnXJI6g==
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
|
||||
"@stripe/stripe-js@^1.13.2":
|
||||
version "1.13.2"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.13.2.tgz#bb2f561085b5dd091355df871d432b8e1fd467f6"
|
||||
|
@ -3197,9 +3190,9 @@ camelize@^1.0.0:
|
|||
integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=
|
||||
|
||||
caniuse-lite@^1.0.30001093, caniuse-lite@^1.0.30001113, caniuse-lite@^1.0.30001181:
|
||||
version "1.0.30001197"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001197.tgz"
|
||||
integrity sha512-8aE+sqBqtXz4G8g35Eg/XEaFr2N7rd/VQ6eABGBmNtcB8cN6qNJhMi6oSFy4UWWZgqgL3filHT8Nha4meu3tsw==
|
||||
version "1.0.30001204"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001204.tgz"
|
||||
integrity sha512-JUdjWpcxfJ9IPamy2f5JaRDCaqJOxDzOSKtbdx4rH9VivMd1vIzoPumsJa9LoMIi4Fx2BV2KZOxWhNkBjaYivQ==
|
||||
|
||||
chalk@4.0.0:
|
||||
version "4.0.0"
|
||||
|
@ -4877,6 +4870,11 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2:
|
|||
is-data-descriptor "^1.0.0"
|
||||
kind-of "^6.0.2"
|
||||
|
||||
is-electron@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.0.tgz#8943084f09e8b731b3a7a0298a7b5d56f6b7eef0"
|
||||
integrity sha512-SpMppC2XR3YdxSzczXReBjqs2zGscWQpBIKqwXYBFic0ERaxNVgwLCHwOLZeESfdJQjX0RDvrJ1lBXX2ij+G1Q==
|
||||
|
||||
is-extendable@^0.1.0, is-extendable@^0.1.1:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
|
||||
|
|
Loading…
Reference in a new issue