Merge branch 'master' into stripe-integration

This commit is contained in:
Abhinav-grd 2021-04-07 12:48:45 +05:30
commit 2b5d881f99
53 changed files with 2421 additions and 884 deletions

View file

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View file

@ -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
View 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',
};

View file

@ -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

View 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>
);
}

View file

@ -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);

View 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>
);
}

View 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;

View 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;

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View 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')}
/>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View file

@ -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>
);

View 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>
);
}

View file

@ -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}

View file

@ -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>
);

View file

@ -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}

View file

@ -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"

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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}
/>
</>
);
}

View 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>
</>
);
}

View file

@ -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}`);

View file

@ -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;
}
}
/**

View file

@ -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';

View file

@ -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,

View file

@ -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',

View 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();

View file

@ -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');
}
};

View file

@ -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;

View file

@ -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;
}
};

View file

@ -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;

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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';

View 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),
};
}

View file

@ -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
View 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;

View file

@ -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));
}

View 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 });
}

View file

@ -1,4 +1,4 @@
import { runningInBrowser } from 'utils/common/utilFunctions';
import { runningInBrowser } from 'utils/common';
const localForage: LocalForage = runningInBrowser() && require('localforage');
if (runningInBrowser()) {

View file

@ -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) => {

View file

@ -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;

View file

@ -1,4 +1,4 @@
import { runningInBrowser } from 'utils/common/utilFunctions';
import { runningInBrowser } from 'utils/common';
import englishConstants from './englishConstants';
/** Enums of supported locale */

View file

@ -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);

View file

@ -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"