Merge branch 'main' into accounts-passkeys

This commit is contained in:
Neeraj Gupta 2024-02-16 14:53:04 +05:30
commit 5d7d166976
127 changed files with 6078 additions and 368 deletions

View file

@ -13,5 +13,8 @@
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"typescript.enablePromptUseWorkspaceTsdk": true
"typescript.enablePromptUseWorkspaceTsdk": true,
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}

9
Development.md Normal file
View file

@ -0,0 +1,9 @@
## Notes for Developers
### Local production builds
```sh
yarn export:photos
cd apps/
python3 -m http.server
```

View file

@ -1,6 +1,3 @@
import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
import { initSentry } from '@ente/shared/sentry/config/sentry.config.base';
const DEFAULT_SENTRY_DSN =
'https://e2ccc39d811640b49602323774220955@sentry.ente.io/13';
setupSentry(DEFAULT_SENTRY_DSN);
initSentry('https://ad075e4713480307bb8bc0811547c65e@sentry.ente.io/8');

View file

@ -5,7 +5,7 @@ import { t } from 'i18next';
import { useRouter } from 'next/router';
import { Overlay } from '@ente/shared/components/Container';
import EnteSpinner from '@ente/shared/components/EnteSpinner';
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
import { LS_KEYS } from '@ente/shared/storage/localStorage';
import HTTPService from '@ente/shared/network/HTTPService';
import Head from 'next/head';
import LoadingBar from 'react-top-loading-bar';
@ -17,8 +17,10 @@ import {
DialogBoxAttributesV2,
SetDialogBoxAttributesV2,
} from '@ente/shared/components/DialogBoxV2/types';
import { addLogLine } from '@ente/shared/logging';
import { clearLogsIfLocalStorageLimitExceeded } from '@ente/shared/logging/web';
import {
clearLogsIfLocalStorageLimitExceeded,
logStartupMessage,
} from '@ente/shared/logging/web';
import { CacheProvider } from '@emotion/react';
import {
@ -31,8 +33,6 @@ import createEmotionCache from '@ente/shared/themes/createEmotionCache';
import { THEME_COLOR } from '@ente/shared/themes/constants';
import { SetTheme } from '@ente/shared/themes/types';
import { setupI18n } from '@ente/shared/i18n';
import { getSentryUserID } from '@ente/shared/sentry/utils';
import { User } from '@ente/shared/user/types';
import { useLocalState } from '@ente/shared/hooks/useLocalState';
import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
import { getTheme } from '@ente/shared/themes';
@ -87,12 +87,7 @@ export default function App(props: EnteAppProps) {
});
// setup logging
clearLogsIfLocalStorageLimitExceeded();
const main = async () => {
addLogLine(`userID: ${(getData(LS_KEYS.USER) as User)?.id}`);
addLogLine(`sentryID: ${await getSentryUserID()}`);
addLogLine(`sentry release ID: ${process.env.SENTRY_RELEASE}`);
};
main();
logStartupMessage();
}, []);
const setUserOnline = () => setOffline(false);

13
apps/cast/.eslintrc.js Normal file
View file

@ -0,0 +1,13 @@
module.exports = {
// When root is set to true, ESLint will stop looking for configuration files in parent directories.
// This is required here to ensure desktop picks the right eslint config, where this app is
// packaged as a submodule.
root: true,
extends: ['@ente/eslint-config'],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json',
},
ignorePatterns: ['.eslintrc.js'],
};

36
apps/cast/.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

40
apps/cast/README.md Normal file
View file

@ -0,0 +1,40 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

53
apps/cast/configUtil.js Normal file
View file

@ -0,0 +1,53 @@
const cp = require('child_process');
const { getIsSentryEnabled } = require('./sentryConfigUtil');
module.exports = {
WEB_SECURITY_HEADERS: {
'Strict-Transport-Security': ' max-age=63072000',
'X-Content-Type-Options': 'nosniff',
'X-Download-Options': 'noopen',
'X-Frame-Options': 'deny',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'same-origin',
},
CSP_DIRECTIVES: {
// self is safe enough
'default-src': "'self'",
// data to allow two factor qr code
'img-src': "'self' blob: data: https://*.openstreetmap.org",
'media-src': "'self' blob:",
'manifest-src': "'self'",
'style-src': "'self' 'unsafe-inline'",
'font-src ': "'self'; script-src 'self' 'unsafe-eval' blob:",
'connect-src':
"'self' https://*.ente.io http://localhost:8080 data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com https://ente-prod-v3.s3.eu-central-2.wasabisys.com/ https://ente-staging-eu.s3.eu-central-003.backblazeb2.com/ ws://localhost:3000/",
'base-uri ': "'self'",
// to allow worker
'child-src': "'self' blob:",
'object-src': "'none'",
'frame-ancestors': " 'none'",
'form-action': "'none'",
'report-uri': ' https://csp-reporter.ente.io/local',
'report-to': ' https://csp-reporter.ente.io/local',
'script-src-elem': "'self' https://www.gstatic.com",
},
ALL_ROUTES: '/(.*)',
buildCSPHeader: (directives) => ({
'Content-Security-Policy-Report-Only': Object.entries(
directives
).reduce((acc, [key, value]) => acc + `${key} ${value};`, ''),
}),
convertToNextHeaderFormat: (headers) =>
Object.entries(headers).map(([key, value]) => ({ key, value })),
getGitSha: () =>
cp.execSync('git rev-parse --short HEAD', {
cwd: __dirname,
encoding: 'utf8',
}),
getIsSentryEnabled: getIsSentryEnabled,
};

3
apps/cast/next.config.js Normal file
View file

@ -0,0 +1,3 @@
const nextConfigBase = require('@ente/shared/next/next.config.base.js');
module.exports = nextConfigBase;

19
apps/cast/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "cast",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"export": "next export"
},
"dependencies": {
"mime-types": "^2.1.35",
"jszip": "3.10.1"
},
"devDependencies": {
"sass": "^1.69.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -0,0 +1,3 @@
<svg width="43" height="13" viewBox="0 0 43 13" fill="#fff" xmlns="http://www.w3.org/2000/svg">
<path d="M6.102 12.144C4.998 12.144 4.026 11.928 3.186 11.496C2.358 11.064 1.716 10.476 1.26 9.732C0.804 8.976 0.576 8.118 0.576 7.158C0.576 6.186 0.798 5.328 1.242 4.584C1.698 3.828 2.316 3.24 3.096 2.82C3.876 2.388 4.758 2.172 5.742 2.172C6.69 2.172 7.542 2.376 8.298 2.784C9.066 3.18 9.672 3.756 10.116 4.512C10.56 5.256 10.782 6.15 10.782 7.194C10.782 7.302 10.776 7.428 10.764 7.572C10.752 7.704 10.74 7.83 10.728 7.95H2.862V6.312H9.252L8.172 6.798C8.172 6.294 8.07 5.856 7.866 5.484C7.662 5.112 7.38 4.824 7.02 4.62C6.66 4.404 6.24 4.296 5.76 4.296C5.28 4.296 4.854 4.404 4.482 4.62C4.122 4.824 3.84 5.118 3.636 5.502C3.432 5.874 3.33 6.318 3.33 6.834V7.266C3.33 7.794 3.444 8.262 3.672 8.67C3.912 9.066 4.242 9.372 4.662 9.588C5.094 9.792 5.598 9.894 6.174 9.894C6.69 9.894 7.14 9.816 7.524 9.66C7.92 9.504 8.28 9.27 8.604 8.958L10.098 10.578C9.654 11.082 9.096 11.472 8.424 11.748C7.752 12.012 6.978 12.144 6.102 12.144ZM18.5375 2.172C19.3055 2.172 19.9895 2.328 20.5895 2.64C21.2015 2.94 21.6815 3.408 22.0295 4.044C22.3775 4.668 22.5515 5.472 22.5515 6.456V12H19.7435V6.888C19.7435 6.108 19.5695 5.532 19.2215 5.16C18.8855 4.788 18.4055 4.602 17.7815 4.602C17.3375 4.602 16.9355 4.698 16.5755 4.89C16.2275 5.07 15.9515 5.352 15.7475 5.736C15.5555 6.12 15.4595 6.612 15.4595 7.212V12H12.6515V2.316H15.3335V4.998L14.8295 4.188C15.1775 3.54 15.6755 3.042 16.3235 2.694C16.9715 2.346 17.7095 2.172 18.5375 2.172ZM29.0568 12.144C27.9168 12.144 27.0288 11.856 26.3928 11.28C25.7568 10.692 25.4388 9.822 25.4388 8.67V0.174H28.2468V8.634C28.2468 9.042 28.3548 9.36 28.5708 9.588C28.7868 9.804 29.0808 9.912 29.4528 9.912C29.8968 9.912 30.2748 9.792 30.5868 9.552L31.3428 11.532C31.0548 11.736 30.7068 11.892 30.2988 12C29.9028 12.096 29.4888 12.144 29.0568 12.144ZM23.9448 4.692V2.532H30.6588V4.692H23.9448ZM37.4262 12.144C36.3222 12.144 35.3502 11.928 34.5102 11.496C33.6822 11.064 33.0402 10.476 32.5842 9.732C32.1282 8.976 31.9002 8.118 31.9002 7.158C31.9002 6.186 32.1222 5.328 32.5662 4.584C33.0222 3.828 33.6402 3.24 34.4202 2.82C35.2002 2.388 36.0822 2.172 37.0662 2.172C38.0142 2.172 38.8662 2.376 39.6222 2.784C40.3902 3.18 40.9962 3.756 41.4402 4.512C41.8842 5.256 42.1062 6.15 42.1062 7.194C42.1062 7.302 42.1002 7.428 42.0882 7.572C42.0762 7.704 42.0642 7.83 42.0522 7.95H34.1862V6.312H40.5762L39.4962 6.798C39.4962 6.294 39.3942 5.856 39.1902 5.484C38.9862 5.112 38.7042 4.824 38.3442 4.62C37.9842 4.404 37.5642 4.296 37.0842 4.296C36.6042 4.296 36.1782 4.404 35.8062 4.62C35.4462 4.824 35.1642 5.118 34.9602 5.502C34.7562 5.874 34.6542 6.318 34.6542 6.834V7.266C34.6542 7.794 34.7682 8.262 34.9962 8.67C35.2362 9.066 35.5662 9.372 35.9862 9.588C36.4182 9.792 36.9222 9.894 37.4982 9.894C38.0142 9.894 38.4642 9.816 38.8482 9.66C39.2442 9.504 39.6042 9.27 39.9282 8.958L41.4222 10.578C40.9782 11.082 40.4202 11.472 39.7482 11.748C39.0762 12.012 38.3022 12.144 37.4262 12.144Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

View file

@ -0,0 +1,2 @@
import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
setupSentry('https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2');

View file

@ -0,0 +1,3 @@
defaults.url=https://sentry.ente.io/
defaults.org=ente
defaults.project=photos-web

View file

View file

@ -0,0 +1,11 @@
const ENV_DEVELOPMENT = 'development';
module.exports.getIsSentryEnabled = () => {
if (process.env.NEXT_PUBLIC_SENTRY_ENV === ENV_DEVELOPMENT) {
return false;
} else if (process.env.NEXT_PUBLIC_DISABLE_SENTRY === 'true') {
return false;
} else {
return true;
}
};

View file

@ -0,0 +1,51 @@
.circle {
width: 100px;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
overflow: hidden;
&.animate {
animation: scaleIn 0.3s ease-in-out forwards;
}
}
@keyframes scaleIn {
0% {
transform: scale(0);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.checkmark {
width: 100px;
height: 100px;
&__circle {
fill: green;
}
&__check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: strokeCheck 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
stroke: white;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
}
@keyframes strokeCheck {
100% {
stroke-dashoffset: 0;
}
}

View file

@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import styles from './FilledCircleCheck.module.scss'; // Import our CSS module
const FilledCircleCheck = () => {
const [animate, setAnimate] = useState(false);
useEffect(() => {
setAnimate(true);
}, []);
return (
<div className={`${styles.circle} ${animate ? styles.animate : ''}`}>
<svg
className={styles.checkmark}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52">
<circle
className={styles.checkmark__circle}
cx="26"
cy="26"
r="25"
fill="green"
/>
<path
className={styles.checkmark__check}
fill="none"
d="M14.1 27.2l7.1 7.2 16.7-16.8"
/>
</svg>
</div>
);
};
export default FilledCircleCheck;

View file

@ -0,0 +1,62 @@
const colourPool = [
'#87CEFA', // Light Blue
'#90EE90', // Light Green
'#F08080', // Light Coral
'#FFFFE0', // Light Yellow
'#FFB6C1', // Light Pink
'#E0FFFF', // Light Cyan
'#FAFAD2', // Light Goldenrod
'#87CEFA', // Light Sky Blue
'#D3D3D3', // Light Gray
'#B0C4DE', // Light Steel Blue
'#FFA07A', // Light Salmon
'#20B2AA', // Light Sea Green
'#778899', // Light Slate Gray
'#AFEEEE', // Light Turquoise
'#7A58C1', // Light Violet
'#FFA500', // Light Orange
'#A0522D', // Light Brown
'#9370DB', // Light Purple
'#008080', // Light Teal
'#808000', // Light Olive
];
export default function LargeType({ chars }: { chars: string[] }) {
return (
<table
style={{
fontSize: '4rem',
fontWeight: 'bold',
fontFamily: 'monospace',
display: 'flex',
position: 'relative',
}}>
{chars.map((char, i) => (
<tr
key={i}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '0.5rem',
// alternating background
backgroundColor: i % 2 === 0 ? '#2e2e2e' : '#5e5e5e',
}}>
<span
style={{
color: colourPool[i % colourPool.length],
lineHeight: 1.2,
}}>
{char}
</span>
<span
style={{
fontSize: '1rem',
}}>
{i + 1}
</span>
</tr>
))}
</table>
);
}

View file

@ -0,0 +1,42 @@
import FilledCircleCheck from './FilledCircleCheck';
export default function PairedSuccessfullyOverlay() {
return (
<div
style={{
position: 'fixed',
top: 0,
right: 0,
height: '100%',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
backgroundColor: 'black',
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
textAlign: 'center',
}}>
<FilledCircleCheck />
<h2
style={{
marginBottom: 0,
}}>
Pairing Complete
</h2>
<p
style={{
lineHeight: '1.5rem',
}}>
We're preparing your album.
<br /> This should only take a few seconds.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { SlideshowContext } from 'pages/slideshow';
import { useContext, useEffect, useState } from 'react';
export default function PhotoAuditorium({
url,
nextSlideUrl,
}: {
url: string;
nextSlideUrl: string;
}) {
const { showNextSlide } = useContext(SlideshowContext);
const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false);
const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false);
const [prerenderTime, setPrerenderTime] = useState<number | null>(null);
useEffect(() => {
let timeout: NodeJS.Timeout;
let timeout2: NodeJS.Timeout;
if (nextSlidePrerendered) {
const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0;
const delayTime = Math.max(5000 - elapsedTime, 0);
if (elapsedTime >= 5000) {
setShowPreloadedNextSlide(true);
} else {
timeout = setTimeout(() => {
setShowPreloadedNextSlide(true);
}, delayTime);
}
if (showNextSlide) {
timeout2 = setTimeout(() => {
showNextSlide();
setNextSlidePrerendered(false);
setPrerenderTime(null);
setShowPreloadedNextSlide(false);
}, delayTime);
}
}
return () => {
if (timeout) clearTimeout(timeout);
if (timeout2) clearTimeout(timeout2);
};
}, [nextSlidePrerendered, showNextSlide, prerenderTime]);
return (
<div
style={{
width: '100vw',
height: '100vh',
backgroundImage: `url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundBlendMode: 'multiply',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
}}>
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backdropFilter: 'blur(10px)',
}}>
<img
src={url}
style={{
maxWidth: '100%',
maxHeight: '100%',
display: showPreloadedNextSlide ? 'none' : 'block',
}}
/>
<img
src={nextSlideUrl}
style={{
maxWidth: '100%',
maxHeight: '100%',
display: showPreloadedNextSlide ? 'block' : 'none',
}}
onLoad={() => {
setNextSlidePrerendered(true);
setPrerenderTime(Date.now());
}}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import mime from 'mime-types';
import { SlideshowContext } from 'pages/slideshow';
import { useContext, useEffect, useRef } from 'react';
export default function VideoAuditorium({
name,
url,
}: {
name: string;
url: string;
}) {
const { showNextSlide } = useContext(SlideshowContext);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
attemptPlay();
}, [url, videoRef]);
const attemptPlay = async () => {
if (videoRef.current) {
try {
await videoRef.current.play();
} catch {
showNextSlide();
}
}
};
return (
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}>
<video
ref={videoRef}
autoPlay
controls
style={{
maxWidth: '100vw',
maxHeight: '100vh',
}}
onError={showNextSlide}
onEnded={showNextSlide}>
<source src={url} type={mime.lookup(name)} />
</video>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { FILE_TYPE } from 'constants/file';
import PhotoAuditorium from './PhotoAuditorium';
// import VideoAuditorium from './VideoAuditorium';
interface fileProp {
fileName: string;
fileURL: string;
type: FILE_TYPE;
}
interface IProps {
file1: fileProp;
file2: fileProp;
}
export default function Theatre(props: IProps) {
switch (props.file1.type && props.file2.type) {
case FILE_TYPE.IMAGE:
return (
<PhotoAuditorium
url={props.file1.fileURL}
nextSlideUrl={props.file2.fileURL}
/>
);
// case FILE_TYPE.VIDEO:
// return (
// <VideoAuditorium name={props.fileName} url={props.fileURL} />
// );
}
}

View file

@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
export default function TimerBar({ percentage }: { percentage: number }) {
const okColor = '#75C157';
const warningColor = '#FFC000';
const lateColor = '#FF0000';
const [backgroundColor, setBackgroundColor] = useState(okColor);
useEffect(() => {
if (percentage >= 40) {
setBackgroundColor(okColor);
} else if (percentage >= 20) {
setBackgroundColor(warningColor);
} else {
setBackgroundColor(lateColor);
}
}, [percentage]);
return (
<div
style={{
width: `${percentage}%`, // Set the width based on the time left
height: '10px', // Same as the border thickness
backgroundColor, // The color of the moving border
transition: 'width 1s linear', // Smooth transition for the width change
}}
/>
);
}

View file

@ -0,0 +1 @@
export const REQUEST_BATCH_SIZE = 1000;

View file

@ -0,0 +1,56 @@
import { getAlbumsURL } from '@ente/shared/network/api';
import { runningInBrowser } from '@ente/shared/platform';
import { PAGES } from 'constants/pages';
export enum APPS {
PHOTOS = 'PHOTOS',
AUTH = 'AUTH',
ALBUMS = 'ALBUMS',
}
export const ALLOWED_APP_PAGES = new Map([
[APPS.ALBUMS, [PAGES.SHARED_ALBUMS, PAGES.ROOT]],
[
APPS.AUTH,
[
PAGES.ROOT,
PAGES.LOGIN,
PAGES.SIGNUP,
PAGES.VERIFY,
PAGES.CREDENTIALS,
PAGES.RECOVER,
PAGES.CHANGE_PASSWORD,
PAGES.GENERATE,
PAGES.AUTH,
PAGES.TWO_FACTOR_VERIFY,
PAGES.TWO_FACTOR_RECOVER,
],
],
]);
export const CLIENT_PACKAGE_NAMES = new Map([
[APPS.ALBUMS, 'io.ente.albums.web'],
[APPS.PHOTOS, 'io.ente.photos.web'],
[APPS.AUTH, 'io.ente.auth.web'],
]);
export const getAppNameAndTitle = () => {
if (!runningInBrowser()) {
return {};
}
const currentURL = new URL(window.location.href);
const albumsURL = new URL(getAlbumsURL());
if (currentURL.origin === albumsURL.origin) {
return { name: APPS.ALBUMS, title: 'ente Photos' };
} else {
return { name: APPS.PHOTOS, title: 'ente Photos' };
}
};
export const getAppTitle = () => {
return getAppNameAndTitle().title;
};
export const getAppName = () => {
return getAppNameAndTitle().name;
};

View file

@ -0,0 +1,5 @@
export enum CACHES {
THUMBS = 'thumbs',
FACE_CROPS = 'face-crops',
FILES = 'files',
}

View file

@ -0,0 +1,100 @@
export const ARCHIVE_SECTION = -1;
export const TRASH_SECTION = -2;
export const DUMMY_UNCATEGORIZED_COLLECTION = -3;
export const HIDDEN_ITEMS_SECTION = -4;
export const ALL_SECTION = 0;
export const DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME = 'Hidden';
export enum CollectionType {
folder = 'folder',
favorites = 'favorites',
album = 'album',
uncategorized = 'uncategorized',
}
export enum CollectionSummaryType {
folder = 'folder',
favorites = 'favorites',
album = 'album',
archive = 'archive',
trash = 'trash',
uncategorized = 'uncategorized',
all = 'all',
outgoingShare = 'outgoingShare',
incomingShareViewer = 'incomingShareViewer',
incomingShareCollaborator = 'incomingShareCollaborator',
sharedOnlyViaLink = 'sharedOnlyViaLink',
archived = 'archived',
defaultHidden = 'defaultHidden',
hiddenItems = 'hiddenItems',
pinned = 'pinned',
}
export enum COLLECTION_LIST_SORT_BY {
NAME,
CREATION_TIME_ASCENDING,
UPDATION_TIME_DESCENDING,
}
export const COLLECTION_SHARE_DEFAULT_VALID_DURATION =
10 * 24 * 60 * 60 * 1000 * 1000;
export const COLLECTION_SHARE_DEFAULT_DEVICE_LIMIT = 4;
export const COLLECTION_SORT_ORDER = new Map([
[CollectionSummaryType.all, 0],
[CollectionSummaryType.hiddenItems, 0],
[CollectionSummaryType.uncategorized, 1],
[CollectionSummaryType.favorites, 2],
[CollectionSummaryType.pinned, 3],
[CollectionSummaryType.album, 4],
[CollectionSummaryType.folder, 4],
[CollectionSummaryType.incomingShareViewer, 4],
[CollectionSummaryType.incomingShareCollaborator, 4],
[CollectionSummaryType.outgoingShare, 4],
[CollectionSummaryType.sharedOnlyViaLink, 4],
[CollectionSummaryType.archived, 4],
[CollectionSummaryType.archive, 5],
[CollectionSummaryType.trash, 6],
[CollectionSummaryType.defaultHidden, 7],
]);
export const SYSTEM_COLLECTION_TYPES = new Set([
CollectionSummaryType.all,
CollectionSummaryType.archive,
CollectionSummaryType.trash,
CollectionSummaryType.uncategorized,
CollectionSummaryType.hiddenItems,
CollectionSummaryType.defaultHidden,
]);
export const ADD_TO_NOT_ALLOWED_COLLECTION = new Set([
CollectionSummaryType.all,
CollectionSummaryType.archive,
CollectionSummaryType.incomingShareViewer,
CollectionSummaryType.trash,
CollectionSummaryType.uncategorized,
CollectionSummaryType.defaultHidden,
CollectionSummaryType.hiddenItems,
]);
export const MOVE_TO_NOT_ALLOWED_COLLECTION = new Set([
CollectionSummaryType.all,
CollectionSummaryType.archive,
CollectionSummaryType.incomingShareViewer,
CollectionSummaryType.incomingShareCollaborator,
CollectionSummaryType.trash,
CollectionSummaryType.uncategorized,
CollectionSummaryType.defaultHidden,
CollectionSummaryType.hiddenItems,
]);
export const OPTIONS_NOT_HAVING_COLLECTION_TYPES = new Set([
CollectionSummaryType.all,
CollectionSummaryType.archive,
]);
export const HIDE_FROM_COLLECTION_BAR_TYPES = new Set([
CollectionSummaryType.trash,
CollectionSummaryType.archive,
CollectionSummaryType.uncategorized,
CollectionSummaryType.defaultHidden,
]);

View file

@ -0,0 +1,3 @@
export const INPUT_PATH_PLACEHOLDER = 'INPUT';
export const FFMPEG_PLACEHOLDER = 'FFMPEG';
export const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';

View file

@ -0,0 +1,43 @@
export const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1);
export const MAX_EDITED_CREATION_TIME = new Date();
export const MAX_EDITED_FILE_NAME_LENGTH = 100;
export const MAX_CAPTION_SIZE = 5000;
export const TYPE_HEIC = 'heic';
export const TYPE_HEIF = 'heif';
export const TYPE_JPEG = 'jpeg';
export const TYPE_JPG = 'jpg';
export enum FILE_TYPE {
IMAGE,
VIDEO,
LIVE_PHOTO,
OTHERS,
}
export const RAW_FORMATS = [
'heic',
'rw2',
'tiff',
'arw',
'cr3',
'cr2',
'raf',
'nef',
'psd',
'dng',
'tif',
];
export const SUPPORTED_RAW_FORMATS = [
'heic',
'rw2',
'tiff',
'arw',
'cr3',
'cr2',
'nef',
'psd',
'dng',
'tif',
];

View file

@ -0,0 +1,15 @@
export const GAP_BTW_TILES = 4;
export const DATE_CONTAINER_HEIGHT = 48;
export const SIZE_AND_COUNT_CONTAINER_HEIGHT = 72;
export const IMAGE_CONTAINER_MAX_HEIGHT = 180;
export const IMAGE_CONTAINER_MAX_WIDTH = 180;
export const MIN_COLUMNS = 4;
export const SPACE_BTW_DATES = 44;
export const SPACE_BTW_DATES_TO_IMAGE_CONTAINER_WIDTH_RATIO = 0.244;
export enum PLAN_PERIOD {
MONTH = 'month',
YEAR = 'year',
}
export const SYNC_INTERVAL_IN_MICROSECONDS = 1000 * 60 * 5; // 5 minutes

View file

@ -0,0 +1,20 @@
export enum PAGES {
CHANGE_EMAIL = '/change-email',
CHANGE_PASSWORD = '/change-password',
CREDENTIALS = '/credentials',
GALLERY = '/gallery',
GENERATE = '/generate',
LOGIN = '/login',
RECOVER = '/recover',
SIGNUP = '/signup',
TWO_FACTOR_SETUP = '/two-factor/setup',
TWO_FACTOR_VERIFY = '/two-factor/verify',
TWO_FACTOR_RECOVER = '/two-factor/recover',
VERIFY = '/verify',
ROOT = '/',
SHARED_ALBUMS = '/shared-albums',
// ML_DEBUG = '/ml-debug',
DEDUPLICATE = '/deduplicate',
// AUTH page is used to show (auth)enticator codes
AUTH = '/auth',
}

View file

@ -0,0 +1,15 @@
export const ENV_DEVELOPMENT = 'development';
export const ENV_PRODUCTION = 'production';
export const getSentryDSN = () =>
process.env.NEXT_PUBLIC_SENTRY_DSN ??
'https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2';
export const getSentryENV = () =>
process.env.NEXT_PUBLIC_SENTRY_ENV ?? ENV_PRODUCTION;
export const getSentryRelease = () => process.env.SENTRY_RELEASE;
export { getIsSentryEnabled } from '../../sentryConfigUtil';
export const isDEVSentryENV = () => getSentryENV() === ENV_DEVELOPMENT;

View file

@ -0,0 +1,142 @@
import { ENCRYPTION_CHUNK_SIZE } from '@ente/shared/crypto/constants';
import { FILE_TYPE } from 'constants/file';
import {
FileTypeInfo,
ImportSuggestion,
Location,
ParsedExtractedMetadata,
} from 'types/upload';
// list of format that were missed by type-detection for some files.
export const WHITELISTED_FILE_FORMATS: FileTypeInfo[] = [
{ fileType: FILE_TYPE.IMAGE, exactType: 'jpeg', mimeType: 'image/jpeg' },
{ fileType: FILE_TYPE.IMAGE, exactType: 'jpg', mimeType: 'image/jpeg' },
{ fileType: FILE_TYPE.VIDEO, exactType: 'webm', mimeType: 'video/webm' },
{ fileType: FILE_TYPE.VIDEO, exactType: 'mod', mimeType: 'video/mpeg' },
{ fileType: FILE_TYPE.VIDEO, exactType: 'mp4', mimeType: 'video/mp4' },
{ fileType: FILE_TYPE.IMAGE, exactType: 'gif', mimeType: 'image/gif' },
{ fileType: FILE_TYPE.VIDEO, exactType: 'dv', mimeType: 'video/x-dv' },
{
fileType: FILE_TYPE.VIDEO,
exactType: 'wmv',
mimeType: 'video/x-ms-asf',
},
{
fileType: FILE_TYPE.VIDEO,
exactType: 'hevc',
mimeType: 'video/hevc',
},
{
fileType: FILE_TYPE.IMAGE,
exactType: 'raf',
mimeType: 'image/x-fuji-raf',
},
{
fileType: FILE_TYPE.IMAGE,
exactType: 'orf',
mimeType: 'image/x-olympus-orf',
},
{
fileType: FILE_TYPE.IMAGE,
exactType: 'crw',
mimeType: 'image/x-canon-crw',
},
];
export const KNOWN_NON_MEDIA_FORMATS = ['xmp', 'html', 'txt'];
export const EXIFLESS_FORMATS = ['gif', 'bmp'];
// this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
export const MULTIPART_PART_SIZE = 20 * 1024 * 1024;
export const FILE_READER_CHUNK_SIZE = ENCRYPTION_CHUNK_SIZE;
export const FILE_CHUNKS_COMBINED_FOR_A_UPLOAD_PART = Math.floor(
MULTIPART_PART_SIZE / FILE_READER_CHUNK_SIZE
);
export const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random();
export const NULL_LOCATION: Location = { latitude: null, longitude: null };
export enum UPLOAD_STAGES {
START,
READING_GOOGLE_METADATA_FILES,
EXTRACTING_METADATA,
UPLOADING,
CANCELLING,
FINISH,
}
export enum UPLOAD_STRATEGY {
SINGLE_COLLECTION,
COLLECTION_PER_FOLDER,
}
export enum UPLOAD_RESULT {
FAILED,
ALREADY_UPLOADED,
UNSUPPORTED,
BLOCKED,
TOO_LARGE,
LARGER_THAN_AVAILABLE_STORAGE,
UPLOADED,
UPLOADED_WITH_STATIC_THUMBNAIL,
ADDED_SYMLINK,
}
export enum PICKED_UPLOAD_TYPE {
FILES = 'files',
FOLDERS = 'folders',
ZIPS = 'zips',
}
export const MAX_FILE_SIZE_SUPPORTED = 4 * 1024 * 1024 * 1024; // 4 GB
export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB
export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = {
location: NULL_LOCATION,
creationTime: null,
width: null,
height: null,
};
export const A_SEC_IN_MICROSECONDS = 1e6;
export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = {
rootFolderName: '',
hasNestedFolders: false,
hasRootLevelFileWithFolder: false,
};
export const BLACK_THUMBNAIL_BASE64 =
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' +
'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ' +
'EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC' +
'ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF' +
'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk' +
'6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztL' +
'W2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAA' +
'AAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVY' +
'nLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImK' +
'kpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oAD' +
'AMBAAIRAxEAPwD/AD/6ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAC' +
'gAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA' +
'KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAK' +
'ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA' +
'KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD/9k=';

View file

@ -0,0 +1,19 @@
export const ENTE_WEBSITE_LINK = 'https://ente.io';
export const ML_BLOG_LINK = 'https://ente.io/blog/desktop-ml-beta';
export const FACE_SEARCH_PRIVACY_POLICY_LINK =
'https://ente.io/privacy#8-biometric-information-privacy-policy';
export const SUPPORT_EMAIL = 'support@ente.io';
export const APP_DOWNLOAD_URL = 'https://ente.io/download/desktop';
export const FEEDBACK_EMAIL = 'feedback@ente.io';
export const DELETE_ACCOUNT_EMAIL = 'account-deletion@ente.io';
export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues';
export const DESKTOP_ROADMAP_URL =
'https://github.com/ente-io/photos-desktop/issues';

View file

@ -0,0 +1,21 @@
import type { AppProps } from 'next/app';
import 'styles/global.css';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { getTheme } from '@ente/shared/themes';
import { THEME_COLOR } from '@ente/shared/themes/constants';
import { APPS } from '@ente/shared/apps/constants';
export default function App({ Component, pageProps }: AppProps) {
return (
<ThemeProvider theme={getTheme(THEME_COLOR.DARK, APPS.PHOTOS)}>
<CssBaseline enableColorScheme />
<main
style={{
display: 'contents',
}}>
<Component {...pageProps} />
</main>
</ThemeProvider>
);
}

View file

@ -0,0 +1,25 @@
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html
lang="en"
style={{
height: '100%',
width: '100%',
}}>
<Head />
<body
style={{
height: '100%',
width: '100%',
margin: 0,
backgroundColor: 'black',
color: 'white',
}}>
<Main />
<NextScript />
</body>
</Html>
);
}

View file

@ -0,0 +1,242 @@
import EnteSpinner from '@ente/shared/components/EnteSpinner';
import { boxSealOpen, toB64 } from '@ente/shared/crypto/internal/libsodium';
import { useCastReceiver } from '@ente/shared/hooks/useCastReceiver';
import { addLogLine } from '@ente/shared/logging';
import castGateway from '@ente/shared/network/cast';
import LargeType from 'components/LargeType';
import _sodium from 'libsodium-wrappers';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { storeCastData } from 'services/cast/castService';
// Function to generate cryptographically secure digits
const generateSecureData = (length: number): Uint8Array => {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
// Modulo operation to ensure each byte is a single digit
for (let i = 0; i < length; i++) {
array[i] = array[i] % 10;
}
return array;
};
const convertDataToDecimalString = (data: Uint8Array): string => {
let decimalString = '';
for (let i = 0; i < data.length; i++) {
decimalString += data[i].toString(); // No need to pad, as each value is a single digit
}
return decimalString;
};
export default function PairingMode() {
const [digits, setDigits] = useState<string[]>([]);
const [publicKeyB64, setPublicKeyB64] = useState('');
const [privateKeyB64, setPrivateKeyB64] = useState('');
const [codePending, setCodePending] = useState(true);
const [isCastReady, setIsCastReady] = useState(false);
const { cast } = useCastReceiver();
useEffect(() => {
init();
}, []);
useEffect(() => {
if (!cast) return;
if (isCastReady) return;
const context = cast.framework.CastReceiverContext.getInstance();
try {
const options = new cast.framework.CastReceiverOptions();
options.customNamespaces = Object.assign({});
options.customNamespaces['urn:x-cast:pair-request'] =
cast.framework.system.MessageType.JSON;
options.disableIdleTimeout = true;
context.addCustomMessageListener(
'urn:x-cast:pair-request',
messageReceiveHandler
);
context.start(options);
} catch (e) {
addLogLine(e, 'failed to create cast context');
}
setIsCastReady(true);
return () => {
context.stop();
};
}, [cast, isCastReady]);
const messageReceiveHandler = (message: {
type: string;
senderId: string;
data: any;
}) => {
cast.framework.CastReceiverContext.getInstance().sendCustomMessage(
'urn:x-cast:pair-request',
message.senderId,
{
code: digits.join(''),
}
);
};
const init = async () => {
const data = generateSecureData(6);
setDigits(convertDataToDecimalString(data).split(''));
const keypair = await generateKeyPair();
setPublicKeyB64(await toB64(keypair.publicKey));
setPrivateKeyB64(await toB64(keypair.privateKey));
};
const generateKeyPair = async () => {
await _sodium.ready;
const keypair = _sodium.crypto_box_keypair();
return keypair;
};
const pollForCastData = async () => {
if (codePending) {
return;
}
// see if we were acknowledged on the client.
// the client will send us the encrypted payload using our public key that we advertised.
// then, we can decrypt this and store all the necessary info locally so we can play the collection slideshow.
let devicePayload = '';
try {
const encDastData = await castGateway.getCastData(
`${digits.join('')}`
);
if (!encDastData) return;
devicePayload = encDastData;
} catch (e) {
setCodePending(true);
init();
return;
}
const decryptedPayload = await boxSealOpen(
devicePayload,
publicKeyB64,
privateKeyB64
);
const decryptedPayloadObj = JSON.parse(atob(decryptedPayload));
return decryptedPayloadObj;
};
const advertisePublicKey = async (publicKeyB64: string) => {
// hey client, we exist!
try {
await castGateway.registerDevice(
`${digits.join('')}`,
publicKeyB64
);
setCodePending(false);
} catch (e) {
// schedule re-try after 5 seconds
setTimeout(() => {
init();
}, 5000);
return;
}
};
const router = useRouter();
useEffect(() => {
if (digits.length < 1 || !publicKeyB64 || !privateKeyB64) return;
const interval = setInterval(async () => {
const data = await pollForCastData();
if (!data) return;
storeCastData(data);
await router.push('/slideshow');
}, 1000);
return () => {
clearInterval(interval);
};
}, [digits, publicKeyB64, privateKeyB64, codePending]);
useEffect(() => {
if (!publicKeyB64) return;
advertisePublicKey(publicKeyB64);
}, [publicKeyB64]);
return (
<>
<div
style={{
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}>
<div
style={{
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}>
<img width={150} src="/images/ente.svg" />
<h1
style={{
fontWeight: 'normal',
}}>
Enter this code on <b>ente</b> to pair this TV
</h1>
<div
style={{
borderRadius: '10px',
overflow: 'hidden',
}}>
{codePending ? (
<EnteSpinner />
) : (
<>
<LargeType chars={digits} />
</>
)}
</div>
<p
style={{
fontSize: '1.2rem',
}}>
Visit{' '}
<a
style={{
textDecoration: 'none',
color: '#87CEFA',
fontWeight: 'bold',
}}
href="https://ente.io/cast"
target="_blank">
ente.io/cast
</a>{' '}
for help
</p>
<div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
backgroundColor: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '10px',
borderRadius: '10px',
}}>
<img src="/images/help-qrcode.webp" />
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,186 @@
import { logError } from '@ente/shared/sentry';
import PairedSuccessfullyOverlay from 'components/PairedSuccessfullyOverlay';
import Theatre from 'components/Theatre';
import { FILE_TYPE } from 'constants/file';
import { useRouter } from 'next/router';
import { createContext, useEffect, useState } from 'react';
import {
getCastCollection,
getLocalFiles,
syncPublicFiles,
} from 'services/cast/castService';
import { Collection } from 'types/collection';
import { EnteFile } from 'types/file';
import { getPreviewableImage, isRawFileFromFileName } from 'utils/file';
export const SlideshowContext = createContext<{
showNextSlide: () => void;
}>(null);
const renderableFileURLCache = new Map<number, string>();
export default function Slideshow() {
const [collectionFiles, setCollectionFiles] = useState<EnteFile[]>([]);
const [currentFile, setCurrentFile] = useState<EnteFile | undefined>(
undefined
);
const [nextFile, setNextFile] = useState<EnteFile | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [castToken, setCastToken] = useState<string>('');
const [castCollection, setCastCollection] = useState<
Collection | undefined
>(undefined);
const syncCastFiles = async (token: string) => {
try {
const castToken = window.localStorage.getItem('castToken');
const requestedCollectionKey =
window.localStorage.getItem('collectionKey');
const collection = await getCastCollection(
castToken,
requestedCollectionKey
);
if (
castCollection === undefined ||
castCollection.updationTime !== collection.updationTime
) {
setCastCollection(collection);
await syncPublicFiles(token, collection, () => {});
const files = await getLocalFiles(String(collection.id));
setCollectionFiles(
files.filter((file) => isFileEligibleForCast(file))
);
}
} catch (e) {
logError(e, 'error during sync');
router.push('/');
}
};
const init = async () => {
try {
const castToken = window.localStorage.getItem('castToken');
setCastToken(castToken);
} catch (e) {
logError(e, 'error during sync');
router.push('/');
}
};
useEffect(() => {
if (castToken) {
const intervalId = setInterval(() => {
syncCastFiles(castToken);
}, 5000);
return () => clearInterval(intervalId);
}
}, [castToken]);
const isFileEligibleForCast = (file: EnteFile) => {
const fileType = file.metadata.fileType;
if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) {
return false;
}
const fileSizeLimit = 100 * 1024 * 1024;
if (file.info.fileSize > fileSizeLimit) {
return false;
}
const name = file.metadata.title;
if (fileType === FILE_TYPE.IMAGE) {
if (isRawFileFromFileName(name)) {
return false;
}
}
return true;
};
const router = useRouter();
useEffect(() => {
init();
}, []);
useEffect(() => {
if (collectionFiles.length < 1) return;
showNextSlide();
}, [collectionFiles]);
const showNextSlide = () => {
const currentIndex = collectionFiles.findIndex(
(file) => file.id === currentFile?.id
);
const nextIndex = (currentIndex + 1) % collectionFiles.length;
const nextNextIndex = (nextIndex + 1) % collectionFiles.length;
const nextFile = collectionFiles[nextIndex];
const nextNextFile = collectionFiles[nextNextIndex];
setCurrentFile(nextFile);
setNextFile(nextNextFile);
};
const [renderableFileURL, setRenderableFileURL] = useState<string>('');
const getRenderableFileURL = async () => {
if (!currentFile) return;
const cacheValue = renderableFileURLCache.get(currentFile.id);
if (cacheValue) {
setRenderableFileURL(cacheValue);
setLoading(false);
return;
}
try {
const blob = await getPreviewableImage(
currentFile as EnteFile,
castToken
);
const url = URL.createObjectURL(blob);
renderableFileURLCache.set(currentFile?.id, url);
setRenderableFileURL(url);
} catch (e) {
return;
} finally {
setLoading(false);
}
};
useEffect(() => {
if (currentFile) {
getRenderableFileURL();
}
}, [currentFile]);
return (
<>
<SlideshowContext.Provider value={{ showNextSlide }}>
<Theatre
file1={{
fileName: currentFile?.metadata.title,
fileURL: renderableFileURL,
type: currentFile?.metadata.fileType,
}}
file2={{
fileName: nextFile?.metadata.title,
fileURL: renderableFileURL,
type: nextFile?.metadata.fileType,
}}
/>
</SlideshowContext.Provider>
{loading && <PairedSuccessfullyOverlay />}
</>
);
}

View file

@ -0,0 +1,32 @@
export enum MS_KEYS {
OPT_OUT_OF_CRASH_REPORTS = 'optOutOfCrashReports',
SRP_CONFIGURE_IN_PROGRESS = 'srpConfigureInProgress',
REDIRECT_URL = 'redirectUrl',
}
type StoreType = Map<Partial<MS_KEYS>, any>;
class InMemoryStore {
private store: StoreType = new Map();
get(key: MS_KEYS) {
return this.store.get(key);
}
set(key: MS_KEYS, value: any) {
this.store.set(key, value);
}
delete(key: MS_KEYS) {
this.store.delete(key);
}
has(key: MS_KEYS) {
return this.store.has(key);
}
clear() {
this.store.clear();
}
}
export default new InMemoryStore();

View file

@ -0,0 +1,41 @@
import { LimitedCacheStorage } from 'types/cache/index';
// import { ElectronCacheStorage } from 'services/electron/cache';
// import { runningInElectron, runningInWorker } from 'utils/common';
// import { WorkerElectronCacheStorageService } from 'services/workerElectronCache/service';
class cacheStorageFactory {
// workerElectronCacheStorageServiceInstance: WorkerElectronCacheStorageService;
getCacheStorage(): LimitedCacheStorage {
// if (runningInElectron()) {
// if (runningInWorker()) {
// if (!this.workerElectronCacheStorageServiceInstance) {
// // this.workerElectronCacheStorageServiceInstance =
// // new WorkerElectronCacheStorageService();
// }
// return this.workerElectronCacheStorageServiceInstance;
// } else {
// // return ElectronCacheStorage;
// }
// } else {
return transformBrowserCacheStorageToLimitedCacheStorage(caches);
// }
}
}
export const CacheStorageFactory = new cacheStorageFactory();
function transformBrowserCacheStorageToLimitedCacheStorage(
caches: CacheStorage
): LimitedCacheStorage {
return {
async open(cacheName) {
const cache = await caches.open(cacheName);
return {
match: cache.match.bind(cache),
put: cache.put.bind(cache),
delete: cache.delete.bind(cache),
};
},
delete: caches.delete.bind(caches),
};
}

View file

@ -0,0 +1,33 @@
import { logError } from '@ente/shared/sentry';
import { CacheStorageFactory } from './cacheStorageFactory';
const SecurityError = 'SecurityError';
const INSECURE_OPERATION = 'The operation is insecure.';
async function openCache(cacheName: string) {
try {
return await CacheStorageFactory.getCacheStorage().open(cacheName);
} catch (e) {
// ignoring insecure operation error, as it is thrown in incognito mode in firefox
if (e.name === SecurityError && e.message === INSECURE_OPERATION) {
// no-op
} else {
// log and ignore, we don't want to break the caller flow, when cache is not available
logError(e, 'openCache failed');
}
}
}
async function deleteCache(cacheName: string) {
try {
return await CacheStorageFactory.getCacheStorage().delete(cacheName);
} catch (e) {
// ignoring insecure operation error, as it is thrown in incognito mode in firefox
if (e.name === SecurityError && e.message === INSECURE_OPERATION) {
// no-op
} else {
// log and ignore, we don't want to break the caller flow, when cache is not available
logError(e, 'deleteCache failed');
}
}
}
export const CacheStorageService = { open: openCache, delete: deleteCache };

View file

@ -0,0 +1,305 @@
import { getEndpoint } from '@ente/shared/network/api';
import localForage from '@ente/shared/storage/localForage';
import HTTPService from '@ente/shared/network/HTTPService';
import { logError } from '@ente/shared/sentry';
import { CustomError, parseSharingErrorCodes } from '@ente/shared/error';
import ComlinkCryptoWorker from '@ente/shared/crypto';
import { Collection, CollectionPublicMagicMetadata } from 'types/collection';
import { EncryptedEnteFile, EnteFile } from 'types/file';
import { decryptFile, mergeMetadata, sortFiles } from 'utils/file';
export interface SavedCollectionFiles {
collectionLocalID: string;
files: EnteFile[];
}
const ENDPOINT = getEndpoint();
const COLLECTION_FILES_TABLE = 'collection-files';
const COLLECTIONS_TABLE = 'collections';
const getLastSyncKey = (collectionUID: string) => `${collectionUID}-time`;
export const getLocalFiles = async (
collectionUID: string
): Promise<EnteFile[]> => {
const localSavedcollectionFiles =
(await localForage.getItem<SavedCollectionFiles[]>(
COLLECTION_FILES_TABLE
)) || [];
const matchedCollection = localSavedcollectionFiles.find(
(item) => item.collectionLocalID === collectionUID
);
return matchedCollection?.files || [];
};
const savecollectionFiles = async (
collectionUID: string,
files: EnteFile[]
) => {
const collectionFiles =
(await localForage.getItem<SavedCollectionFiles[]>(
COLLECTION_FILES_TABLE
)) || [];
await localForage.setItem(
COLLECTION_FILES_TABLE,
dedupeCollectionFiles([
{ collectionLocalID: collectionUID, files },
...collectionFiles,
])
);
};
export const getLocalCollections = async (collectionKey: string) => {
const localCollections =
(await localForage.getItem<Collection[]>(COLLECTIONS_TABLE)) || [];
const collection =
localCollections.find(
(localSavedPublicCollection) =>
localSavedPublicCollection.key === collectionKey
) || null;
return collection;
};
const saveCollection = async (collection: Collection) => {
const collections =
(await localForage.getItem<Collection[]>(COLLECTIONS_TABLE)) ?? [];
await localForage.setItem(
COLLECTIONS_TABLE,
dedupeCollections([collection, ...collections])
);
};
const dedupeCollections = (collections: Collection[]) => {
const keySet = new Set([]);
return collections.filter((collection) => {
if (!keySet.has(collection.key)) {
keySet.add(collection.key);
return true;
} else {
return false;
}
});
};
const dedupeCollectionFiles = (collectionFiles: SavedCollectionFiles[]) => {
const keySet = new Set([]);
return collectionFiles.filter(({ collectionLocalID: collectionUID }) => {
if (!keySet.has(collectionUID)) {
keySet.add(collectionUID);
return true;
} else {
return false;
}
});
};
async function getSyncTime(collectionUID: string): Promise<number> {
const lastSyncKey = getLastSyncKey(collectionUID);
const lastSyncTime = await localForage.getItem<number>(lastSyncKey);
return lastSyncTime ?? 0;
}
const updateSyncTime = async (collectionUID: string, time: number) =>
await localForage.setItem(getLastSyncKey(collectionUID), time);
export const syncPublicFiles = async (
token: string,
collection: Collection,
setPublicFiles: (files: EnteFile[]) => void
) => {
try {
let files: EnteFile[] = [];
const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false;
const collectionUID = String(collection.id);
const localFiles = await getLocalFiles(collectionUID);
files = [...files, ...localFiles];
try {
const lastSyncTime = await getSyncTime(collectionUID);
if (collection.updationTime === lastSyncTime) {
return sortFiles(files, sortAsc);
}
const fetchedFiles = await fetchFiles(
token,
collection,
lastSyncTime,
files,
setPublicFiles
);
files = [...files, ...fetchedFiles];
const latestVersionFiles = new Map<string, EnteFile>();
files.forEach((file) => {
const uid = `${file.collectionID}-${file.id}`;
if (
!latestVersionFiles.has(uid) ||
latestVersionFiles.get(uid).updationTime < file.updationTime
) {
latestVersionFiles.set(uid, file);
}
});
files = [];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, file] of latestVersionFiles) {
if (file.isDeleted) {
continue;
}
files.push(file);
}
await savecollectionFiles(collectionUID, files);
await updateSyncTime(collectionUID, collection.updationTime);
setPublicFiles([...sortFiles(mergeMetadata(files), sortAsc)]);
} catch (e) {
const parsedError = parseSharingErrorCodes(e);
logError(e, 'failed to sync shared collection files');
if (parsedError.message === CustomError.TOKEN_EXPIRED) {
throw e;
}
}
return [...sortFiles(mergeMetadata(files), sortAsc)];
} catch (e) {
logError(e, 'failed to get local or sync shared collection files');
throw e;
}
};
const fetchFiles = async (
castToken: string,
collection: Collection,
sinceTime: number,
files: EnteFile[],
setPublicFiles: (files: EnteFile[]) => void
): Promise<EnteFile[]> => {
try {
let decryptedFiles: EnteFile[] = [];
let time = sinceTime;
let resp;
const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false;
do {
if (!castToken) {
break;
}
resp = await HTTPService.get(
`${ENDPOINT}/cast/diff`,
{
sinceTime: time,
},
{
'Cache-Control': 'no-cache',
'X-Cast-Access-Token': castToken,
}
);
decryptedFiles = [
...decryptedFiles,
...(await Promise.all(
resp.data.diff.map(async (file: EncryptedEnteFile) => {
if (!file.isDeleted) {
return await decryptFile(file, collection.key);
} else {
return file;
}
}) as Promise<EnteFile>[]
)),
];
if (resp.data.diff.length) {
time = resp.data.diff.slice(-1)[0].updationTime;
}
setPublicFiles(
sortFiles(
mergeMetadata(
[...(files || []), ...decryptedFiles].filter(
(item) => !item.isDeleted
)
),
sortAsc
)
);
} while (resp.data.hasMore);
return decryptedFiles;
} catch (e) {
logError(e, 'Get cast files failed');
throw e;
}
};
export const getCastCollection = async (
castToken: string,
collectionKey: string
): Promise<Collection> => {
try {
const resp = await HTTPService.get(`${ENDPOINT}/cast/info`, null, {
'Cache-Control': 'no-cache',
'X-Cast-Access-Token': castToken,
});
const fetchedCollection = resp.data.collection;
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const collectionName = (fetchedCollection.name =
fetchedCollection.name ||
(await cryptoWorker.decryptToUTF8(
fetchedCollection.encryptedName,
fetchedCollection.nameDecryptionNonce,
collectionKey
)));
let collectionPublicMagicMetadata: CollectionPublicMagicMetadata;
if (fetchedCollection.pubMagicMetadata?.data) {
collectionPublicMagicMetadata = {
...fetchedCollection.pubMagicMetadata,
data: await cryptoWorker.decryptMetadata(
fetchedCollection.pubMagicMetadata.data,
fetchedCollection.pubMagicMetadata.header,
collectionKey
),
};
}
const collection = {
...fetchedCollection,
name: collectionName,
key: collectionKey,
pubMagicMetadata: collectionPublicMagicMetadata,
};
await saveCollection(collection);
return collection;
} catch (e) {
logError(e, 'failed to get cast collection');
throw e;
}
};
export const removeCollection = async (
collectionUID: string,
collectionKey: string
) => {
const collections =
(await localForage.getItem<Collection[]>(COLLECTIONS_TABLE)) || [];
await localForage.setItem(
COLLECTIONS_TABLE,
collections.filter((collection) => collection.key !== collectionKey)
);
await removeCollectionFiles(collectionUID);
};
export const removeCollectionFiles = async (collectionUID: string) => {
await localForage.removeItem(getLastSyncKey(collectionUID));
const collectionFiles =
(await localForage.getItem<SavedCollectionFiles[]>(
COLLECTION_FILES_TABLE
)) ?? [];
await localForage.setItem(
COLLECTION_FILES_TABLE,
collectionFiles.filter(
(collectionFiles) =>
collectionFiles.collectionLocalID !== collectionUID
)
);
};
export const storeCastData = (payloadObj: Object) => {
// iterate through all the keys in the payload object and set them in localStorage.
for (const key in payloadObj) {
window.localStorage.setItem(key, payloadObj[key]);
}
};

View file

@ -0,0 +1,273 @@
import {
generateStreamFromArrayBuffer,
getRenderableFileURL,
createTypedObjectURL,
} from 'utils/file';
import { EnteFile } from 'types/file';
import { FILE_TYPE } from 'constants/file';
import { CustomError } from '@ente/shared/error';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import { CACHES } from 'constants/cache';
import { CacheStorageService } from './cache/cacheStorageService';
import { LimitedCache } from 'types/cache';
import { getCastFileURL, getCastThumbnailURL } from '@ente/shared/network/api';
import HTTPService from '@ente/shared/network/HTTPService';
import { logError } from '@ente/shared/sentry';
class CastDownloadManager {
private fileObjectURLPromise = new Map<
string,
Promise<{ original: string[]; converted: string[] }>
>();
private thumbnailObjectURLPromise = new Map<number, Promise<string>>();
private fileDownloadProgress = new Map<number, number>();
private progressUpdater: (value: Map<number, number>) => void;
setProgressUpdater(progressUpdater: (value: Map<number, number>) => void) {
this.progressUpdater = progressUpdater;
}
private async getThumbnailCache() {
try {
const thumbnailCache = await CacheStorageService.open(
CACHES.THUMBS
);
return thumbnailCache;
} catch (e) {
return null;
// ignore
}
}
public async getCachedThumbnail(
file: EnteFile,
thumbnailCache?: LimitedCache
) {
try {
if (!thumbnailCache) {
thumbnailCache = await this.getThumbnailCache();
}
const cacheResp: Response = await thumbnailCache?.match(
file.id.toString()
);
if (cacheResp) {
return URL.createObjectURL(await cacheResp.blob());
}
return null;
} catch (e) {
logError(e, 'failed to get cached thumbnail');
throw e;
}
}
public async getThumbnail(file: EnteFile, castToken: string) {
try {
if (!this.thumbnailObjectURLPromise.has(file.id)) {
const downloadPromise = async () => {
const thumbnailCache = await this.getThumbnailCache();
const cachedThumb = await this.getCachedThumbnail(
file,
thumbnailCache
);
if (cachedThumb) {
return cachedThumb;
}
const thumb = await this.downloadThumb(castToken, file);
const thumbBlob = new Blob([thumb]);
try {
await thumbnailCache?.put(
file.id.toString(),
new Response(thumbBlob)
);
} catch (e) {
// TODO: handle storage full exception.
}
return URL.createObjectURL(thumbBlob);
};
this.thumbnailObjectURLPromise.set(file.id, downloadPromise());
}
return await this.thumbnailObjectURLPromise.get(file.id);
} catch (e) {
this.thumbnailObjectURLPromise.delete(file.id);
logError(e, 'get castDownloadManager preview Failed');
throw e;
}
}
private downloadThumb = async (castToken: string, file: EnteFile) => {
const resp = await HTTPService.get(
getCastThumbnailURL(file.id),
null,
{
'X-Cast-Access-Token': castToken,
},
{ responseType: 'arraybuffer' }
);
if (typeof resp.data === 'undefined') {
throw Error(CustomError.REQUEST_FAILED);
}
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const decrypted = await cryptoWorker.decryptThumbnail(
new Uint8Array(resp.data),
await cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
file.key
);
return decrypted;
};
getFile = async (file: EnteFile, castToken: string, forPreview = false) => {
const fileKey = forPreview ? `${file.id}_preview` : `${file.id}`;
try {
const getFilePromise = async () => {
const fileStream = await this.downloadFile(castToken, file);
const fileBlob = await new Response(fileStream).blob();
if (forPreview) {
return await getRenderableFileURL(file, fileBlob);
} else {
const fileURL = await createTypedObjectURL(
fileBlob,
file.metadata.title
);
return { converted: [fileURL], original: [fileURL] };
}
};
if (!this.fileObjectURLPromise.get(fileKey)) {
this.fileObjectURLPromise.set(fileKey, getFilePromise());
}
const fileURLs = await this.fileObjectURLPromise.get(fileKey);
return fileURLs;
} catch (e) {
this.fileObjectURLPromise.delete(fileKey);
logError(e, 'castDownloadManager failed to get file');
throw e;
}
};
public async getCachedOriginalFile(file: EnteFile) {
return await this.fileObjectURLPromise.get(file.id.toString());
}
async downloadFile(castToken: string, file: EnteFile) {
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const onDownloadProgress = this.trackDownloadProgress(file.id);
if (
file.metadata.fileType === FILE_TYPE.IMAGE ||
file.metadata.fileType === FILE_TYPE.LIVE_PHOTO
) {
const resp = await HTTPService.get(
getCastFileURL(file.id),
null,
{
'X-Cast-Access-Token': castToken,
},
{ responseType: 'arraybuffer' }
);
if (typeof resp.data === 'undefined') {
throw Error(CustomError.REQUEST_FAILED);
}
const decrypted = await cryptoWorker.decryptFile(
new Uint8Array(resp.data),
await cryptoWorker.fromB64(file.file.decryptionHeader),
file.key
);
return generateStreamFromArrayBuffer(decrypted);
}
const resp = await fetch(getCastFileURL(file.id), {
headers: {
'X-Cast-Access-Token': castToken,
},
});
const reader = resp.body.getReader();
const contentLength = +resp.headers.get('Content-Length');
let downloadedBytes = 0;
const stream = new ReadableStream({
async start(controller) {
const decryptionHeader = await cryptoWorker.fromB64(
file.file.decryptionHeader
);
const fileKey = await cryptoWorker.fromB64(file.key);
const { pullState, decryptionChunkSize } =
await cryptoWorker.initChunkDecryption(
decryptionHeader,
fileKey
);
let data = new Uint8Array();
// The following function handles each data chunk
function push() {
// "done" is a Boolean and value a "Uint8Array"
reader.read().then(async ({ done, value }) => {
// Is there more data to read?
if (!done) {
downloadedBytes += value.byteLength;
onDownloadProgress({
loaded: downloadedBytes,
total: contentLength,
});
const buffer = new Uint8Array(
data.byteLength + value.byteLength
);
buffer.set(new Uint8Array(data), 0);
buffer.set(new Uint8Array(value), data.byteLength);
if (buffer.length > decryptionChunkSize) {
const fileData = buffer.slice(
0,
decryptionChunkSize
);
const { decryptedData } =
await cryptoWorker.decryptFileChunk(
fileData,
pullState
);
controller.enqueue(decryptedData);
data = buffer.slice(decryptionChunkSize);
} else {
data = buffer;
}
push();
} else {
if (data) {
const { decryptedData } =
await cryptoWorker.decryptFileChunk(
data,
pullState
);
controller.enqueue(decryptedData);
data = null;
}
controller.close();
}
});
}
push();
},
});
return stream;
}
trackDownloadProgress = (fileID: number) => {
return (event: { loaded: number; total: number }) => {
if (event.loaded === event.total) {
this.fileDownloadProgress.delete(fileID);
} else {
this.fileDownloadProgress.set(
fileID,
Math.round((event.loaded * 100) / event.total)
);
}
this.progressUpdater(new Map(this.fileDownloadProgress));
};
};
}
export default new CastDownloadManager();

View file

@ -0,0 +1,12 @@
import { EventEmitter } from 'eventemitter3';
// When registering event handlers,
// handle errors to avoid unhandled rejection or propagation to emit call
export enum Events {
LOGOUT = 'logout',
FILE_UPLOADED = 'fileUploaded',
LOCAL_FILES_UPDATED = 'localFilesUpdated',
}
export const eventBus = new EventEmitter<Events>();

View file

@ -0,0 +1,29 @@
// import isElectron from 'is-electron';
// import { ElectronFFmpeg } from 'services/electron/ffmpeg';
import { ElectronFile } from 'types/upload';
import ComlinkFFmpegWorker from 'utils/comlink/ComlinkFFmpegWorker';
export interface IFFmpeg {
run: (
cmd: string[],
inputFile: File | ElectronFile,
outputFilename: string,
dontTimeout?: boolean
) => Promise<File | ElectronFile>;
}
class FFmpegFactory {
private client: IFFmpeg;
async getFFmpegClient() {
if (!this.client) {
// if (isElectron()) {
// this.client = new ElectronFFmpeg();
// } else {
this.client = await ComlinkFFmpegWorker.getInstance();
// }
}
return this.client;
}
}
export default new FFmpegFactory();

View file

@ -0,0 +1,30 @@
import {
FFMPEG_PLACEHOLDER,
INPUT_PATH_PLACEHOLDER,
OUTPUT_PATH_PLACEHOLDER,
} from 'constants/ffmpeg';
import { ElectronFile } from 'types/upload';
import ffmpegFactory from './ffmpegFactory';
import { logError } from '@ente/shared/sentry';
export async function convertToMP4(file: File | ElectronFile) {
try {
const ffmpegClient = await ffmpegFactory.getFFmpegClient();
return await ffmpegClient.run(
[
FFMPEG_PLACEHOLDER,
'-i',
INPUT_PATH_PLACEHOLDER,
'-preset',
'ultrafast',
OUTPUT_PATH_PLACEHOLDER,
],
file,
'output.mp4',
true
);
} catch (e) {
logError(e, 'ffmpeg convertToMP4 failed');
throw e;
}
}

View file

@ -0,0 +1,14 @@
import { logError } from '@ente/shared/sentry';
import WasmHEICConverterService from './wasmHeicConverter/wasmHEICConverterService';
class HeicConversionService {
async convert(heicFileData: Blob): Promise<Blob> {
try {
return await WasmHEICConverterService.convert(heicFileData);
} catch (e) {
logError(e, 'failed to convert heic file');
throw e;
}
}
}
export default new HeicConversionService();

View file

@ -0,0 +1,45 @@
import JSZip from 'jszip';
import { EnteFile } from 'types/file';
import {
getFileExtensionWithDot,
getFileNameWithoutExtension,
} from 'utils/file';
class LivePhoto {
image: Uint8Array;
video: Uint8Array;
imageNameTitle: string;
videoNameTitle: string;
}
export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => {
const originalName = getFileNameWithoutExtension(file.metadata.title);
const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
const livePhoto = new LivePhoto();
for (const zipFilename in zip.files) {
if (zipFilename.startsWith('image')) {
livePhoto.imageNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.image = await zip.files[zipFilename].async('uint8array');
} else if (zipFilename.startsWith('video')) {
livePhoto.videoNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.video = await zip.files[zipFilename].async('uint8array');
}
}
return livePhoto;
};
export const encodeLivePhoto = async (livePhoto: LivePhoto) => {
const zip = new JSZip();
zip.file(
'image' + getFileExtensionWithDot(livePhoto.imageNameTitle),
livePhoto.image
);
zip.file(
'video' + getFileExtensionWithDot(livePhoto.videoNameTitle),
livePhoto.video
);
return await zip.generateAsync({ type: 'uint8array' });
};

View file

@ -0,0 +1,86 @@
import { CustomError } from '@ente/shared/error';
interface RequestQueueItem {
request: (canceller?: RequestCanceller) => Promise<any>;
successCallback: (response: any) => void;
failureCallback: (error: Error) => void;
isCanceled: { status: boolean };
canceller: { exec: () => void };
}
export enum PROCESSING_STRATEGY {
FIFO,
LIFO,
}
export interface RequestCanceller {
exec: () => void;
}
export interface CancellationStatus {
status: boolean;
}
export default class QueueProcessor<T> {
private requestQueue: RequestQueueItem[] = [];
private requestInProcessing = 0;
constructor(
private maxParallelProcesses: number,
private processingStrategy = PROCESSING_STRATEGY.FIFO
) {}
public queueUpRequest(
request: (canceller?: RequestCanceller) => Promise<T>
) {
const isCanceled: CancellationStatus = { status: false };
const canceller: RequestCanceller = {
exec: () => {
isCanceled.status = true;
},
};
const promise = new Promise<T>((resolve, reject) => {
this.requestQueue.push({
request,
successCallback: resolve,
failureCallback: reject,
isCanceled,
canceller,
});
this.pollQueue();
});
return { promise, canceller };
}
private async pollQueue() {
if (this.requestInProcessing < this.maxParallelProcesses) {
this.requestInProcessing++;
this.processQueue();
}
}
private async processQueue() {
while (this.requestQueue.length > 0) {
const queueItem =
this.processingStrategy === PROCESSING_STRATEGY.LIFO
? this.requestQueue.pop()
: this.requestQueue.shift();
let response = null;
if (queueItem.isCanceled.status) {
queueItem.failureCallback(Error(CustomError.REQUEST_CANCELLED));
} else {
try {
response = await queueItem.request(queueItem.canceller);
queueItem.successCallback(response);
} catch (e) {
queueItem.failureCallback(e);
}
}
}
this.requestInProcessing--;
}
}

View file

@ -0,0 +1,93 @@
import { logError } from '@ente/shared/sentry';
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
import { ElectronFile } from 'types/upload';
export async function getUint8ArrayView(
file: Blob | ElectronFile
): Promise<Uint8Array> {
try {
return new Uint8Array(await file.arrayBuffer());
} catch (e) {
logError(e, 'reading file blob failed', {
fileSize: convertBytesToHumanReadable(file.size),
});
throw e;
}
}
export function getFileStream(file: File, chunkSize: number) {
const fileChunkReader = fileChunkReaderMaker(file, chunkSize);
const stream = new ReadableStream<Uint8Array>({
async pull(controller: ReadableStreamDefaultController) {
const chunk = await fileChunkReader.next();
if (chunk.done) {
controller.close();
} else {
controller.enqueue(chunk.value);
}
},
});
const chunkCount = Math.ceil(file.size / chunkSize);
return {
stream,
chunkCount,
};
}
export async function getElectronFileStream(
file: ElectronFile,
chunkSize: number
) {
const chunkCount = Math.ceil(file.size / chunkSize);
return {
stream: await file.stream(),
chunkCount,
};
}
async function* fileChunkReaderMaker(file: File, chunkSize: number) {
let offset = 0;
while (offset < file.size) {
const blob = file.slice(offset, chunkSize + offset);
const fileChunk = await getUint8ArrayView(blob);
yield fileChunk;
offset += chunkSize;
}
return null;
}
// depreciated
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function getUint8ArrayViewOld(
reader: FileReader,
file: Blob
): Promise<Uint8Array> {
return await new Promise((resolve, reject) => {
reader.onabort = () =>
reject(
Error(
`file reading was aborted, file size= ${convertBytesToHumanReadable(
file.size
)}`
)
);
reader.onerror = () =>
reject(
Error(
`file reading has failed, file size= ${convertBytesToHumanReadable(
file.size
)} , reason= ${reader.error}`
)
);
reader.onload = () => {
// Do whatever you want with the file contents
const result =
typeof reader.result === 'string'
? new TextEncoder().encode(reader.result)
: new Uint8Array(reader.result);
resolve(result);
};
reader.readAsArrayBuffer(file);
});
}

View file

@ -0,0 +1,108 @@
import { FILE_TYPE } from 'constants/file';
import { ElectronFile, FileTypeInfo } from 'types/upload';
import {
WHITELISTED_FILE_FORMATS,
KNOWN_NON_MEDIA_FORMATS,
} from 'constants/upload';
import { CustomError } from '@ente/shared/error';
import { getFileExtension } from 'utils/file';
import { logError } from '@ente/shared/sentry';
import { getUint8ArrayView } from './readerService';
import FileType, { FileTypeResult } from 'file-type';
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
function getFileSize(file: File | ElectronFile) {
return file.size;
}
const TYPE_VIDEO = 'video';
const TYPE_IMAGE = 'image';
const CHUNK_SIZE_FOR_TYPE_DETECTION = 4100;
export async function getFileType(
receivedFile: File | ElectronFile
): Promise<FileTypeInfo> {
try {
let fileType: FILE_TYPE;
let typeResult: FileTypeResult;
if (receivedFile instanceof File) {
typeResult = await extractFileType(receivedFile);
} else {
typeResult = await extractElectronFileType(receivedFile);
}
const mimTypeParts: string[] = typeResult.mime?.split('/');
if (mimTypeParts?.length !== 2) {
throw Error(CustomError.INVALID_MIME_TYPE(typeResult.mime));
}
switch (mimTypeParts[0]) {
case TYPE_IMAGE:
fileType = FILE_TYPE.IMAGE;
break;
case TYPE_VIDEO:
fileType = FILE_TYPE.VIDEO;
break;
default:
throw Error(CustomError.NON_MEDIA_FILE);
}
return {
fileType,
exactType: typeResult.ext,
mimeType: typeResult.mime,
};
} catch (e) {
const fileFormat = getFileExtension(receivedFile.name);
const fileSize = convertBytesToHumanReadable(getFileSize(receivedFile));
const whiteListedFormat = WHITELISTED_FILE_FORMATS.find(
(a) => a.exactType === fileFormat
);
if (whiteListedFormat) {
return whiteListedFormat;
}
if (KNOWN_NON_MEDIA_FORMATS.includes(fileFormat)) {
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
}
if (e.message === CustomError.NON_MEDIA_FILE) {
logError(e, 'unsupported file format', {
fileFormat,
fileSize,
});
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
}
logError(e, 'type detection failed', {
fileFormat,
fileSize,
});
throw Error(CustomError.TYPE_DETECTION_FAILED(fileFormat));
}
}
async function extractFileType(file: File) {
const fileBlobChunk = file.slice(0, CHUNK_SIZE_FOR_TYPE_DETECTION);
const fileDataChunk = await getUint8ArrayView(fileBlobChunk);
return getFileTypeFromBuffer(fileDataChunk);
}
async function extractElectronFileType(file: ElectronFile) {
const stream = await file.stream();
const reader = stream.getReader();
const { value: fileDataChunk } = await reader.read();
await reader.cancel();
return getFileTypeFromBuffer(fileDataChunk);
}
async function getFileTypeFromBuffer(buffer: Uint8Array) {
const result = await FileType.fromBuffer(buffer);
if (!result?.mime) {
let logableInfo = '';
try {
logableInfo = `result: ${JSON.stringify(result)}`;
} catch (e) {
logableInfo = 'failed to stringify result';
}
throw Error(`mimetype missing from file type result - ${logableInfo}`);
}
return result;
}

View file

@ -0,0 +1,116 @@
import { promiseWithTimeout } from '@ente/shared/promise';
import { createFFmpeg, FFmpeg } from 'ffmpeg-wasm';
import QueueProcessor from 'services/queueProcessor';
import { getUint8ArrayView } from 'services/readerService';
import { logError } from '@ente/shared/sentry';
import { generateTempName } from 'utils/temp';
import { addLogLine } from '@ente/shared/logging';
const INPUT_PATH_PLACEHOLDER = 'INPUT';
const FFMPEG_PLACEHOLDER = 'FFMPEG';
const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
const FFMPEG_EXECUTION_WAIT_TIME = 30 * 1000;
export class WasmFFmpeg {
private ffmpeg: FFmpeg;
private ready: Promise<void> = null;
private ffmpegTaskQueue = new QueueProcessor<File>(1);
constructor() {
this.ffmpeg = createFFmpeg({
corePath: '/js/ffmpeg/ffmpeg-core.js',
mt: false,
});
this.ready = this.init();
}
private async init() {
if (!this.ffmpeg.isLoaded()) {
await this.ffmpeg.load();
}
}
async run(
cmd: string[],
inputFile: File,
outputFileName: string,
dontTimeout = false
) {
const response = this.ffmpegTaskQueue.queueUpRequest(() => {
if (dontTimeout) {
return this.execute(cmd, inputFile, outputFileName);
} else {
return promiseWithTimeout<File>(
this.execute(cmd, inputFile, outputFileName),
FFMPEG_EXECUTION_WAIT_TIME
);
}
});
try {
return await response.promise;
} catch (e) {
logError(e, 'ffmpeg run failed');
throw e;
}
}
private async execute(
cmd: string[],
inputFile: File,
outputFileName: string
) {
let tempInputFilePath: string;
let tempOutputFilePath: string;
try {
await this.ready;
const extension = getFileExtension(inputFile.name);
const tempNameSuffix = extension ? `input.${extension}` : 'input';
tempInputFilePath = `${generateTempName(10, tempNameSuffix)}`;
this.ffmpeg.FS(
'writeFile',
tempInputFilePath,
await getUint8ArrayView(inputFile)
);
tempOutputFilePath = `${generateTempName(10, outputFileName)}`;
cmd = cmd.map((cmdPart) => {
if (cmdPart === FFMPEG_PLACEHOLDER) {
return '';
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return tempInputFilePath;
} else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
} else {
return cmdPart;
}
});
addLogLine(`${cmd}`);
await this.ffmpeg.run(...cmd);
return new File(
[this.ffmpeg.FS('readFile', tempOutputFilePath)],
outputFileName
);
} finally {
try {
this.ffmpeg.FS('unlink', tempInputFilePath);
} catch (e) {
logError(e, 'unlink input file failed');
}
try {
this.ffmpeg.FS('unlink', tempOutputFilePath);
} catch (e) {
logError(e, 'unlink output file failed');
}
}
}
}
function getFileExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf('.');
if (lastDotPosition === -1) return null;
else {
return filename.slice(lastDotPosition + 1);
}
}

View file

@ -0,0 +1,13 @@
import * as HeicConvert from 'heic-convert';
import { getUint8ArrayView } from 'services/readerService';
export async function convertHEIC(
fileBlob: Blob,
format: string
): Promise<Blob> {
const filedata = await getUint8ArrayView(fileBlob);
const result = await HeicConvert({ buffer: filedata, format });
const convertedFileData = new Uint8Array(result);
const convertedFileBlob = new Blob([convertedFileData]);
return convertedFileBlob;
}

View file

@ -0,0 +1,114 @@
import QueueProcessor from 'services/queueProcessor';
import { retryAsyncFunction } from 'utils/network';
import { DedicatedConvertWorker } from 'worker/convert.worker';
import { ComlinkWorker } from 'utils/comlink/comlinkWorker';
import { getDedicatedConvertWorker } from 'utils/comlink/ComlinkConvertWorker';
import { logError } from '@ente/shared/sentry';
import { addLogLine } from '@ente/shared/logging';
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
import { CustomError } from '@ente/shared/error';
const WORKER_POOL_SIZE = 2;
const MAX_CONVERSION_IN_PARALLEL = 1;
const WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS = [100, 100];
const WAIT_TIME_IN_MICROSECONDS = 30 * 1000;
const BREATH_TIME_IN_MICROSECONDS = 1000;
const CONVERT_FORMAT = 'JPEG';
class HEICConverter {
private convertProcessor = new QueueProcessor<Blob>(
MAX_CONVERSION_IN_PARALLEL
);
private workerPool: ComlinkWorker<typeof DedicatedConvertWorker>[] = [];
private ready: Promise<void>;
constructor() {
this.ready = this.init();
}
private async init() {
this.workerPool = [];
for (let i = 0; i < WORKER_POOL_SIZE; i++) {
this.workerPool.push(getDedicatedConvertWorker());
}
}
async convert(fileBlob: Blob): Promise<Blob> {
await this.ready;
const response = this.convertProcessor.queueUpRequest(() =>
retryAsyncFunction<Blob>(async () => {
const convertWorker = this.workerPool.shift();
const worker = await convertWorker.remote;
try {
const convertedHEIC = await new Promise<Blob>(
(resolve, reject) => {
const main = async () => {
try {
const timeout = setTimeout(() => {
reject(Error('wait time exceeded'));
}, WAIT_TIME_IN_MICROSECONDS);
const startTime = Date.now();
const convertedHEIC =
await worker.convertHEIC(
fileBlob,
CONVERT_FORMAT
);
addLogLine(
`originalFileSize:${convertBytesToHumanReadable(
fileBlob?.size
)},convertedFileSize:${convertBytesToHumanReadable(
convertedHEIC?.size
)}, heic conversion time: ${
Date.now() - startTime
}ms `
);
clearTimeout(timeout);
resolve(convertedHEIC);
} catch (e) {
reject(e);
}
};
main();
}
);
if (!convertedHEIC || convertedHEIC?.size === 0) {
logError(
Error(`converted heic fileSize is Zero`),
'converted heic fileSize is Zero',
{
originalFileSize: convertBytesToHumanReadable(
fileBlob?.size ?? 0
),
convertedFileSize: convertBytesToHumanReadable(
convertedHEIC?.size ?? 0
),
}
);
}
await new Promise((resolve) => {
setTimeout(
() => resolve(null),
BREATH_TIME_IN_MICROSECONDS
);
});
this.workerPool.push(convertWorker);
return convertedHEIC;
} catch (e) {
logError(e, 'heic conversion failed');
convertWorker.terminate();
this.workerPool.push(getDedicatedConvertWorker());
throw e;
}
}, WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS)
);
try {
return await response.promise;
} catch (e) {
if (e.message === CustomError.REQUEST_CANCELLED) {
// ignore
return null;
}
throw e;
}
}
}
export default new HEICConverter();

View file

@ -0,0 +1,3 @@
#__next {
height: 100%;
}

20
apps/cast/src/types/cache/index.ts vendored Normal file
View file

@ -0,0 +1,20 @@
export interface LimitedCacheStorage {
open: (cacheName: string) => Promise<LimitedCache>;
delete: (cacheName: string) => Promise<boolean>;
}
export interface LimitedCache {
match: (key: string) => Promise<Response>;
put: (key: string, data: Response) => Promise<void>;
delete: (key: string) => Promise<boolean>;
}
export interface ProxiedLimitedCacheStorage {
open: (cacheName: string) => Promise<ProxiedWorkerLimitedCache>;
delete: (cacheName: string) => Promise<boolean>;
}
export interface ProxiedWorkerLimitedCache {
match: (key: string) => Promise<ArrayBuffer>;
put: (key: string, data: ArrayBuffer) => Promise<void>;
delete: (key: string) => Promise<boolean>;
}

View file

@ -0,0 +1,5 @@
export interface CastPayload {
collectionID: number;
collectionKey: string;
castToken: string;
}

View file

@ -0,0 +1,159 @@
import { EnteFile } from 'types/file';
import { CollectionSummaryType, CollectionType } from 'constants/collection';
import {
EncryptedMagicMetadata,
MagicMetadataCore,
SUB_TYPE,
VISIBILITY_STATE,
} from 'types/magicMetadata';
export enum COLLECTION_ROLE {
VIEWER = 'VIEWER',
OWNER = 'OWNER',
COLLABORATOR = 'COLLABORATOR',
UNKNOWN = 'UNKNOWN',
}
export interface CollectionUser {
id: number;
email: string;
role: COLLECTION_ROLE;
}
export interface EncryptedCollection {
id: number;
owner: CollectionUser;
// collection name was unencrypted in the past, so we need to keep it as optional
name?: string;
encryptedKey: string;
keyDecryptionNonce: string;
encryptedName: string;
nameDecryptionNonce: string;
type: CollectionType;
attributes: collectionAttributes;
sharees: CollectionUser[];
publicURLs?: PublicURL[];
updationTime: number;
isDeleted: boolean;
magicMetadata: EncryptedMagicMetadata;
pubMagicMetadata: EncryptedMagicMetadata;
sharedMagicMetadata: EncryptedMagicMetadata;
}
export interface Collection
extends Omit<
EncryptedCollection,
| 'encryptedKey'
| 'keyDecryptionNonce'
| 'encryptedName'
| 'nameDecryptionNonce'
| 'magicMetadata'
| 'pubMagicMetadata'
| 'sharedMagicMetadata'
> {
key: string;
name: string;
magicMetadata: CollectionMagicMetadata;
pubMagicMetadata: CollectionPublicMagicMetadata;
sharedMagicMetadata: CollectionShareeMagicMetadata;
}
// define a method on Collection interface to return the sync key as collection.id-time
// this is used to store the last sync time of a collection in local storage
export interface PublicURL {
url: string;
deviceLimit: number;
validTill: number;
enableDownload: boolean;
enableCollect: boolean;
passwordEnabled: boolean;
nonce?: string;
opsLimit?: number;
memLimit?: number;
}
export interface UpdatePublicURL {
collectionID: number;
disablePassword?: boolean;
enableDownload?: boolean;
enableCollect?: boolean;
validTill?: number;
deviceLimit?: number;
passHash?: string;
nonce?: string;
opsLimit?: number;
memLimit?: number;
}
export interface CreatePublicAccessTokenRequest {
collectionID: number;
validTill?: number;
deviceLimit?: number;
}
export interface EncryptedFileKey {
id: number;
encryptedKey: string;
keyDecryptionNonce: string;
}
export interface AddToCollectionRequest {
collectionID: number;
files: EncryptedFileKey[];
}
export interface MoveToCollectionRequest {
fromCollectionID: number;
toCollectionID: number;
files: EncryptedFileKey[];
}
export interface collectionAttributes {
encryptedPath?: string;
pathDecryptionNonce?: string;
}
export type CollectionToFileMap = Map<number, EnteFile>;
export interface RemoveFromCollectionRequest {
collectionID: number;
fileIDs: number[];
}
export interface CollectionMagicMetadataProps {
visibility?: VISIBILITY_STATE;
subType?: SUB_TYPE;
order?: number;
}
export type CollectionMagicMetadata =
MagicMetadataCore<CollectionMagicMetadataProps>;
export interface CollectionShareeMetadataProps {
visibility?: VISIBILITY_STATE;
}
export type CollectionShareeMagicMetadata =
MagicMetadataCore<CollectionShareeMetadataProps>;
export interface CollectionPublicMagicMetadataProps {
asc?: boolean;
coverID?: number;
}
export type CollectionPublicMagicMetadata =
MagicMetadataCore<CollectionPublicMagicMetadataProps>;
export interface CollectionSummary {
id: number;
name: string;
type: CollectionSummaryType;
coverFile: EnteFile;
latestFile: EnteFile;
fileCount: number;
updationTime: number;
order?: number;
}
export type CollectionSummaries = Map<number, CollectionSummary>;
export type CollectionFilesCount = Map<number, number>;

View file

@ -0,0 +1,103 @@
import {
EncryptedMagicMetadata,
MagicMetadataCore,
VISIBILITY_STATE,
} from 'types/magicMetadata';
import { Metadata } from 'types/upload';
export interface MetadataFileAttributes {
encryptedData: string;
decryptionHeader: string;
}
export interface S3FileAttributes {
objectKey: string;
decryptionHeader: string;
}
export interface FileInfo {
fileSize: number;
thumbSize: number;
}
export interface EncryptedEnteFile {
id: number;
collectionID: number;
ownerID: number;
file: S3FileAttributes;
thumbnail: S3FileAttributes;
metadata: MetadataFileAttributes;
info: FileInfo;
magicMetadata: EncryptedMagicMetadata;
pubMagicMetadata: EncryptedMagicMetadata;
encryptedKey: string;
keyDecryptionNonce: string;
isDeleted: boolean;
updationTime: number;
}
export interface EnteFile
extends Omit<
EncryptedEnteFile,
| 'metadata'
| 'pubMagicMetadata'
| 'magicMetadata'
| 'encryptedKey'
| 'keyDecryptionNonce'
> {
metadata: Metadata;
magicMetadata: FileMagicMetadata;
pubMagicMetadata: FilePublicMagicMetadata;
isTrashed?: boolean;
key: string;
src?: string;
msrc?: string;
html?: string;
w?: number;
h?: number;
title?: string;
deleteBy?: number;
isSourceLoaded?: boolean;
originalVideoURL?: string;
originalImageURL?: string;
dataIndex?: number;
conversionFailed?: boolean;
isConverted?: boolean;
}
export interface TrashRequest {
items: TrashRequestItems[];
}
export interface TrashRequestItems {
fileID: number;
collectionID: number;
}
export interface FileWithUpdatedMagicMetadata {
file: EnteFile;
updatedMagicMetadata: FileMagicMetadata;
}
export interface FileWithUpdatedPublicMagicMetadata {
file: EnteFile;
updatedPublicMagicMetadata: FilePublicMagicMetadata;
}
export interface FileMagicMetadataProps {
visibility?: VISIBILITY_STATE;
filePaths?: string[];
}
export type FileMagicMetadata = MagicMetadataCore<FileMagicMetadataProps>;
export interface FilePublicMagicMetadataProps {
editedTime?: number;
editedName?: string;
caption?: string;
uploaderName?: string;
w?: number;
h?: number;
}
export type FilePublicMagicMetadata =
MagicMetadataCore<FilePublicMagicMetadataProps>;

View file

@ -0,0 +1,57 @@
// import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
// import { CollectionSelectorAttributes } from 'components/Collections/CollectionSelector';
// import { TimeStampListItem } from 'components/PhotoList';
import { User } from '@ente/shared/user/types';
import { Collection } from 'types/collection';
import { EnteFile } from 'types/file';
export type SelectedState = {
[k: number]: boolean;
ownCount: number;
count: number;
collectionID: number;
};
export type SetFiles = React.Dispatch<React.SetStateAction<EnteFile[]>>;
export type SetCollections = React.Dispatch<React.SetStateAction<Collection[]>>;
export type SetLoading = React.Dispatch<React.SetStateAction<boolean>>;
// export type SetCollectionSelectorAttributes = React.Dispatch<
// React.SetStateAction<CollectionSelectorAttributes>
// >;
// export type SetCollectionDownloadProgressAttributes = React.Dispatch<
// React.SetStateAction<CollectionDownloadProgressAttributes>
// >;
export type MergedSourceURL = {
original: string;
converted: string;
};
export enum UploadTypeSelectorIntent {
normalUpload,
import,
collectPhotos,
}
export type GalleryContextType = {
thumbs: Map<number, string>;
files: Map<number, MergedSourceURL>;
showPlanSelectorModal: () => void;
setActiveCollectionID: (collectionID: number) => void;
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
setBlockingLoad: (value: boolean) => void;
setIsInSearchMode: (value: boolean) => void;
// photoListHeader: TimeStampListItem;
openExportModal: () => void;
authenticateUser: (callback: () => void) => void;
user: User;
userIDToEmailMap: Map<number, string>;
emailList: string[];
openHiddenSection: (callback?: () => void) => void;
isClipSearchResult: boolean;
};
export enum CollectionSelectorIntent {
upload,
add,
move,
restore,
unhide,
}

View file

@ -0,0 +1,29 @@
export interface MagicMetadataCore<T> {
version: number;
count: number;
header: string;
data: T;
}
export type EncryptedMagicMetadata = MagicMetadataCore<string>;
export enum VISIBILITY_STATE {
VISIBLE = 0,
ARCHIVED = 1,
HIDDEN = 2,
}
export enum SUB_TYPE {
DEFAULT = 0,
DEFAULT_HIDDEN = 1,
QUICK_LINK_COLLECTION = 2,
}
export interface BulkUpdateMagicMetadataRequest {
metadataList: UpdateMagicMetadataRequest[];
}
export interface UpdateMagicMetadataRequest {
id: number;
magicMetadata: EncryptedMagicMetadata;
}

View file

@ -0,0 +1,170 @@
import {
B64EncryptionResult,
LocalFileAttributes,
} from '@ente/shared/crypto/types';
import { FILE_TYPE } from 'constants/file';
import { Collection } from 'types/collection';
import {
MetadataFileAttributes,
S3FileAttributes,
FilePublicMagicMetadata,
FilePublicMagicMetadataProps,
} from 'types/file';
import { EncryptedMagicMetadata } from 'types/magicMetadata';
export interface DataStream {
stream: ReadableStream<Uint8Array>;
chunkCount: number;
}
export function isDataStream(object: any): object is DataStream {
return 'stream' in object;
}
export type Logger = (message: string) => void;
export interface Metadata {
title: string;
creationTime: number;
modificationTime: number;
latitude: number;
longitude: number;
fileType: FILE_TYPE;
hasStaticThumbnail?: boolean;
hash?: string;
imageHash?: string;
videoHash?: string;
localID?: number;
version?: number;
deviceFolder?: string;
}
export interface Location {
latitude: number;
longitude: number;
}
export interface ParsedMetadataJSON {
creationTime: number;
modificationTime: number;
latitude: number;
longitude: number;
}
export interface MultipartUploadURLs {
objectKey: string;
partURLs: string[];
completeURL: string;
}
export interface FileTypeInfo {
fileType: FILE_TYPE;
exactType: string;
mimeType?: string;
imageType?: string;
videoType?: string;
}
/*
* ElectronFile is a custom interface that is used to represent
* any file on disk as a File-like object in the Electron desktop app.
*
* This was added to support the auto-resuming of failed uploads
* which needed absolute paths to the files which the
* normal File interface does not provide.
*/
export interface ElectronFile {
name: string;
path: string;
size: number;
lastModified: number;
stream: () => Promise<ReadableStream<Uint8Array>>;
blob: () => Promise<Blob>;
arrayBuffer: () => Promise<Uint8Array>;
}
export interface UploadAsset {
isLivePhoto?: boolean;
file?: File | ElectronFile;
livePhotoAssets?: LivePhotoAssets;
isElectron?: boolean;
}
export interface LivePhotoAssets {
image: globalThis.File | ElectronFile;
video: globalThis.File | ElectronFile;
}
export interface FileWithCollection extends UploadAsset {
localID: number;
collection?: Collection;
collectionID?: number;
}
export type ParsedMetadataJSONMap = Map<string, ParsedMetadataJSON>;
export interface UploadURL {
url: string;
objectKey: string;
}
export interface FileInMemory {
filedata: Uint8Array | DataStream;
thumbnail: Uint8Array;
hasStaticThumbnail: boolean;
}
export interface FileWithMetadata
extends Omit<FileInMemory, 'hasStaticThumbnail'> {
metadata: Metadata;
localID: number;
pubMagicMetadata: FilePublicMagicMetadata;
}
export interface EncryptedFile {
file: ProcessedFile;
fileKey: B64EncryptionResult;
}
export interface ProcessedFile {
file: LocalFileAttributes<Uint8Array | DataStream>;
thumbnail: LocalFileAttributes<Uint8Array>;
metadata: LocalFileAttributes<string>;
pubMagicMetadata: EncryptedMagicMetadata;
localID: number;
}
export interface BackupedFile {
file: S3FileAttributes;
thumbnail: S3FileAttributes;
metadata: MetadataFileAttributes;
pubMagicMetadata: EncryptedMagicMetadata;
}
export interface UploadFile extends BackupedFile {
collectionID: number;
encryptedKey: string;
keyDecryptionNonce: string;
}
export interface ParsedExtractedMetadata {
location: Location;
creationTime: number;
width: number;
height: number;
}
// This is used to prompt the user the make upload strategy choice
export interface ImportSuggestion {
rootFolderName: string;
hasNestedFolders: boolean;
hasRootLevelFileWithFolder: boolean;
}
export interface PublicUploadProps {
token: string;
passwordToken: string;
accessedThroughSharedURL: boolean;
}
export interface ExtractMetadataResult {
metadata: Metadata;
publicMagicMetadata: FilePublicMagicMetadataProps;
}

View file

@ -0,0 +1,43 @@
import { UPLOAD_RESULT, UPLOAD_STAGES } from 'constants/upload';
export type FileID = number;
export type FileName = string;
export type PercentageUploaded = number;
export type UploadFileNames = Map<FileID, FileName>;
export interface UploadCounter {
finished: number;
total: number;
}
export interface InProgressUpload {
localFileID: FileID;
progress: PercentageUploaded;
}
export interface FinishedUpload {
localFileID: FileID;
result: UPLOAD_RESULT;
}
export type InProgressUploads = Map<FileID, PercentageUploaded>;
export type FinishedUploads = Map<FileID, UPLOAD_RESULT>;
export type SegregatedFinishedUploads = Map<UPLOAD_RESULT, FileID[]>;
export interface ProgressUpdater {
setPercentComplete: React.Dispatch<React.SetStateAction<number>>;
setUploadCounter: React.Dispatch<React.SetStateAction<UploadCounter>>;
setUploadStage: React.Dispatch<React.SetStateAction<UPLOAD_STAGES>>;
setInProgressUploads: React.Dispatch<
React.SetStateAction<InProgressUpload[]>
>;
setFinishedUploads: React.Dispatch<
React.SetStateAction<SegregatedFinishedUploads>
>;
setUploadFilenames: React.Dispatch<React.SetStateAction<UploadFileNames>>;
setHasLivePhotos: React.Dispatch<React.SetStateAction<boolean>>;
setUploadProgressView: React.Dispatch<React.SetStateAction<boolean>>;
}

View file

@ -0,0 +1,147 @@
import { COLLECTION_ROLE, Collection } from 'types/collection';
import {
CollectionSummaryType,
CollectionType,
HIDE_FROM_COLLECTION_BAR_TYPES,
OPTIONS_NOT_HAVING_COLLECTION_TYPES,
} from 'constants/collection';
import { SUB_TYPE, VISIBILITY_STATE } from 'types/magicMetadata';
import { User } from '@ente/shared/user/types';
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
export enum COLLECTION_OPS_TYPE {
ADD,
MOVE,
REMOVE,
RESTORE,
UNHIDE,
}
export function getSelectedCollection(
collectionID: number,
collections: Collection[]
) {
return collections.find((collection) => collection.id === collectionID);
}
export const shouldShowOptions = (type: CollectionSummaryType) => {
return !OPTIONS_NOT_HAVING_COLLECTION_TYPES.has(type);
};
export const showEmptyTrashQuickOption = (type: CollectionSummaryType) => {
return type === CollectionSummaryType.trash;
};
export const showDownloadQuickOption = (type: CollectionSummaryType) => {
return (
type === CollectionSummaryType.folder ||
type === CollectionSummaryType.favorites ||
type === CollectionSummaryType.album ||
type === CollectionSummaryType.uncategorized ||
type === CollectionSummaryType.hiddenItems ||
type === CollectionSummaryType.incomingShareViewer ||
type === CollectionSummaryType.incomingShareCollaborator ||
type === CollectionSummaryType.outgoingShare ||
type === CollectionSummaryType.sharedOnlyViaLink ||
type === CollectionSummaryType.archived ||
type === CollectionSummaryType.pinned
);
};
export const showShareQuickOption = (type: CollectionSummaryType) => {
return (
type === CollectionSummaryType.folder ||
type === CollectionSummaryType.album ||
type === CollectionSummaryType.outgoingShare ||
type === CollectionSummaryType.sharedOnlyViaLink ||
type === CollectionSummaryType.archived ||
type === CollectionSummaryType.incomingShareViewer ||
type === CollectionSummaryType.incomingShareCollaborator ||
type === CollectionSummaryType.pinned
);
};
export const shouldBeShownOnCollectionBar = (type: CollectionSummaryType) => {
return !HIDE_FROM_COLLECTION_BAR_TYPES.has(type);
};
export const getUserOwnedCollections = (collections: Collection[]) => {
const user: User = getData(LS_KEYS.USER);
if (!user?.id) {
throw Error('user missing');
}
return collections.filter((collection) => collection.owner.id === user.id);
};
export const isDefaultHiddenCollection = (collection: Collection) =>
collection.magicMetadata?.data.subType === SUB_TYPE.DEFAULT_HIDDEN;
export const isHiddenCollection = (collection: Collection) =>
collection.magicMetadata?.data.visibility === VISIBILITY_STATE.HIDDEN;
export const isQuickLinkCollection = (collection: Collection) =>
collection.magicMetadata?.data.subType === SUB_TYPE.QUICK_LINK_COLLECTION;
export function isOutgoingShare(collection: Collection, user: User): boolean {
return collection.owner.id === user.id && collection.sharees?.length > 0;
}
export function isIncomingShare(collection: Collection, user: User) {
return collection.owner.id !== user.id;
}
export function isIncomingViewerShare(collection: Collection, user: User) {
const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
return sharee?.role === COLLECTION_ROLE.VIEWER;
}
export function isIncomingCollabShare(collection: Collection, user: User) {
const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
return sharee?.role === COLLECTION_ROLE.COLLABORATOR;
}
export function isSharedOnlyViaLink(collection: Collection) {
return collection.publicURLs?.length && !collection.sharees?.length;
}
export function isValidMoveTarget(
sourceCollectionID: number,
targetCollection: Collection,
user: User
) {
return (
sourceCollectionID !== targetCollection.id &&
!isHiddenCollection(targetCollection) &&
!isQuickLinkCollection(targetCollection) &&
!isIncomingShare(targetCollection, user)
);
}
export function isValidReplacementAlbum(
collection: Collection,
user: User,
wantedCollectionName: string
) {
return (
collection.name === wantedCollectionName &&
(collection.type === CollectionType.album ||
collection.type === CollectionType.folder) &&
!isHiddenCollection(collection) &&
!isQuickLinkCollection(collection) &&
!isIncomingShare(collection, user)
);
}
export function getCollectionNameMap(
collections: Collection[]
): Map<number, string> {
return new Map<number, string>(
collections.map((collection) => [collection.id, collection.name])
);
}
export function getNonHiddenCollections(
collections: Collection[]
): Collection[] {
return collections.filter((collection) => !isHiddenCollection(collection));
}
export function getHiddenCollections(collections: Collection[]): Collection[] {
return collections.filter((collection) => isHiddenCollection(collection));
}

View file

@ -0,0 +1,30 @@
import { Remote } from 'comlink';
import { DedicatedConvertWorker } from 'worker/convert.worker';
import { ComlinkWorker } from './comlinkWorker';
import { runningInBrowser } from '@ente/shared/platform';
class ComlinkConvertWorker {
private comlinkWorkerInstance: Remote<DedicatedConvertWorker>;
async getInstance() {
if (!this.comlinkWorkerInstance) {
this.comlinkWorkerInstance = await getDedicatedConvertWorker()
.remote;
}
return this.comlinkWorkerInstance;
}
}
export const getDedicatedConvertWorker = () => {
if (runningInBrowser()) {
const cryptoComlinkWorker = new ComlinkWorker<
typeof DedicatedConvertWorker
>(
'ente-convert-worker',
new Worker(new URL('worker/convert.worker.ts', import.meta.url))
);
return cryptoComlinkWorker;
}
};
export default new ComlinkConvertWorker();

View file

@ -0,0 +1,25 @@
import { Remote } from 'comlink';
import { DedicatedCryptoWorker } from 'worker/crypto.worker';
import { ComlinkWorker } from './comlinkWorker';
class ComlinkCryptoWorker {
private comlinkWorkerInstance: Promise<Remote<DedicatedCryptoWorker>>;
async getInstance() {
if (!this.comlinkWorkerInstance) {
const comlinkWorker = getDedicatedCryptoWorker();
this.comlinkWorkerInstance = comlinkWorker.remote;
}
return this.comlinkWorkerInstance;
}
}
export const getDedicatedCryptoWorker = () => {
const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedCryptoWorker>(
'ente-crypto-worker',
new Worker(new URL('worker/crypto.worker.ts', import.meta.url))
);
return cryptoComlinkWorker;
};
export default new ComlinkCryptoWorker();

View file

@ -0,0 +1,25 @@
import { Remote } from 'comlink';
import { DedicatedFFmpegWorker } from 'worker/ffmpeg.worker';
import { ComlinkWorker } from './comlinkWorker';
class ComlinkFFmpegWorker {
private comlinkWorkerInstance: Promise<Remote<DedicatedFFmpegWorker>>;
async getInstance() {
if (!this.comlinkWorkerInstance) {
const comlinkWorker = getDedicatedFFmpegWorker();
this.comlinkWorkerInstance = comlinkWorker.remote;
}
return this.comlinkWorkerInstance;
}
}
const getDedicatedFFmpegWorker = () => {
const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedFFmpegWorker>(
'ente-ffmpeg-worker',
new Worker(new URL('worker/ffmpeg.worker.ts', import.meta.url))
);
return cryptoComlinkWorker;
};
export default new ComlinkFFmpegWorker();

View file

@ -0,0 +1,27 @@
import { addLocalLog } from '@ente/shared/logging';
import { Remote, wrap } from 'comlink';
// import { WorkerElectronCacheStorageClient } from 'services/workerElectronCache/client';
export class ComlinkWorker<T extends new () => InstanceType<T>> {
public remote: Promise<Remote<InstanceType<T>>>;
private worker: Worker;
private name: string;
constructor(name: string, worker: Worker) {
this.name = name;
this.worker = worker;
this.worker.onerror = (errorEvent) => {
console.error('Got error event from worker', errorEvent);
};
addLocalLog(() => `Initiated ${this.name}`);
const comlink = wrap<T>(this.worker);
this.remote = new comlink() as Promise<Remote<InstanceType<T>>>;
// expose(WorkerElectronCacheStorageClient, this.worker);
}
public terminate() {
this.worker.terminate();
addLocalLog(() => `Terminated ${this.name}`);
}
}

View file

@ -0,0 +1,15 @@
export const readAsDataURL = (blob) =>
new Promise<string>((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => resolve(fileReader.result as string);
fileReader.onerror = () => reject(fileReader.error);
fileReader.readAsDataURL(blob);
});
export const readAsText = (blob) =>
new Promise<string>((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => resolve(fileReader.result as string);
fileReader.onerror = () => reject(fileReader.error);
fileReader.readAsText(blob);
});

View file

@ -0,0 +1,577 @@
import { SelectedState } from 'types/gallery';
import {
EnteFile,
EncryptedEnteFile,
FileMagicMetadata,
FilePublicMagicMetadata,
} from 'types/file';
import { decodeLivePhoto } from 'services/livePhotoService';
import { getFileType } from 'services/typeDetectionService';
import { logError } from '@ente/shared/sentry';
import {
TYPE_HEIC,
TYPE_HEIF,
FILE_TYPE,
SUPPORTED_RAW_FORMATS,
RAW_FORMATS,
} from 'constants/file';
import CastDownloadManager from 'services/castDownloadManager';
import heicConversionService from 'services/heicConversionService';
import * as ffmpegService from 'services/ffmpeg/ffmpegService';
import { isArchivedFile } from 'utils/magicMetadata';
import { CustomError } from '@ente/shared/error';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import isElectron from 'is-electron';
import { isPlaybackPossible } from 'utils/photoFrame';
import { FileTypeInfo } from 'types/upload';
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
import { User } from '@ente/shared/user/types';
import { addLogLine, addLocalLog } from '@ente/shared/logging';
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
export enum FILE_OPS_TYPE {
DOWNLOAD,
FIX_TIME,
ARCHIVE,
UNARCHIVE,
HIDE,
TRASH,
DELETE_PERMANENTLY,
}
export function groupFilesBasedOnCollectionID(files: EnteFile[]) {
const collectionWiseFiles = new Map<number, EnteFile[]>();
for (const file of files) {
if (!collectionWiseFiles.has(file.collectionID)) {
collectionWiseFiles.set(file.collectionID, []);
}
collectionWiseFiles.get(file.collectionID).push(file);
}
return collectionWiseFiles;
}
function getSelectedFileIds(selectedFiles: SelectedState) {
const filesIDs: number[] = [];
for (const [key, val] of Object.entries(selectedFiles)) {
if (typeof val === 'boolean' && val) {
filesIDs.push(Number(key));
}
}
return new Set(filesIDs);
}
export function getSelectedFiles(
selected: SelectedState,
files: EnteFile[]
): EnteFile[] {
const selectedFilesIDs = getSelectedFileIds(selected);
return files.filter((file) => selectedFilesIDs.has(file.id));
}
export function sortFiles(files: EnteFile[], sortAsc = false) {
// sort based on the time of creation time of the file,
// for files with same creation time, sort based on the time of last modification
const factor = sortAsc ? -1 : 1;
return files.sort((a, b) => {
if (a.metadata.creationTime === b.metadata.creationTime) {
return (
factor *
(b.metadata.modificationTime - a.metadata.modificationTime)
);
}
return factor * (b.metadata.creationTime - a.metadata.creationTime);
});
}
export function sortTrashFiles(files: EnteFile[]) {
return files.sort((a, b) => {
if (a.deleteBy === b.deleteBy) {
if (a.metadata.creationTime === b.metadata.creationTime) {
return (
b.metadata.modificationTime - a.metadata.modificationTime
);
}
return b.metadata.creationTime - a.metadata.creationTime;
}
return a.deleteBy - b.deleteBy;
});
}
export async function decryptFile(
file: EncryptedEnteFile,
collectionKey: string
): Promise<EnteFile> {
try {
const worker = await ComlinkCryptoWorker.getInstance();
const {
encryptedKey,
keyDecryptionNonce,
metadata,
magicMetadata,
pubMagicMetadata,
...restFileProps
} = file;
const fileKey = await worker.decryptB64(
encryptedKey,
keyDecryptionNonce,
collectionKey
);
const fileMetadata = await worker.decryptMetadata(
metadata.encryptedData,
metadata.decryptionHeader,
fileKey
);
let fileMagicMetadata: FileMagicMetadata;
let filePubMagicMetadata: FilePublicMagicMetadata;
if (magicMetadata?.data) {
fileMagicMetadata = {
...file.magicMetadata,
data: await worker.decryptMetadata(
magicMetadata.data,
magicMetadata.header,
fileKey
),
};
}
if (pubMagicMetadata?.data) {
filePubMagicMetadata = {
...pubMagicMetadata,
data: await worker.decryptMetadata(
pubMagicMetadata.data,
pubMagicMetadata.header,
fileKey
),
};
}
return {
...restFileProps,
key: fileKey,
metadata: fileMetadata,
magicMetadata: fileMagicMetadata,
pubMagicMetadata: filePubMagicMetadata,
};
} catch (e) {
logError(e, 'file decryption failed');
throw e;
}
}
export function getFileNameWithoutExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf('.');
if (lastDotPosition === -1) return filename;
else return filename.slice(0, lastDotPosition);
}
export function getFileExtensionWithDot(filename: string) {
const lastDotPosition = filename.lastIndexOf('.');
if (lastDotPosition === -1) return '';
else return filename.slice(lastDotPosition);
}
export function splitFilenameAndExtension(filename: string): [string, string] {
const lastDotPosition = filename.lastIndexOf('.');
if (lastDotPosition === -1) return [filename, null];
else
return [
filename.slice(0, lastDotPosition),
filename.slice(lastDotPosition + 1),
];
}
export function getFileExtension(filename: string) {
return splitFilenameAndExtension(filename)[1]?.toLocaleLowerCase();
}
export function generateStreamFromArrayBuffer(data: Uint8Array) {
return new ReadableStream({
async start(controller: ReadableStreamDefaultController) {
controller.enqueue(data);
controller.close();
},
});
}
export async function getRenderableFileURL(file: EnteFile, fileBlob: Blob) {
switch (file.metadata.fileType) {
case FILE_TYPE.IMAGE: {
const convertedBlob = await getRenderableImage(
file.metadata.title,
fileBlob
);
const { originalURL, convertedURL } = getFileObjectURLs(
fileBlob,
convertedBlob
);
return {
converted: [convertedURL],
original: [originalURL],
};
}
case FILE_TYPE.LIVE_PHOTO: {
return await getRenderableLivePhotoURL(file, fileBlob);
}
case FILE_TYPE.VIDEO: {
const convertedBlob = await getPlayableVideo(
file.metadata.title,
fileBlob
);
const { originalURL, convertedURL } = getFileObjectURLs(
fileBlob,
convertedBlob
);
return {
converted: [convertedURL],
original: [originalURL],
};
}
default: {
const previewURL = await createTypedObjectURL(
fileBlob,
file.metadata.title
);
return {
converted: [previewURL],
original: [previewURL],
};
}
}
}
async function getRenderableLivePhotoURL(
file: EnteFile,
fileBlob: Blob
): Promise<{ original: string[]; converted: string[] }> {
const livePhoto = await decodeLivePhoto(file, fileBlob);
const imageBlob = new Blob([livePhoto.image]);
const videoBlob = new Blob([livePhoto.video]);
const convertedImageBlob = await getRenderableImage(
livePhoto.imageNameTitle,
imageBlob
);
const convertedVideoBlob = await getPlayableVideo(
livePhoto.videoNameTitle,
videoBlob,
true
);
const { originalURL: originalImageURL, convertedURL: convertedImageURL } =
getFileObjectURLs(imageBlob, convertedImageBlob);
const { originalURL: originalVideoURL, convertedURL: convertedVideoURL } =
getFileObjectURLs(videoBlob, convertedVideoBlob);
return {
converted: [convertedImageURL, convertedVideoURL],
original: [originalImageURL, originalVideoURL],
};
}
export async function getPlayableVideo(
videoNameTitle: string,
videoBlob: Blob,
forceConvert = false
) {
try {
const isPlayable = await isPlaybackPossible(
URL.createObjectURL(videoBlob)
);
if (isPlayable && !forceConvert) {
return videoBlob;
} else {
if (!forceConvert && !isElectron()) {
return null;
}
addLogLine(
'video format not supported, converting it name:',
videoNameTitle
);
const mp4ConvertedVideo = await ffmpegService.convertToMP4(
new File([videoBlob], videoNameTitle)
);
addLogLine('video successfully converted', videoNameTitle);
return new Blob([await mp4ConvertedVideo.arrayBuffer()]);
}
} catch (e) {
addLogLine('video conversion failed', videoNameTitle);
logError(e, 'video conversion failed');
return null;
}
}
export async function getRenderableImage(fileName: string, imageBlob: Blob) {
let fileTypeInfo: FileTypeInfo;
try {
const tempFile = new File([imageBlob], fileName);
fileTypeInfo = await getFileType(tempFile);
addLocalLog(() => `file type info: ${JSON.stringify(fileTypeInfo)}`);
const { exactType } = fileTypeInfo;
let convertedImageBlob: Blob;
if (isRawFile(exactType)) {
try {
if (!isSupportedRawFormat(exactType)) {
throw Error(CustomError.UNSUPPORTED_RAW_FORMAT);
}
if (!isElectron()) {
throw Error(CustomError.NOT_AVAILABLE_ON_WEB);
}
addLogLine(
`RawConverter called for ${fileName}-${convertBytesToHumanReadable(
imageBlob.size
)}`
);
// convertedImageBlob = await imageProcessor.convertToJPEG(
// imageBlob,
// fileName
// );
addLogLine(`${fileName} successfully converted`);
} catch (e) {
try {
if (!isFileHEIC(exactType)) {
throw e;
}
addLogLine(
`HEICConverter called for ${fileName}-${convertBytesToHumanReadable(
imageBlob.size
)}`
);
convertedImageBlob = await heicConversionService.convert(
imageBlob
);
addLogLine(`${fileName} successfully converted`);
} catch (e) {
throw Error(CustomError.NON_PREVIEWABLE_FILE);
}
}
return convertedImageBlob;
} else {
return imageBlob;
}
} catch (e) {
logError(e, 'get Renderable Image failed', { fileTypeInfo });
return null;
}
}
export function isFileHEIC(exactType: string) {
return (
exactType.toLowerCase().endsWith(TYPE_HEIC) ||
exactType.toLowerCase().endsWith(TYPE_HEIF)
);
}
export function isRawFile(exactType: string) {
return RAW_FORMATS.includes(exactType.toLowerCase());
}
export function isRawFileFromFileName(fileName: string) {
for (const rawFormat of RAW_FORMATS) {
if (fileName.toLowerCase().endsWith(rawFormat)) {
return true;
}
}
return false;
}
export function isSupportedRawFormat(exactType: string) {
return SUPPORTED_RAW_FORMATS.includes(exactType.toLowerCase());
}
export function mergeMetadata(files: EnteFile[]): EnteFile[] {
return files.map((file) => {
if (file.pubMagicMetadata?.data.editedTime) {
file.metadata.creationTime = file.pubMagicMetadata.data.editedTime;
}
if (file.pubMagicMetadata?.data.editedName) {
file.metadata.title = file.pubMagicMetadata.data.editedName;
}
return file;
});
}
export async function getFileFromURL(fileURL: string) {
const fileBlob = await (await fetch(fileURL)).blob();
const fileFile = new File([fileBlob], 'temp');
return fileFile;
}
export function getUniqueFiles(files: EnteFile[]) {
const idSet = new Set<number>();
const uniqueFiles = files.filter((file) => {
if (!idSet.has(file.id)) {
idSet.add(file.id);
return true;
} else {
return false;
}
});
return uniqueFiles;
}
export const isImageOrVideo = (fileType: FILE_TYPE) =>
[FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType);
export const getArchivedFiles = (files: EnteFile[]) => {
return files.filter(isArchivedFile).map((file) => file.id);
};
export const createTypedObjectURL = async (blob: Blob, fileName: string) => {
const type = await getFileType(new File([blob], fileName));
return URL.createObjectURL(new Blob([blob], { type: type.mimeType }));
};
export const getUserOwnedFiles = (files: EnteFile[]) => {
const user: User = getData(LS_KEYS.USER);
if (!user?.id) {
throw Error('user missing');
}
return files.filter((file) => file.ownerID === user.id);
};
// doesn't work on firefox
export const copyFileToClipboard = async (fileUrl: string) => {
const canvas = document.createElement('canvas');
const canvasCTX = canvas.getContext('2d');
const image = new Image();
const blobPromise = new Promise<Blob>((resolve, reject) => {
let timeout: NodeJS.Timeout = null;
try {
image.setAttribute('src', fileUrl);
image.onload = () => {
canvas.width = image.width;
canvas.height = image.height;
canvasCTX.drawImage(image, 0, 0, image.width, image.height);
canvas.toBlob(
(blob) => {
resolve(blob);
},
'image/png',
1
);
clearTimeout(timeout);
};
} catch (e) {
void logError(e, 'failed to copy to clipboard');
reject(e);
} finally {
clearTimeout(timeout);
}
timeout = setTimeout(
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
WAIT_TIME_IMAGE_CONVERSION
);
});
const { ClipboardItem } = window;
await navigator.clipboard
.write([new ClipboardItem({ 'image/png': blobPromise })])
.catch((e) => logError(e, 'failed to copy to clipboard'));
};
export function getLatestVersionFiles(files: EnteFile[]) {
const latestVersionFiles = new Map<string, EnteFile>();
files.forEach((file) => {
const uid = `${file.collectionID}-${file.id}`;
if (
!latestVersionFiles.has(uid) ||
latestVersionFiles.get(uid).updationTime < file.updationTime
) {
latestVersionFiles.set(uid, file);
}
});
return Array.from(latestVersionFiles.values()).filter(
(file) => !file.isDeleted
);
}
export function getPersonalFiles(files: EnteFile[], user: User) {
if (!user?.id) {
throw Error('user missing');
}
return files.filter((file) => file.ownerID === user.id);
}
export function getIDBasedSortedFiles(files: EnteFile[]) {
return files.sort((a, b) => a.id - b.id);
}
export function constructFileToCollectionMap(files: EnteFile[]) {
const fileToCollectionsMap = new Map<number, number[]>();
(files ?? []).forEach((file) => {
if (!fileToCollectionsMap.get(file.id)) {
fileToCollectionsMap.set(file.id, []);
}
fileToCollectionsMap.get(file.id).push(file.collectionID);
});
return fileToCollectionsMap;
}
export const shouldShowAvatar = (file: EnteFile, user: User) => {
if (!file || !user) {
return false;
}
// is Shared file
else if (file.ownerID !== user.id) {
return true;
}
// is public collected file
else if (
file.ownerID === user.id &&
file.pubMagicMetadata?.data?.uploaderName
) {
return true;
} else {
return false;
}
};
export const getPreviewableImage = async (
file: EnteFile,
castToken: string
): Promise<Blob> => {
try {
let fileBlob: Blob;
const fileURL = await CastDownloadManager.getCachedOriginalFile(
file
)[0];
if (!fileURL) {
fileBlob = await new Response(
await CastDownloadManager.downloadFile(castToken, file)
).blob();
} else {
fileBlob = await (await fetch(fileURL)).blob();
}
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const livePhoto = await decodeLivePhoto(file, fileBlob);
fileBlob = new Blob([livePhoto.image]);
}
const convertedBlob = await getRenderableImage(
file.metadata.title,
fileBlob
);
fileBlob = convertedBlob;
const fileType = await getFileType(
new File([fileBlob], file.metadata.title)
);
fileBlob = new Blob([fileBlob], { type: fileType.mimeType });
return fileBlob;
} catch (e) {
logError(e, 'failed to download file');
}
};
const getFileObjectURLs = (originalBlob: Blob, convertedBlob: Blob) => {
const originalURL = URL.createObjectURL(originalBlob);
const convertedURL = convertedBlob
? convertedBlob === originalBlob
? originalURL
: URL.createObjectURL(convertedBlob)
: null;
return { originalURL, convertedURL };
};

View file

@ -0,0 +1,42 @@
import { FILE_TYPE } from 'constants/file';
import { getFileExtension } from 'utils/file';
const IMAGE_EXTENSIONS = [
'heic',
'heif',
'jpeg',
'jpg',
'png',
'gif',
'bmp',
'tiff',
'webp',
];
const VIDEO_EXTENSIONS = [
'mov',
'mp4',
'm4v',
'avi',
'wmv',
'flv',
'mkv',
'webm',
'3gp',
'3g2',
'avi',
'ogv',
'mpg',
'mp',
];
export function getFileTypeFromExtensionForLivePhotoClustering(
filename: string
) {
const extension = getFileExtension(filename)?.toLowerCase();
if (IMAGE_EXTENSIONS.includes(extension)) {
return FILE_TYPE.IMAGE;
} else if (VIDEO_EXTENSIONS.includes(extension)) {
return FILE_TYPE.VIDEO;
}
}

View file

@ -0,0 +1,97 @@
import { Collection } from 'types/collection';
import { EnteFile } from 'types/file';
import { MagicMetadataCore, VISIBILITY_STATE } from 'types/magicMetadata';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
export function isArchivedFile(item: EnteFile): boolean {
if (!item || !item.magicMetadata || !item.magicMetadata.data) {
return false;
}
return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
}
export function isArchivedCollection(item: Collection): boolean {
if (!item) {
return false;
}
if (item.magicMetadata && item.magicMetadata.data) {
return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
}
if (item.sharedMagicMetadata && item.sharedMagicMetadata.data) {
return (
item.sharedMagicMetadata.data.visibility ===
VISIBILITY_STATE.ARCHIVED
);
}
return false;
}
export function isPinnedCollection(item: Collection) {
if (
!item ||
!item.magicMetadata ||
!item.magicMetadata.data ||
typeof item.magicMetadata.data === 'string' ||
typeof item.magicMetadata.data.order === 'undefined'
) {
return false;
}
return item.magicMetadata.data.order !== 0;
}
export async function updateMagicMetadata<T>(
magicMetadataUpdates: T,
originalMagicMetadata?: MagicMetadataCore<T>,
decryptionKey?: string
): Promise<MagicMetadataCore<T>> {
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
if (!originalMagicMetadata) {
originalMagicMetadata = getNewMagicMetadata<T>();
}
if (typeof originalMagicMetadata?.data === 'string') {
originalMagicMetadata.data = await cryptoWorker.decryptMetadata(
originalMagicMetadata.data,
originalMagicMetadata.header,
decryptionKey
);
}
// copies the existing magic metadata properties of the files and updates the visibility value
// The expected behavior while updating magic metadata is to let the existing property as it is and update/add the property you want
const magicMetadataProps: T = {
...originalMagicMetadata.data,
...magicMetadataUpdates,
};
const nonEmptyMagicMetadataProps =
getNonEmptyMagicMetadataProps(magicMetadataProps);
const magicMetadata = {
...originalMagicMetadata,
data: nonEmptyMagicMetadataProps,
count: Object.keys(nonEmptyMagicMetadataProps).length,
};
return magicMetadata;
}
export const getNewMagicMetadata = <T>(): MagicMetadataCore<T> => {
return {
version: 1,
data: null,
header: null,
count: 0,
};
};
export const getNonEmptyMagicMetadataProps = <T>(magicMetadataProps: T): T => {
return Object.fromEntries(
Object.entries(magicMetadataProps).filter(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_, v]) => v !== null && v !== undefined
)
) as T;
};

View file

@ -0,0 +1,28 @@
import { sleep } from '@ente/shared/sleep';
const waitTimeBeforeNextAttemptInMilliSeconds = [2000, 5000, 10000];
export async function retryAsyncFunction<T>(
request: (abort?: () => void) => Promise<T>,
waitTimeBeforeNextTry?: number[]
): Promise<T> {
if (!waitTimeBeforeNextTry) {
waitTimeBeforeNextTry = waitTimeBeforeNextAttemptInMilliSeconds;
}
for (
let attemptNumber = 0;
attemptNumber <= waitTimeBeforeNextTry.length;
attemptNumber++
) {
try {
const resp = await request();
return resp;
} catch (e) {
if (attemptNumber === waitTimeBeforeNextTry.length) {
throw e;
}
await sleep(waitTimeBeforeNextTry[attemptNumber]);
}
}
}

View file

@ -0,0 +1,148 @@
import { FILE_TYPE } from 'constants/file';
import { EnteFile } from 'types/file';
import { MergedSourceURL } from 'types/gallery';
import { logError } from '@ente/shared/sentry';
const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000;
export async function isPlaybackPossible(url: string): Promise<boolean> {
return await new Promise((resolve) => {
const t = setTimeout(() => {
resolve(false);
}, WAIT_FOR_VIDEO_PLAYBACK);
const video = document.createElement('video');
video.addEventListener('canplay', function () {
clearTimeout(t);
video.remove(); // Clean up the video element
// also check for duration > 0 to make sure it is not a broken video
if (video.duration > 0) {
resolve(true);
} else {
resolve(false);
}
});
video.addEventListener('error', function () {
clearTimeout(t);
video.remove();
resolve(false);
});
video.src = url;
});
}
export async function playVideo(livePhotoVideo, livePhotoImage) {
const videoPlaying = !livePhotoVideo.paused;
if (videoPlaying) return;
livePhotoVideo.style.opacity = 1;
livePhotoImage.style.opacity = 0;
livePhotoVideo.load();
livePhotoVideo.play().catch(() => {
pauseVideo(livePhotoVideo, livePhotoImage);
});
}
export async function pauseVideo(livePhotoVideo, livePhotoImage) {
const videoPlaying = !livePhotoVideo.paused;
if (!videoPlaying) return;
livePhotoVideo.pause();
livePhotoVideo.style.opacity = 0;
livePhotoImage.style.opacity = 1;
}
export function updateFileMsrcProps(file: EnteFile, url: string) {
file.msrc = url;
file.isSourceLoaded = false;
file.conversionFailed = false;
file.isConverted = false;
if (file.metadata.fileType === FILE_TYPE.IMAGE) {
file.src = url;
} else {
file.html = `
<div class = 'pswp-item-container'>
<img src="${url}"/>
</div>
`;
}
}
export async function updateFileSrcProps(
file: EnteFile,
mergedURL: MergedSourceURL
) {
const urls = {
original: mergedURL.original.split(','),
converted: mergedURL.converted.split(','),
};
let originalImageURL;
let originalVideoURL;
let convertedImageURL;
let convertedVideoURL;
let originalURL;
let isConverted;
let conversionFailed;
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
[originalImageURL, originalVideoURL] = urls.original;
[convertedImageURL, convertedVideoURL] = urls.converted;
isConverted =
originalVideoURL !== convertedVideoURL ||
originalImageURL !== convertedImageURL;
conversionFailed = !convertedVideoURL || !convertedImageURL;
} else if (file.metadata.fileType === FILE_TYPE.VIDEO) {
[originalVideoURL] = urls.original;
[convertedVideoURL] = urls.converted;
isConverted = originalVideoURL !== convertedVideoURL;
conversionFailed = !convertedVideoURL;
} else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
[originalImageURL] = urls.original;
[convertedImageURL] = urls.converted;
isConverted = originalImageURL !== convertedImageURL;
conversionFailed = !convertedImageURL;
} else {
[originalURL] = urls.original;
isConverted = false;
conversionFailed = false;
}
const isPlayable = !isConverted || (isConverted && !conversionFailed);
file.w = window.innerWidth;
file.h = window.innerHeight;
file.isSourceLoaded = true;
file.originalImageURL = originalImageURL;
file.originalVideoURL = originalVideoURL;
file.isConverted = isConverted;
file.conversionFailed = conversionFailed;
if (!isPlayable) {
return;
}
if (file.metadata.fileType === FILE_TYPE.VIDEO) {
file.html = `
<video controls onContextMenu="return false;">
<source src="${convertedVideoURL}" />
Your browser does not support the video tag.
</video>
`;
} else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
file.html = `
<div class = 'pswp-item-container'>
<img id = "live-photo-image-${file.id}" src="${convertedImageURL}" onContextMenu="return false;"/>
<video id = "live-photo-video-${file.id}" loop muted onContextMenu="return false;">
<source src="${convertedVideoURL}" />
Your browser does not support the video tag.
</video>
</div>
`;
} else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
file.src = convertedImageURL;
} else {
logError(
Error(`unknown file type - ${file.metadata.fileType}`),
'Unknown file type'
);
file.src = originalURL;
}
}

View file

@ -0,0 +1,14 @@
const CHARACTERS =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
export function generateTempName(length: number, suffix: string) {
let tempName = '';
const charactersLength = CHARACTERS.length;
for (let i = 0; i < length; i++) {
tempName += CHARACTERS.charAt(
Math.floor(Math.random() * charactersLength)
);
}
return `${tempName}-${suffix}`;
}

View file

@ -0,0 +1,78 @@
import i18n, { t } from 'i18next';
const dateTimeFullFormatter1 = new Intl.DateTimeFormat(i18n.language, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const dateTimeFullFormatter2 = new Intl.DateTimeFormat(i18n.language, {
year: 'numeric',
});
const dateTimeShortFormatter = new Intl.DateTimeFormat(i18n.language, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const timeFormatter = new Intl.DateTimeFormat(i18n.language, {
timeStyle: 'short',
});
export function formatDateFull(date: number | Date) {
return [dateTimeFullFormatter1, dateTimeFullFormatter2]
.map((f) => f.format(date))
.join(' ');
}
export function formatDate(date: number | Date) {
const withinYear =
new Date().getFullYear() === new Date(date).getFullYear();
const dateTimeFormat2 = !withinYear ? dateTimeFullFormatter2 : null;
return [dateTimeFullFormatter1, dateTimeFormat2]
.filter((f) => !!f)
.map((f) => f.format(date))
.join(' ');
}
export function formatDateTimeShort(date: number | Date) {
return dateTimeShortFormatter.format(date);
}
export function formatTime(date: number | Date) {
return timeFormatter.format(date).toUpperCase();
}
export function formatDateTimeFull(dateTime: number | Date): string {
return [formatDateFull(dateTime), t('at'), formatTime(dateTime)].join(' ');
}
export function formatDateTime(dateTime: number | Date): string {
return [formatDate(dateTime), t('at'), formatTime(dateTime)].join(' ');
}
export function formatDateRelative(date: number) {
const units = {
year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
const relativeDateFormat = new Intl.RelativeTimeFormat(i18n.language, {
localeMatcher: 'best fit',
numeric: 'always',
style: 'long',
});
const elapsed = date - Date.now(); // "Math.abs" accounts for both "past" & "future" scenarios
for (const u in units)
if (Math.abs(elapsed) > units[u] || u === 'second')
return relativeDateFormat.format(
Math.round(elapsed / units[u]),
u as Intl.RelativeTimeFormatUnit
);
}

View file

@ -0,0 +1,136 @@
export interface TimeDelta {
hours?: number;
days?: number;
months?: number;
years?: number;
}
interface DateComponent<T = number> {
year: T;
month: T;
day: T;
hour: T;
minute: T;
second: T;
}
export function validateAndGetCreationUnixTimeInMicroSeconds(dateTime: Date) {
if (!dateTime || isNaN(dateTime.getTime())) {
return null;
}
const unixTime = dateTime.getTime() * 1000;
//ignoring dateTimeString = "0000:00:00 00:00:00"
if (unixTime === Date.UTC(0, 0, 0, 0, 0, 0, 0) || unixTime === 0) {
return null;
} else if (unixTime > Date.now() * 1000) {
return null;
} else {
return unixTime;
}
}
/*
generates data component for date in format YYYYMMDD-HHMMSS
*/
export function parseDateFromFusedDateString(dateTime: string) {
const dateComponent: DateComponent<number> = convertDateComponentToNumber({
year: dateTime.slice(0, 4),
month: dateTime.slice(4, 6),
day: dateTime.slice(6, 8),
hour: dateTime.slice(9, 11),
minute: dateTime.slice(11, 13),
second: dateTime.slice(13, 15),
});
return validateAndGetDateFromComponents(dateComponent);
}
/* sample date format = 2018-08-19 12:34:45
the date has six symbol separated number values
which we would extract and use to form the date
*/
export function tryToParseDateTime(dateTime: string): Date {
const dateComponent = getDateComponentsFromSymbolJoinedString(dateTime);
if (dateComponent.year?.length === 8 && dateComponent.month?.length === 6) {
// the filename has size 8 consecutive and then 6 consecutive digits
// high possibility that the it is a date in format YYYYMMDD-HHMMSS
const possibleDateTime = dateComponent.year + '-' + dateComponent.month;
return parseDateFromFusedDateString(possibleDateTime);
}
return validateAndGetDateFromComponents(
convertDateComponentToNumber(dateComponent)
);
}
function getDateComponentsFromSymbolJoinedString(
dateTime: string
): DateComponent<string> {
const [year, month, day, hour, minute, second] =
dateTime.match(/\d+/g) ?? [];
return { year, month, day, hour, minute, second };
}
function validateAndGetDateFromComponents(
dateComponent: DateComponent<number>
) {
let date = getDateFromComponents(dateComponent);
if (hasTimeValues(dateComponent) && !isTimePartValid(date, dateComponent)) {
// if the date has time values but they are not valid
// then we remove the time values and try to validate the date
date = getDateFromComponents(removeTimeValues(dateComponent));
}
if (!isDatePartValid(date, dateComponent)) {
return null;
}
return date;
}
function isTimePartValid(date: Date, dateComponent: DateComponent<number>) {
return (
date.getHours() === dateComponent.hour &&
date.getMinutes() === dateComponent.minute &&
date.getSeconds() === dateComponent.second
);
}
function isDatePartValid(date: Date, dateComponent: DateComponent<number>) {
return (
date.getFullYear() === dateComponent.year &&
date.getMonth() === dateComponent.month &&
date.getDate() === dateComponent.day
);
}
function convertDateComponentToNumber(
dateComponent: DateComponent<string>
): DateComponent<number> {
return {
year: Number(dateComponent.year),
// https://stackoverflow.com/questions/2552483/why-does-the-month-argument-range-from-0-to-11-in-javascripts-date-constructor
month: Number(dateComponent.month) - 1,
day: Number(dateComponent.day),
hour: Number(dateComponent.hour),
minute: Number(dateComponent.minute),
second: Number(dateComponent.second),
};
}
function getDateFromComponents(dateComponent: DateComponent<number>) {
const { year, month, day, hour, minute, second } = dateComponent;
if (hasTimeValues(dateComponent)) {
return new Date(year, month, day, hour, minute, second);
} else {
return new Date(year, month, day);
}
}
function hasTimeValues(dateComponent: DateComponent<number>) {
const { hour, minute, second } = dateComponent;
return !isNaN(hour) && !isNaN(minute) && !isNaN(second);
}
function removeTimeValues(
dateComponent: DateComponent<number>
): DateComponent<number> {
return { ...dateComponent, hour: 0, minute: 0, second: 0 };
}

View file

@ -0,0 +1,10 @@
import * as Comlink from 'comlink';
import { convertHEIC } from 'services/wasmHeicConverter/wasmHEICConverterClient';
export class DedicatedConvertWorker {
async convertHEIC(fileBlob: Blob, format: string) {
return convertHEIC(fileBlob, format);
}
}
Comlink.expose(DedicatedConvertWorker, self);

View file

@ -0,0 +1,215 @@
import * as Comlink from 'comlink';
import { StateAddress } from 'libsodium-wrappers';
import * as libsodium from '@ente/shared/crypto/internal/libsodium';
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
export class DedicatedCryptoWorker {
async decryptMetadata(
encryptedMetadata: string,
header: string,
key: string
) {
const encodedMetadata = await libsodium.decryptChaChaOneShot(
await libsodium.fromB64(encryptedMetadata),
await libsodium.fromB64(header),
key
);
return JSON.parse(textDecoder.decode(encodedMetadata));
}
async decryptThumbnail(
fileData: Uint8Array,
header: Uint8Array,
key: string
) {
return libsodium.decryptChaChaOneShot(fileData, header, key);
}
async decryptEmbedding(
encryptedEmbedding: string,
header: string,
key: string
) {
const encodedEmbedding = await libsodium.decryptChaChaOneShot(
await libsodium.fromB64(encryptedEmbedding),
await libsodium.fromB64(header),
key
);
return Float32Array.from(
JSON.parse(textDecoder.decode(encodedEmbedding))
);
}
async decryptFile(fileData: Uint8Array, header: Uint8Array, key: string) {
return libsodium.decryptChaCha(fileData, header, key);
}
async encryptMetadata(metadata: Object, key: string) {
const encodedMetadata = textEncoder.encode(JSON.stringify(metadata));
const { file: encryptedMetadata } =
await libsodium.encryptChaChaOneShot(encodedMetadata, key);
const { encryptedData, ...other } = encryptedMetadata;
return {
file: {
encryptedData: await libsodium.toB64(encryptedData),
...other,
},
key,
};
}
async encryptThumbnail(fileData: Uint8Array, key: string) {
return libsodium.encryptChaChaOneShot(fileData, key);
}
async encryptEmbedding(embedding: Float32Array, key: string) {
const encodedEmbedding = textEncoder.encode(
JSON.stringify(Array.from(embedding))
);
const { file: encryptEmbedding } = await libsodium.encryptChaChaOneShot(
encodedEmbedding,
key
);
const { encryptedData, ...other } = encryptEmbedding;
return {
file: {
encryptedData: await libsodium.toB64(encryptedData),
...other,
},
key,
};
}
async encryptFile(fileData: Uint8Array) {
return libsodium.encryptChaCha(fileData);
}
async encryptFileChunk(
data: Uint8Array,
pushState: StateAddress,
isFinalChunk: boolean
) {
return libsodium.encryptFileChunk(data, pushState, isFinalChunk);
}
async initChunkEncryption() {
return libsodium.initChunkEncryption();
}
async initChunkDecryption(header: Uint8Array, key: Uint8Array) {
return libsodium.initChunkDecryption(header, key);
}
async decryptFileChunk(fileData: Uint8Array, pullState: StateAddress) {
return libsodium.decryptFileChunk(fileData, pullState);
}
async initChunkHashing() {
return libsodium.initChunkHashing();
}
async hashFileChunk(hashState: StateAddress, chunk: Uint8Array) {
return libsodium.hashFileChunk(hashState, chunk);
}
async completeChunkHashing(hashState: StateAddress) {
return libsodium.completeChunkHashing(hashState);
}
async deriveKey(
passphrase: string,
salt: string,
opsLimit: number,
memLimit: number
) {
return libsodium.deriveKey(passphrase, salt, opsLimit, memLimit);
}
async deriveSensitiveKey(passphrase: string, salt: string) {
return libsodium.deriveSensitiveKey(passphrase, salt);
}
async deriveInteractiveKey(passphrase: string, salt: string) {
return libsodium.deriveInteractiveKey(passphrase, salt);
}
async decryptB64(data: string, nonce: string, key: string) {
return libsodium.decryptB64(data, nonce, key);
}
async decryptToUTF8(data: string, nonce: string, key: string) {
return libsodium.decryptToUTF8(data, nonce, key);
}
async encryptToB64(data: string, key: string) {
return libsodium.encryptToB64(data, key);
}
async generateKeyAndEncryptToB64(data: string) {
return libsodium.generateKeyAndEncryptToB64(data);
}
async encryptUTF8(data: string, key: string) {
return libsodium.encryptUTF8(data, key);
}
async generateEncryptionKey() {
return libsodium.generateEncryptionKey();
}
async generateSaltToDeriveKey() {
return libsodium.generateSaltToDeriveKey();
}
async generateKeyPair() {
return libsodium.generateKeyPair();
}
async boxSealOpen(input: string, publicKey: string, secretKey: string) {
return libsodium.boxSealOpen(input, publicKey, secretKey);
}
async boxSeal(input: string, publicKey: string) {
return libsodium.boxSeal(input, publicKey);
}
async generateSubKey(
key: string,
subKeyLength: number,
subKeyID: number,
context: string
) {
return libsodium.generateSubKey(key, subKeyLength, subKeyID, context);
}
async fromUTF8(string: string) {
return libsodium.fromUTF8(string);
}
async toUTF8(data: string) {
return libsodium.toUTF8(data);
}
async toB64(data: Uint8Array) {
return libsodium.toB64(data);
}
async toURLSafeB64(data: Uint8Array) {
return libsodium.toURLSafeB64(data);
}
async fromB64(string: string) {
return libsodium.fromB64(string);
}
async toHex(string: string) {
return libsodium.toHex(string);
}
async fromHex(string: string) {
return libsodium.fromHex(string);
}
}
Comlink.expose(DedicatedCryptoWorker, self);

View file

@ -0,0 +1,15 @@
import * as Comlink from 'comlink';
import { WasmFFmpeg } from 'services/wasm/ffmpeg';
export class DedicatedFFmpegWorker {
wasmFFmpeg: WasmFFmpeg;
constructor() {
this.wasmFFmpeg = new WasmFFmpeg();
}
run(cmd, inputFile, outputFileName, dontTimeout) {
return this.wasmFFmpeg.run(cmd, inputFile, outputFileName, dontTimeout);
}
}
Comlink.expose(DedicatedFFmpegWorker, self);

25
apps/cast/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "./src",
"downlevelIteration": true,
"jsx": "preserve",
"jsxImportSource": "@emotion/react",
"lib": ["dom", "dom.iterable", "esnext", "webworker"],
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"strictNullChecks": false,
"target": "es5",
"useUnknownInCatchVariables": false
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.js",
"../../packages/shared/themes/mui-theme.d.ts",
"../../packages/accounts/**/*.tsx"
],
"exclude": ["node_modules", "out", ".next", "thirdparty"]
}

View file

@ -1,46 +1,41 @@
# Sample configuration file
#
# All variables are commented out by default. Copy paste this into a new file
# called `.env.local`, or create a new file with that name and add the
# environment variables you need into it. That `.env.local` file will be
# .gitignored, so you can freely customize how the app runs in your local setup.
# called `.env.local` (or create a new file with that name) and add the
# environment variables you want to apply during development. `.env.local` is
# gitignored, so you can freely customize it for your local setup.
#
# - `.env.local` is picked up by next when NODE_ENV is development
# `.env.local` is picked up by Next.js when NODE_ENV is 'development' (it is
# 'production' by default, but gets set to 'development' when we run `next dev`)
#
# - `.env` is picked up always
#
# You don't necessarily need to use these files, these variables can be provided
# as environment variables when running yarn dev too. e.g.
# Alternatively, these variables can be provided as environment variables, say:
#
# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:3000 yarn dev:photos
#
# Variables prefixed with NEXT_PUBLIC_ (in our case, all of them) are made
# available when next runs our code in the browser. For more details, see
# Variables prefixed with NEXT_PUBLIC_ are made available when Next.js runs our
# code in the browser (Behind the scenes, Next.js just hardcodes occurrences of
# `process.env.NEXT_PUBLIC_FOO` with the value of the `NEXT_PUBLIC_FOO` env var
# when the bundle is built). See
# https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
#
# By default, the app is configured to connect to the production instance etc.
# This is usually a good default, for example someone might want to run the
# client locally but still use their actual ente account.
# A development build behaves differently in some aspects:
#
# Even though it connects to the production instances, when invoked with `yarn
# dev:*`, next will behave as if NODE_ENV was set to 'development' (otherwise
# this is assumed to be 'production'). There are some other cases too when we
# assume we're in a dev environment (e.g. the NEXT_PUBLIC_APP_ENV env var below.
# For the full list of rules that decide what counts as a dev build, see the
# `isDevDeployment` function).
# - Logs go to the browser console (in addition to the log file)
# - There is some additional logging
# - Sentry is not initialized
# - ... (search for isDevBuild to see all impacts)
#
# We have some development related conveniences tied to the dev build:
# 1. Logs go to the browser console instead of the log file
# 2. Sentry crash reporting etc is disabled
#
# The variables below thus serve as ways to customize which API instance we
# connect to for various purposes. These variables are only honoured when we're
# in a development environment.
# Note that even in development build, the app still connects to the production
# APIs by default (can be customized using the env vars below). This is usually
# a good default, for example a customer cloning this repository want to build
# and run the client from source but still use their actual Ente account.
# The ente API endpoint
# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:3000
# The Ente API endpoint
#
# NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:3000
# The ente API endpoint for payments related functionality
# The Ente API endpoint for payments related functionality
#
# NEXT_PUBLIC_ENTE_PAYMENT_ENDPOINT = http://localhost:3001
# The URL for the shared albums deployment
@ -49,6 +44,7 @@
# deploying, we add an a CNAME alias from "albums.ente.io" -> "/shared-album".
#
# Enhancement: Consider splitting this into a separate app/ in this repository.
#
# NEXT_PUBLIC_ENTE_ALBUM_ENDPOINT = http://localhost:3002
# The URL of the family plans web app deployment
@ -58,41 +54,21 @@
# these pages.
#
# Enhancement: Consider moving that into the app/ folder in this repository.
#
# NEXT_PUBLIC_ENTE_FAMILY_PORTAL_ENDPOINT = http://localhost:3003
# This in not useful when running locally. It is used to provide us a way to
# mark certain deployments as "staging" by setting environment variables in the
# CI job that deploys them on a remote server. See the `isDevDeployment`
# function.
# Set this to "true" to disable the upload of files via Cloudflare Workers.
#
# By default, the photos web app gets deployed to "web.ente.io".
# NEXT_PUBLIC_ENTE_WEB_ENDPOINT = http://localhost:3000
# Set this to true to disable reporting of crashes to Sentry.
# These workers were introduced as a way of make file uploads faster:
# https://ente.io/blog/tech/making-uploads-faster/
#
# Crash reporting is disabled if the user has opted out. This provides another
# way to disable crash reporting, say for local test branches.
# NEXT_PUBLIC_DISABLE_SENTRY=true
# Set this to disable the upload of files via CF Workers
# By default, that's the route we take. However, during development it can be
# convenient to turn this flag on to directly upload to the S3-compatible URLs
# returned by the ente API.
#
# CF workers provide us with a way of make the file uploads faster. The why and
# how is explained here: https://ente.io/blog/tech/making-uploads-faster/
#
# By default, that's the route we take. This flag can be set to true to disable
# that route, and instead directly upload to the S3-compatible URLs provided by
# our API server.
#
# Note the double negative.
# NEXT_PUBLIC_DISABLE_CF_UPLOAD_PROXY = true
# This is an alternative to run as a development build.
#
# You likely don't need this if you're running on your machine, because when
# invoked with `next dev` (as is the case for `yarn dev:photos` etc), next will
# behave as if NODE_ENV was set to 'development'.
# NEXT_PUBLIC_APP_ENV = development
# NEXT_PUBLIC_ENTE_DIRECT_UPLOAD = true
# The path of the JSON file which contains the expected results of our
# integration tests. See `upload.test.ts` for more details.
# NEXT_PUBLIC_EXPECTED_JSON_PATH = /path/to/dataset/expected.json
#
# NEXT_PUBLIC_ENTE_TEST_EXPECTED_JSON_PATH = /path/to/dataset/expected.json

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
"MAGIC_SEARCH_STATUS": "Magic Search Status",
"INDEXED_ITEMS": "Indexed items",
"CAST_ALBUM_TO_TV": "Play album on TV",
"ENTER_CAST_PIN_CODE": "Enter the code you see on the TV below to pair this device.",
"PAIR_DEVICE_TO_TV": "Pair devices",
"TV_NOT_FOUND": "TV not found. Did you enter the PIN correctly?",
"AUTO_CAST_PAIR": "Auto Pair",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "Auto Pair requires connecting to Google servers and only works with Chromecast supported devices. Google will not receive sensitive data, such as your photos.",
"PAIR_WITH_PIN": "Pair with PIN",
"CHOOSE_DEVICE_FROM_BROWSER": "Choose a cast-compatible device from the browser popup.",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "Pair with PIN works for any large screen device you want to play your album on.",
"VISIT_CAST_ENTE_IO": "Visit cast.ente.io on the device you want to pair.",
"CAST_AUTO_PAIR_FAILED": "Chromecast Auto Pair failed. Please try again.",
"CACHE_DIRECTORY": "Cache folder",
"PASSKEYS": "Passkeys",
"FREEHAND": "Freehand",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "Router les chargements vers les serveurs à proximité",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "Éléments indexés",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -38,7 +38,7 @@
"KEY_GENERATION_IN_PROGRESS_MESSAGE": "Generazione delle chiavi di crittografia...",
"PASSPHRASE_HINT": "Password",
"CONFIRM_PASSPHRASE": "Conferma la password",
"REFERRAL_CODE_HINT": "",
"REFERRAL_CODE_HINT": "Come hai conosciuto Ente? (opzionale)",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "Le password non corrispondono",
"CONSOLE_WARNING_STOP": "STOP!",
@ -179,7 +179,7 @@
"SUBSCRIPTION_CANCEL_SUCCESS": "Abbonamento annullato con successo",
"REACTIVATE_SUBSCRIPTION": "Riattiva abbonamento",
"REACTIVATE_SUBSCRIPTION_MESSAGE": "Una volta riattivato, ti verrà addebitato il valore di {{date, dateTime}}",
"SUBSCRIPTION_ACTIVATE_SUCCESS": "",
"SUBSCRIPTION_ACTIVATE_SUCCESS": "Iscrizione attivata con successo ",
"SUBSCRIPTION_ACTIVATE_FAILED": "",
"SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "Grazie",
"CANCEL_SUBSCRIPTION_ON_MOBILE": "Annulla abbonamento mobile",
@ -196,13 +196,13 @@
"SHARE": "Condividi",
"SHARE_COLLECTION": "Condividi album",
"SHAREES": "Condividi con",
"SHARE_WITH_SELF": "",
"ALREADY_SHARED": "",
"SHARING_BAD_REQUEST_ERROR": "",
"SHARING_DISABLED_FOR_FREE_ACCOUNTS": "",
"SHARE_WITH_SELF": "Ops, non puoi condividere a te stesso",
"ALREADY_SHARED": "Ops, lo stai già condividendo con {{email}}",
"SHARING_BAD_REQUEST_ERROR": "Condividere gli album non è consentito",
"SHARING_DISABLED_FOR_FREE_ACCOUNTS": "La condivisione è disabilitata per gli account free",
"DOWNLOAD_COLLECTION": "Scarica album",
"DOWNLOAD_COLLECTION_MESSAGE": "",
"CREATE_ALBUM_FAILED": "",
"DOWNLOAD_COLLECTION_MESSAGE": "<p>Sei sicuro di volere scaricare l'album interamente?</p><p>Tutti i file saranno messi in coda per il download</p>",
"CREATE_ALBUM_FAILED": "Operazione di creazione dell'album fallita, per favore riprova",
"SEARCH": "Ricerca",
"SEARCH_RESULTS": "Risultati della ricerca",
"NO_RESULTS": "",
@ -210,21 +210,21 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Posizione",
"CITY": "",
"CITY": "Posizione",
"DATE": "Data",
"FILE_NAME": "Nome file",
"THING": "",
"THING": "Contenuto",
"FILE_CAPTION": "Descrizione",
"FILE_TYPE": "",
"FILE_TYPE": "Tipo del file",
"CLIP": ""
},
"photos_count_zero": "",
"photos_count_zero": "Nessuna memoria",
"photos_count_one": "",
"photos_count_other": "",
"TERMS_AND_CONDITIONS": "",
"ADD_TO_COLLECTION": "Aggiungi all'album",
"SELECTED": "",
"VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD": "",
"VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD": "Questo video non può essere riprodotto nel tuo browser",
"PEOPLE": "Persone",
"INDEXING_SCHEDULED": "",
"ANALYZING_PHOTOS": "",
@ -233,17 +233,17 @@
"UNIDENTIFIED_FACES": "volti non identificati",
"OBJECTS": "",
"TEXT": "testo",
"INFO": "",
"INFO": "Info ",
"INFO_OPTION": "",
"FILE_NAME": "Nome file",
"CAPTION_PLACEHOLDER": "Aggiungi una descrizione",
"LOCATION": "Posizione",
"SHOW_ON_MAP": "",
"MAP": "",
"MAP_SETTINGS": "",
"ENABLE_MAPS": "",
"ENABLE_MAP": "",
"DISABLE_MAPS": "",
"SHOW_ON_MAP": "Guarda su OpenStreetMap",
"MAP": "Mappa",
"MAP_SETTINGS": "Impostazioni Mappa",
"ENABLE_MAPS": "Attivare Mappa?",
"ENABLE_MAP": "Attivare mappa",
"DISABLE_MAPS": "Disattivare Mappa?",
"ENABLE_MAP_DESCRIPTION": "",
"DISABLE_MAP_DESCRIPTION": "",
"DISABLE_MAP": "",
@ -262,7 +262,7 @@
"ENABLE": "Attiva",
"LOST_DEVICE": "",
"INCORRECT_CODE": "Codice errato",
"TWO_FACTOR_INFO": "",
"TWO_FACTOR_INFO": "Aggiungi un ulteriore livello di sicurezza richiedendo più informazioni rispetto a email e password per eseguire l'accesso al tuo account",
"DISABLE_TWO_FACTOR_LABEL": "",
"UPDATE_TWO_FACTOR_LABEL": "",
"DISABLE": "",
@ -287,63 +287,63 @@
"SKIPPED_VIDEOS_INFO": "",
"LIVE_PHOTOS_DETECTED": "",
"RETRY_FAILED": "",
"FAILED_UPLOADS": "",
"SKIPPED_FILES": "",
"FAILED_UPLOADS": "Caricamento fallito ",
"SKIPPED_FILES": "Ignora caricamenti",
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "",
"UNSUPPORTED_FILES": "",
"SUCCESSFUL_UPLOADS": "",
"SUCCESSFUL_UPLOADS": "Caricamenti eseguiti con successo",
"SKIPPED_INFO": "",
"UNSUPPORTED_INFO": "",
"BLOCKED_UPLOADS": "",
"SKIPPED_VIDEOS": "",
"INPROGRESS_METADATA_EXTRACTION": "",
"INPROGRESS_UPLOADS": "",
"TOO_LARGE_UPLOADS": "",
"LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "",
"LARGER_THAN_AVAILABLE_STORAGE_INFO": "",
"TOO_LARGE_INFO": "",
"SKIPPED_VIDEOS": "Video saltati",
"INPROGRESS_METADATA_EXTRACTION": "In corso",
"INPROGRESS_UPLOADS": "Caricamenti in corso",
"TOO_LARGE_UPLOADS": "File pesanti",
"LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "Spazio insufficiente",
"LARGER_THAN_AVAILABLE_STORAGE_INFO": "Questi file non sono stati caricati perché supererebbero la capacità massima del tuo piano di spazio d'archiviazione",
"TOO_LARGE_INFO": "Questi file non sono stati caricati perché superano il nostro limite di pesantezza di un file",
"THUMBNAIL_GENERATION_FAILED_INFO": "",
"UPLOAD_TO_COLLECTION": "",
"UNCATEGORIZED": "",
"ARCHIVE": "Archivio",
"FAVORITES": "",
"ARCHIVE_COLLECTION": "",
"FAVORITES": "Preferiti",
"ARCHIVE_COLLECTION": "Album archiviato",
"ARCHIVE_SECTION_NAME": "Archivio",
"ALL_SECTION_NAME": "",
"ALL_SECTION_NAME": "Tutto",
"MOVE_TO_COLLECTION": "Sposta nell'album",
"UNARCHIVE": "",
"UNARCHIVE_COLLECTION": "",
"HIDE_COLLECTION": "",
"UNHIDE_COLLECTION": "",
"MOVE": "",
"UNARCHIVE": "Rimuovi dall'archivio",
"UNARCHIVE_COLLECTION": "Rimuovi album dall'archivio",
"HIDE_COLLECTION": "Nascondi album",
"UNHIDE_COLLECTION": "Rimuovi album dai nascosti",
"MOVE": "Sposta",
"ADD": "Aggiungi",
"REMOVE": "",
"REMOVE": "Rimuovi",
"YES_REMOVE": "Sì, rimuovi",
"REMOVE_FROM_COLLECTION": "Rimuovi dall'album",
"TRASH": "Cestino",
"MOVE_TO_TRASH": "Sposta nel cestino",
"TRASH_FILES_MESSAGE": "",
"TRASH_FILE_MESSAGE": "",
"DELETE_PERMANENTLY": "",
"RESTORE": "",
"RESTORE_TO_COLLECTION": "",
"EMPTY_TRASH": "",
"EMPTY_TRASH_TITLE": "",
"EMPTY_TRASH_MESSAGE": "",
"LEAVE_SHARED_ALBUM": "",
"LEAVE_ALBUM": "",
"LEAVE_SHARED_ALBUM_TITLE": "",
"TRASH_FILES_MESSAGE": "Gli elementi selezionati verranno eliminati da tutti gli album e spostati nel cestino.",
"TRASH_FILE_MESSAGE": "Il file verrà eliminato da tutti gli album e spostato nel cestino.",
"DELETE_PERMANENTLY": "Elimina definitivamente",
"RESTORE": "Ripristina",
"RESTORE_TO_COLLECTION": "Ripristina nell'album",
"EMPTY_TRASH": "Svuota il cestino",
"EMPTY_TRASH_TITLE": "Vuoi svuotare il cestino?",
"EMPTY_TRASH_MESSAGE": "I file selezionati verranno eliminati definitivamente dal tuo account ente.",
"LEAVE_SHARED_ALBUM": "Sì, esci",
"LEAVE_ALBUM": "Abbandona l'album",
"LEAVE_SHARED_ALBUM_TITLE": "Abbandonare l'album condiviso?",
"LEAVE_SHARED_ALBUM_MESSAGE": "",
"NOT_FILE_OWNER": "",
"CONFIRM_SELF_REMOVE_MESSAGE": "",
"CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "",
"SORT_BY_CREATION_TIME_ASCENDING": "",
"SORT_BY_UPDATION_TIME_DESCENDING": "",
"SORT_BY_CREATION_TIME_ASCENDING": "Meno recente",
"SORT_BY_UPDATION_TIME_DESCENDING": "Ultimo aggiornamento",
"SORT_BY_NAME": "Nome",
"COMPRESS_THUMBNAILS": "",
"THUMBNAIL_REPLACED": "",
"FIX_THUMBNAIL": "",
"FIX_THUMBNAIL_LATER": "",
"COMPRESS_THUMBNAILS": "Comprimi miniature",
"THUMBNAIL_REPLACED": "Miniature compresse",
"FIX_THUMBNAIL": "Comprimi",
"FIX_THUMBNAIL_LATER": "Comprimi più tardi",
"REPLACE_THUMBNAIL_NOT_STARTED": "",
"REPLACE_THUMBNAIL_COMPLETED": "",
"REPLACE_THUMBNAIL_NOOP": "",
@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "Uploaden door nabije servers",
"MAGIC_SEARCH_STATUS": "Magische Zoekfunctie Status",
"INDEXED_ITEMS": "Geïndexeerde bestanden",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "Cache map",
"FREEHAND": "Losse hand",
"APPLY_CROP": "Bijsnijden toepassen",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",

View file

@ -625,6 +625,17 @@
"FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传",
"MAGIC_SEARCH_STATUS": "魔法搜索状态",
"INDEXED_ITEMS": "索引项目",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
"CHOOSE_DEVICE_FROM_BROWSER": "",
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"CACHE_DIRECTORY": "缓存文件夹",
"FREEHAND": "手画",
"APPLY_CROP": "应用裁剪",

View file

@ -1,5 +1,3 @@
import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
import { initSentry } from '@ente/shared/sentry/config/sentry.config.base';
const DEFAULT_SENTRY_DSN =
'https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2';
setupSentry(DEFAULT_SENTRY_DSN);
initSentry('https://cbed7333f2810fbbdb692dcd76d8ca1a@sentry.ente.io/2');

View file

View file

@ -1,3 +1,6 @@
defaults.url=https://sentry.ente.io/
defaults.org=ente
defaults.project=photos-web
# This file is used by the SentryWebpackPlugin to upload sourcemaps (if the
# SENTRY_AUTH_TOKEN environment variable is defined).
defaults.url = https://sentry.ente.io/
defaults.org = ente
defaults.project = photos-web

View file

@ -1,4 +1,5 @@
import { CollectionInfo } from './CollectionInfo';
import React, { Dispatch, SetStateAction } from 'react';
import { Collection, CollectionSummary } from 'types/collection';
import CollectionOptions from 'components/Collections/CollectionOptions';
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
@ -20,6 +21,7 @@ interface Iprops {
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
isActiveCollectionDownloadInProgress: () => boolean;
setActiveCollectionID: (collectionID: number) => void;
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
}
export default function CollectionInfoWithOptions({
@ -49,6 +51,7 @@ export default function CollectionInfoWithOptions({
return <></>;
}
};
return (
<CollectionInfoBarWrapper>
<SpaceBetweenFlex>

Some files were not shown because too many files have changed in this diff Show more