This commit is contained in:
Manav Rathi 2024-02-13 11:56:12 +05:30 committed by GitHub
commit 08d5f318a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 5658 additions and 11 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"
}
}

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

@ -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",
"FREEHAND": "Freehand",
"APPLY_CROP": "Apply Crop",

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>

View file

@ -0,0 +1,238 @@
import { VerticallyCentered } from '@ente/shared/components/Container';
import DialogBoxV2 from '@ente/shared/components/DialogBoxV2';
import EnteButton from '@ente/shared/components/EnteButton';
import EnteSpinner from '@ente/shared/components/EnteSpinner';
import SingleInputForm, {
SingleInputFormProps,
} from '@ente/shared/components/SingleInputForm';
import { boxSeal } from '@ente/shared/crypto/internal/libsodium';
import { loadSender } from '@ente/shared/hooks/useCastSender';
import { addLogLine } from '@ente/shared/logging';
import castGateway from '@ente/shared/network/cast';
import { logError } from '@ente/shared/sentry';
import { Typography } from '@mui/material';
import { t } from 'i18next';
import { useEffect, useState } from 'react';
import { Collection } from 'types/collection';
import { v4 as uuidv4 } from 'uuid';
interface Props {
show: boolean;
onHide: () => void;
currentCollection: Collection;
}
enum AlbumCastError {
TV_NOT_FOUND = 'TV_NOT_FOUND',
}
declare global {
interface Window {
chrome: any;
}
}
export default function AlbumCastDialog(props: Props) {
const [view, setView] = useState<
'choose' | 'auto' | 'pin' | 'auto-cast-error'
>('choose');
const [browserCanCast, setBrowserCanCast] = useState(false);
// Make API call on component mount
useEffect(() => {
castGateway.revokeAllTokens();
setBrowserCanCast(!!window.chrome);
}, []);
const onSubmit: SingleInputFormProps['callback'] = async (
value,
setFieldError
) => {
try {
await doCast(value);
props.onHide();
} catch (e) {
const error = e as Error;
let fieldError: string;
switch (error.message) {
case AlbumCastError.TV_NOT_FOUND:
fieldError = t('TV_NOT_FOUND');
break;
default:
fieldError = t('UNKNOWN_ERROR');
break;
}
setFieldError(fieldError);
}
};
const doCast = async (pin: string) => {
// does the TV exist? have they advertised their existence?
const tvPublicKeyB64 = await castGateway.getPublicKey(pin);
if (!tvPublicKeyB64) {
throw new Error(AlbumCastError.TV_NOT_FOUND);
}
// generate random uuid string
const castToken = uuidv4();
// ok, they exist. let's give them the good stuff.
const payload = JSON.stringify({
castToken: castToken,
collectionID: props.currentCollection.id,
collectionKey: props.currentCollection.key,
});
const encryptedPayload = await boxSeal(btoa(payload), tvPublicKeyB64);
// hey TV, we acknowlege you!
await castGateway.publishCastPayload(
pin,
encryptedPayload,
props.currentCollection.id,
castToken
);
};
useEffect(() => {
if (view === 'auto') {
loadSender().then(async (sender) => {
const { cast } = sender;
const instance = await cast.framework.CastContext.getInstance();
try {
await instance.requestSession();
} catch (e) {
setView('auto-cast-error');
logError(e, 'Error requesting session');
return;
}
const session = instance.getCurrentSession();
session.addMessageListener(
'urn:x-cast:pair-request',
(_, message) => {
const data = message;
const obj = JSON.parse(data);
const code = obj.code;
if (code) {
doCast(code)
.then(() => {
setView('choose');
props.onHide();
})
.catch((e) => {
setView('auto-cast-error');
logError(e, 'Error casting to TV');
});
}
}
);
session
.sendMessage('urn:x-cast:pair-request', {})
.then(() => {
addLogLine('Message sent successfully');
})
.catch((error) => {
logError(error, 'Error sending message');
});
});
}
}, [view]);
useEffect(() => {
if (props.show) {
castGateway.revokeAllTokens();
}
}, [props.show]);
return (
<DialogBoxV2
sx={{ zIndex: 1600 }}
open={props.show}
onClose={props.onHide}
attributes={{
title: t('CAST_ALBUM_TO_TV'),
}}>
{view === 'choose' && (
<>
{browserCanCast && (
<>
<Typography color={'text.muted'}>
{t(
'AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE'
)}
</Typography>
<EnteButton
style={{
marginBottom: '1rem',
}}
onClick={() => {
setView('auto');
}}>
{t('AUTO_CAST_PAIR')}
</EnteButton>
</>
)}
<Typography color="text.muted">
{t('PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE')}
</Typography>
<EnteButton
onClick={() => {
setView('pin');
}}>
{t('PAIR_WITH_PIN')}
</EnteButton>
</>
)}
{view === 'auto' && (
<VerticallyCentered gap="1rem">
<EnteSpinner />
<Typography>{t('CHOOSE_DEVICE_FROM_BROWSER')}</Typography>
<EnteButton
variant="text"
onClick={() => {
setView('choose');
}}>
{t('GO_BACK')}
</EnteButton>
</VerticallyCentered>
)}
{view === 'auto-cast-error' && (
<VerticallyCentered gap="1rem">
<Typography>{t('CAST_AUTO_PAIR_FAILED')}</Typography>
<EnteButton
variant="text"
onClick={() => {
setView('choose');
}}>
{t('GO_BACK')}
</EnteButton>
</VerticallyCentered>
)}
{view === 'pin' && (
<>
<Typography>{t('VISIT_CAST_ENTE_IO')}</Typography>
<Typography>{t('ENTER_CAST_PIN_CODE')}</Typography>
<SingleInputForm
callback={onSubmit}
fieldType="text"
placeholder={'123456'}
buttonText={t('PAIR_DEVICE_TO_TV')}
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
/>
<EnteButton
variant="text"
onClick={() => {
setView('choose');
}}>
{t('GO_BACK')}
</EnteButton>
</>
)}
</DialogBoxV2>
);
}

View file

@ -13,6 +13,7 @@ import PushPinOutlined from '@mui/icons-material/PushPinOutlined';
import { UnPinIcon } from 'components/icons/UnPinIcon';
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
import TvIcon from '@mui/icons-material/Tv';
interface Iprops {
isArchived: boolean;
@ -123,6 +124,14 @@ export function AlbumCollectionOption({
startIcon={<PeopleIcon />}>
{t('SHARE_COLLECTION')}
</OverflowMenuOption>
<OverflowMenuOption
startIcon={<TvIcon />}
onClick={handleCollectionAction(
CollectionActions.SHOW_ALBUM_CAST_DIALOG,
false
)}>
{t('CAST_ALBUM_TO_TV')}
</OverflowMenuOption>
</>
);
}

View file

@ -1,5 +1,11 @@
import { AlbumCollectionOption } from './AlbumCollectionOption';
import React, { useContext, useRef, useState } from 'react';
import React, {
Dispatch,
SetStateAction,
useContext,
useRef,
useState,
} from 'react';
import * as CollectionAPI from 'services/collectionService';
import * as TrashService from 'services/trashService';
import {
@ -43,6 +49,7 @@ interface CollectionOptionsProps {
collectionSummaryType: CollectionSummaryType;
showCollectionShareModal: () => void;
setActiveCollectionID: (collectionID: number) => void;
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
}
export enum CollectionActions {
@ -65,6 +72,7 @@ export enum CollectionActions {
UNPIN,
HIDE,
UNHIDE,
SHOW_ALBUM_CAST_DIALOG,
}
const CollectionOptions = (props: CollectionOptionsProps) => {
@ -76,6 +84,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
showCollectionShareModal,
setFilesDownloadProgressAttributesCreator,
isActiveCollectionDownloadInProgress,
setShowAlbumCastDialog,
} = props;
const { startLoading, finishLoading, setDialogMessage } =
@ -96,7 +105,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
action: CollectionActions,
loader = true
) => {
let callback;
let callback: Function;
switch (action) {
case CollectionActions.SHOW_RENAME_DIALOG:
callback = showRenameCollectionModal;
@ -155,6 +164,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
case CollectionActions.UNHIDE:
callback = unHideAlbum;
break;
case CollectionActions.SHOW_ALBUM_CAST_DIALOG:
callback = showCastAlbumDialog;
break;
default:
logError(
@ -165,7 +177,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
action;
}
}
return async (...args) => {
return async (...args: any) => {
try {
loader && startLoading();
await callback(...args);
@ -183,6 +195,10 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
};
};
const showCastAlbumDialog = () => {
setShowAlbumCastDialog(true);
};
const renameCollection = async (newName: string) => {
if (activeCollection.name !== newName) {
await CollectionAPI.renameCollection(activeCollection, newName);

View file

@ -21,6 +21,7 @@ import {
isFilesDownloadCompleted,
} from '../FilesDownloadProgress';
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
interface Iprops {
activeCollection: Collection;
@ -55,6 +56,8 @@ export default function Collections(props: Iprops) {
const [collectionShareModalView, setCollectionShareModalView] =
useState(false);
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false);
const [collectionListSortBy, setCollectionListSortBy] =
useLocalState<COLLECTION_LIST_SORT_BY>(
LS_KEYS.COLLECTION_SORT_BY,
@ -117,6 +120,7 @@ export default function Collections(props: Iprops) {
isActiveCollectionDownloadInProgress
}
setActiveCollectionID={setActiveCollectionID}
setShowAlbumCastDialog={setShowAlbumCastDialog}
/>
),
itemType: ITEM_TYPE.HEADER,
@ -136,6 +140,7 @@ export default function Collections(props: Iprops) {
const closeAllCollections = () => setAllCollectionView(false);
const openAllCollections = () => setAllCollectionView(true);
const closeCollectionShare = () => setCollectionShareModalView(false);
const closeAlbumCastDialog = () => setShowAlbumCastDialog(false);
return (
<>
@ -171,6 +176,11 @@ export default function Collections(props: Iprops) {
onClose={closeCollectionShare}
collection={activeCollection}
/>
<AlbumCastDialog
currentCollection={activeCollection}
show={showAlbumCastDialog}
onHide={closeAlbumCastDialog}
/>
</>
);
}

View file

@ -9,10 +9,13 @@
"scripts": {
"build:photos": "turbo run build --filter=photos",
"build:auth": "turbo run build --filter=auth",
"build:cast": "turbo run build --filter=cast",
"dev:auth": "turbo run dev --filter=auth",
"dev:photos": "turbo run dev --filter=photos",
"dev:cast": "turbo run dev --filter=cast",
"export:photos": "turbo run export --filter=photos",
"export:auth": "turbo run export --filter=auth",
"export:cast": "turbo run export --filter=cast",
"lint": "turbo run lint",
"albums": "turbo run albums",
"prepare": "husky install"

View file

@ -0,0 +1,44 @@
declare const cast: any;
import { useEffect, useState } from 'react';
type Receiver = {
cast: typeof cast;
};
const load = (() => {
let promise: Promise<Receiver> | null = null;
return () => {
if (promise === null) {
promise = new Promise((resolve) => {
const script = document.createElement('script');
script.src =
'https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js';
script.addEventListener('load', () => {
resolve({
cast,
});
});
document.body.appendChild(script);
});
}
return promise;
};
})();
export const useCastReceiver = () => {
const [receiver, setReceiver] = useState<Receiver | null>({
cast: null,
});
useEffect(() => {
load().then((receiver) => {
setReceiver(receiver);
});
});
return receiver;
};

View file

@ -0,0 +1,63 @@
declare const chrome: any;
declare const cast: any;
declare global {
interface Window {
__onGCastApiAvailable: (isAvailable: boolean) => void;
}
}
import { useEffect, useState } from 'react';
type Sender = {
chrome: typeof chrome;
cast: typeof cast;
};
export const loadSender = (() => {
let promise: Promise<Sender> | null = null;
return () => {
if (promise === null) {
promise = new Promise((resolve) => {
const script = document.createElement('script');
script.src =
'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
window.__onGCastApiAvailable = (isAvailable) => {
if (isAvailable) {
cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId:
process.env.NEXT_PUBLIC_CAST_APP_ID,
autoJoinPolicy:
chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
resolve({
chrome,
cast,
});
}
};
document.body.appendChild(script);
});
}
return promise;
};
})();
export const useCastSender = () => {
const [sender, setSender] = useState<Sender | { chrome: null; cast: null }>(
{
chrome: null,
cast: null,
}
);
useEffect(() => {
loadSender().then((sender) => {
setSender(sender);
});
}, []);
return sender;
};

View file

@ -22,6 +22,22 @@ export const getPublicCollectionFileURL = (id: number) => {
return `https://public-albums.ente.io/download/?fileID=${id}`;
};
export const getCastFileURL = (id: number) => {
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
if (isDevDeployment() && endpoint) {
return `${endpoint}/cast/files/download/${id}`;
}
return `https://cast-albums.ente.io/download/?fileID=${id}`;
};
export const getCastThumbnailURL = (id: number) => {
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
if (isDevDeployment() && endpoint) {
return `${endpoint}/cast/files/preview/${id}`;
}
return `https://cast-albums.ente.io/preview/?fileID=${id}`;
};
export const getThumbnailURL = (id: number) => {
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
if (isDevDeployment() && endpoint) {

View file

@ -0,0 +1,89 @@
import { ApiError } from '../error';
import { logError } from '../sentry';
import { getToken } from '../storage/localStorage/helpers';
import HTTPService from './HTTPService';
import { getEndpoint } from './api';
class CastGateway {
constructor() {}
public async getCastData(code: string): Promise<string | null> {
let resp;
try {
resp = await HTTPService.get(
`${getEndpoint()}/cast/cast-data/${code}`
);
} catch (e) {
logError(e, 'failed to getCastData');
throw e;
}
return resp.data.encCastData;
}
public async revokeAllTokens() {
try {
const token = getToken();
await HTTPService.delete(
getEndpoint() + '/cast/revoke-all-tokens/',
undefined,
undefined,
{
'X-Auth-Token': token,
}
);
} catch (e) {
logError(e, 'removeAllTokens failed');
// swallow error
}
}
public async getPublicKey(code: string): Promise<string> {
let resp;
try {
const token = getToken();
resp = await HTTPService.get(
`${getEndpoint()}/cast/device-info/${code}`,
undefined,
{
'X-Auth-Token': token,
}
);
} catch (e) {
if (e instanceof ApiError && e.httpStatusCode === 404) {
return '';
}
logError(e, 'failed to getPublicKey');
throw e;
}
return resp.data.publicKey;
}
public async registerDevice(code: string, publicKey: string) {
await HTTPService.post(getEndpoint() + '/cast/device-info/', {
deviceCode: `${code}`,
publicKey: publicKey,
});
}
public async publishCastPayload(
code: string,
castPayload: string,
collectionID: number,
castToken: string
) {
const token = getToken();
await HTTPService.post(
getEndpoint() + '/cast/cast-data/',
{
deviceCode: `${code}`,
encPayload: castPayload,
collectionID: collectionID,
castToken: castToken,
},
undefined,
{ 'X-Auth-Token': token }
);
}
}
export default new CastGateway();

View file

@ -0,0 +1,23 @@
import { CustomError } from '../error';
export const promiseWithTimeout = async <T>(
request: Promise<T>,
timeout: number
): Promise<T> => {
const timeoutRef = { current: null };
const rejectOnTimeout = new Promise<null>((_, reject) => {
timeoutRef.current = setTimeout(
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
timeout
);
});
const requestWithTimeOutCancellation = async () => {
const resp = await request;
clearTimeout(timeoutRef.current);
return resp;
};
return await Promise.race([
requestWithTimeOutCancellation(),
rejectOnTimeout,
]);
};

View file

@ -0,0 +1,5 @@
export async function sleep(time: number) {
await new Promise((resolve) => {
setTimeout(() => resolve(null), time);
});
}

View file

@ -1170,6 +1170,14 @@ ansi-styles@^6.0.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
@ -1309,6 +1317,11 @@ base-x@^4.0.0:
resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a"
integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
bip39@^3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/bip39/-/bip39-3.0.4.tgz"
@ -1344,7 +1357,7 @@ brace-expansion@^2.0.1:
dependencies:
balanced-match "^1.0.0"
braces@^3.0.2:
braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@ -1411,6 +1424,21 @@ chalk@^4.0.0, chalk@^4.1.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
"chokidar@>=3.0.0 <4.0.0":
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
chrono-node@^2.2.6:
version "2.3.1"
resolved "https://registry.npmjs.org/chrono-node/-/chrono-node-2.3.1.tgz"
@ -2336,7 +2364,7 @@ get-user-locale@^2.1.3:
"@types/lodash.memoize" "^4.1.7"
lodash.memoize "^4.1.1"
glob-parent@^5.1.2:
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@ -2606,6 +2634,11 @@ immediate@~3.0.5:
resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
immutable@^4.0.0:
version "4.3.4"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f"
integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==
import-fresh@^3.0.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
@ -2687,6 +2720,13 @@ is-bigint@^1.0.1:
dependencies:
has-bigints "^1.0.1"
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-boolean-object@^1.1.0:
version "1.1.2"
resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz"
@ -2746,7 +2786,7 @@ is-fullwidth-code-point@^4.0.0:
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88"
integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3:
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@ -3177,7 +3217,7 @@ mime-db@1.52.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12:
mime-types@^2.1.12, mime-types@^2.1.35:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@ -3330,7 +3370,7 @@ node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@~2.6.1:
dependencies:
whatwg-url "^5.0.0"
normalize-path@^3.0.0:
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
@ -3584,7 +3624,7 @@ picocolors@^1.0.0:
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picomatch@^2.3.1:
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
@ -3893,6 +3933,13 @@ readable-web-to-node-stream@^3.0.0:
dependencies:
readable-stream "^3.6.0"
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.1"
regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.7:
version "0.13.11"
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz"
@ -4015,6 +4062,15 @@ sanitize-filename@^1.6.3:
dependencies:
truncate-utf8-bytes "^1.0.0"
sass@^1.69.5:
version "1.69.5"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.5.tgz#23e18d1c757a35f2e52cc81871060b9ad653dfde"
integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
sax@^1.2.4:
version "1.2.4"
resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz"
@ -4138,7 +4194,7 @@ slice-ansi@^5.0.0:
ansi-styles "^6.0.0"
is-fullwidth-code-point "^4.0.0"
source-map-js@^1.0.2:
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==