diff --git a/.vscode/settings.json b/.vscode/settings.json index 074e180a5..74739151b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,8 @@ "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, - "typescript.enablePromptUseWorkspaceTsdk": true + "typescript.enablePromptUseWorkspaceTsdk": true, + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + } } diff --git a/apps/cast/.eslintrc.js b/apps/cast/.eslintrc.js new file mode 100644 index 000000000..60466d6e1 --- /dev/null +++ b/apps/cast/.eslintrc.js @@ -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'], +}; diff --git a/apps/cast/.gitignore b/apps/cast/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/apps/cast/.gitignore @@ -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 diff --git a/apps/cast/README.md b/apps/cast/README.md new file mode 100644 index 000000000..a75ac5248 --- /dev/null +++ b/apps/cast/README.md @@ -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. diff --git a/apps/cast/configUtil.js b/apps/cast/configUtil.js new file mode 100644 index 000000000..795cf15dc --- /dev/null +++ b/apps/cast/configUtil.js @@ -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, +}; diff --git a/apps/cast/next.config.js b/apps/cast/next.config.js new file mode 100644 index 000000000..d09487bad --- /dev/null +++ b/apps/cast/next.config.js @@ -0,0 +1,3 @@ +const nextConfigBase = require('@ente/shared/next/next.config.base.js'); + +module.exports = nextConfigBase; diff --git a/apps/cast/package.json b/apps/cast/package.json new file mode 100644 index 000000000..ef2d8b7fa --- /dev/null +++ b/apps/cast/package.json @@ -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" + } +} diff --git a/apps/cast/public/favicon.ico b/apps/cast/public/favicon.ico new file mode 100644 index 000000000..4570eb8d9 Binary files /dev/null and b/apps/cast/public/favicon.ico differ diff --git a/apps/cast/public/images/ente.svg b/apps/cast/public/images/ente.svg new file mode 100644 index 000000000..33bd74256 --- /dev/null +++ b/apps/cast/public/images/ente.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/cast/public/images/help-qrcode.webp b/apps/cast/public/images/help-qrcode.webp new file mode 100644 index 000000000..79cd22c99 Binary files /dev/null and b/apps/cast/public/images/help-qrcode.webp differ diff --git a/apps/cast/sentry.client.config.js b/apps/cast/sentry.client.config.js new file mode 100644 index 000000000..f07284c49 --- /dev/null +++ b/apps/cast/sentry.client.config.js @@ -0,0 +1,2 @@ +import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base'; +setupSentry('https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2'); diff --git a/apps/cast/sentry.properties b/apps/cast/sentry.properties new file mode 100644 index 000000000..3bcfe48bc --- /dev/null +++ b/apps/cast/sentry.properties @@ -0,0 +1,3 @@ +defaults.url=https://sentry.ente.io/ +defaults.org=ente +defaults.project=photos-web diff --git a/apps/cast/sentry.server.config.js b/apps/cast/sentry.server.config.js new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cast/sentryConfigUtil.js b/apps/cast/sentryConfigUtil.js new file mode 100644 index 000000000..84d4a7628 --- /dev/null +++ b/apps/cast/sentryConfigUtil.js @@ -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; + } +}; diff --git a/apps/cast/src/components/FilledCircleCheck/FilledCircleCheck.module.scss b/apps/cast/src/components/FilledCircleCheck/FilledCircleCheck.module.scss new file mode 100644 index 000000000..535a2448a --- /dev/null +++ b/apps/cast/src/components/FilledCircleCheck/FilledCircleCheck.module.scss @@ -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; + } +} diff --git a/apps/cast/src/components/FilledCircleCheck/index.tsx b/apps/cast/src/components/FilledCircleCheck/index.tsx new file mode 100644 index 000000000..e70f1e336 --- /dev/null +++ b/apps/cast/src/components/FilledCircleCheck/index.tsx @@ -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 ( +
+ + + + +
+ ); +}; + +export default FilledCircleCheck; diff --git a/apps/cast/src/components/LargeType.tsx b/apps/cast/src/components/LargeType.tsx new file mode 100644 index 000000000..b2a2eb994 --- /dev/null +++ b/apps/cast/src/components/LargeType.tsx @@ -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 ( + + {chars.map((char, i) => ( + + + {char} + + + {i + 1} + + + ))} +
+ ); +} diff --git a/apps/cast/src/components/PairedSuccessfullyOverlay.tsx b/apps/cast/src/components/PairedSuccessfullyOverlay.tsx new file mode 100644 index 000000000..517874b73 --- /dev/null +++ b/apps/cast/src/components/PairedSuccessfullyOverlay.tsx @@ -0,0 +1,42 @@ +import FilledCircleCheck from './FilledCircleCheck'; + +export default function PairedSuccessfullyOverlay() { + return ( +
+
+ +

+ Pairing Complete +

+

+ We're preparing your album. +
This should only take a few seconds. +

+
+
+ ); +} diff --git a/apps/cast/src/components/Theatre/PhotoAuditorium.tsx b/apps/cast/src/components/Theatre/PhotoAuditorium.tsx new file mode 100644 index 000000000..c137aec7f --- /dev/null +++ b/apps/cast/src/components/Theatre/PhotoAuditorium.tsx @@ -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(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 ( +
+
+ + { + setNextSlidePrerendered(true); + setPrerenderTime(Date.now()); + }} + /> +
+
+ ); +} diff --git a/apps/cast/src/components/Theatre/VideoAuditorium.tsx b/apps/cast/src/components/Theatre/VideoAuditorium.tsx new file mode 100644 index 000000000..230d30312 --- /dev/null +++ b/apps/cast/src/components/Theatre/VideoAuditorium.tsx @@ -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(null); + + useEffect(() => { + attemptPlay(); + }, [url, videoRef]); + + const attemptPlay = async () => { + if (videoRef.current) { + try { + await videoRef.current.play(); + } catch { + showNextSlide(); + } + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/cast/src/components/Theatre/index.tsx b/apps/cast/src/components/Theatre/index.tsx new file mode 100644 index 000000000..d2c8e6ab4 --- /dev/null +++ b/apps/cast/src/components/Theatre/index.tsx @@ -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 ( + + ); + // case FILE_TYPE.VIDEO: + // return ( + // + // ); + } +} diff --git a/apps/cast/src/components/TimerBar.tsx b/apps/cast/src/components/TimerBar.tsx new file mode 100644 index 000000000..0d0a07e6b --- /dev/null +++ b/apps/cast/src/components/TimerBar.tsx @@ -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 ( +
+ ); +} diff --git a/apps/cast/src/constants/api.ts b/apps/cast/src/constants/api.ts new file mode 100644 index 000000000..17571f7f3 --- /dev/null +++ b/apps/cast/src/constants/api.ts @@ -0,0 +1 @@ +export const REQUEST_BATCH_SIZE = 1000; diff --git a/apps/cast/src/constants/apps.ts b/apps/cast/src/constants/apps.ts new file mode 100644 index 000000000..5d54d176c --- /dev/null +++ b/apps/cast/src/constants/apps.ts @@ -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; +}; diff --git a/apps/cast/src/constants/cache.ts b/apps/cast/src/constants/cache.ts new file mode 100644 index 000000000..80e0fecf1 --- /dev/null +++ b/apps/cast/src/constants/cache.ts @@ -0,0 +1,5 @@ +export enum CACHES { + THUMBS = 'thumbs', + FACE_CROPS = 'face-crops', + FILES = 'files', +} diff --git a/apps/cast/src/constants/collection.ts b/apps/cast/src/constants/collection.ts new file mode 100644 index 000000000..a0c6a9037 --- /dev/null +++ b/apps/cast/src/constants/collection.ts @@ -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, +]); diff --git a/apps/cast/src/constants/ffmpeg.ts b/apps/cast/src/constants/ffmpeg.ts new file mode 100644 index 000000000..008b09784 --- /dev/null +++ b/apps/cast/src/constants/ffmpeg.ts @@ -0,0 +1,3 @@ +export const INPUT_PATH_PLACEHOLDER = 'INPUT'; +export const FFMPEG_PLACEHOLDER = 'FFMPEG'; +export const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT'; diff --git a/apps/cast/src/constants/file.ts b/apps/cast/src/constants/file.ts new file mode 100644 index 000000000..c00ec9972 --- /dev/null +++ b/apps/cast/src/constants/file.ts @@ -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', +]; diff --git a/apps/cast/src/constants/gallery.ts b/apps/cast/src/constants/gallery.ts new file mode 100644 index 000000000..66b7759d6 --- /dev/null +++ b/apps/cast/src/constants/gallery.ts @@ -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 diff --git a/apps/cast/src/constants/pages.ts b/apps/cast/src/constants/pages.ts new file mode 100644 index 000000000..85b9db018 --- /dev/null +++ b/apps/cast/src/constants/pages.ts @@ -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', +} diff --git a/apps/cast/src/constants/sentry.ts b/apps/cast/src/constants/sentry.ts new file mode 100644 index 000000000..a8a05aff9 --- /dev/null +++ b/apps/cast/src/constants/sentry.ts @@ -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; diff --git a/apps/cast/src/constants/upload.ts b/apps/cast/src/constants/upload.ts new file mode 100644 index 000000000..dccad9bae --- /dev/null +++ b/apps/cast/src/constants/upload.ts @@ -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='; diff --git a/apps/cast/src/constants/urls.ts b/apps/cast/src/constants/urls.ts new file mode 100644 index 000000000..291704e98 --- /dev/null +++ b/apps/cast/src/constants/urls.ts @@ -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'; diff --git a/apps/cast/src/pages/_app.tsx b/apps/cast/src/pages/_app.tsx new file mode 100644 index 000000000..4d504197b --- /dev/null +++ b/apps/cast/src/pages/_app.tsx @@ -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 ( + + + +
+ +
+
+ ); +} diff --git a/apps/cast/src/pages/_document.tsx b/apps/cast/src/pages/_document.tsx new file mode 100644 index 000000000..560edf99e --- /dev/null +++ b/apps/cast/src/pages/_document.tsx @@ -0,0 +1,25 @@ +import { Html, Head, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + +
+ + + + ); +} diff --git a/apps/cast/src/pages/index.tsx b/apps/cast/src/pages/index.tsx new file mode 100644 index 000000000..30da9bf1a --- /dev/null +++ b/apps/cast/src/pages/index.tsx @@ -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([]); + 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 ( + <> +
+
+ +

+ Enter this code on ente to pair this TV +

+
+ {codePending ? ( + + ) : ( + <> + + + )} +
+

+ Visit{' '} + + ente.io/cast + {' '} + for help +

+
+ +
+
+
+ + ); +} diff --git a/apps/cast/src/pages/slideshow.tsx b/apps/cast/src/pages/slideshow.tsx new file mode 100644 index 000000000..f38fef6fe --- /dev/null +++ b/apps/cast/src/pages/slideshow.tsx @@ -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(); + +export default function Slideshow() { + const [collectionFiles, setCollectionFiles] = useState([]); + + const [currentFile, setCurrentFile] = useState( + undefined + ); + const [nextFile, setNextFile] = useState(undefined); + + const [loading, setLoading] = useState(true); + const [castToken, setCastToken] = useState(''); + 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(''); + + 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 ( + <> + + + + {loading && } + + ); +} diff --git a/apps/cast/src/services/InMemoryStore.ts b/apps/cast/src/services/InMemoryStore.ts new file mode 100644 index 000000000..82e0155a6 --- /dev/null +++ b/apps/cast/src/services/InMemoryStore.ts @@ -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, 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(); diff --git a/apps/cast/src/services/cache/cacheStorageFactory.ts b/apps/cast/src/services/cache/cacheStorageFactory.ts new file mode 100644 index 000000000..bb007977c --- /dev/null +++ b/apps/cast/src/services/cache/cacheStorageFactory.ts @@ -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), + }; +} diff --git a/apps/cast/src/services/cache/cacheStorageService.ts b/apps/cast/src/services/cache/cacheStorageService.ts new file mode 100644 index 000000000..87df2063b --- /dev/null +++ b/apps/cast/src/services/cache/cacheStorageService.ts @@ -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 }; diff --git a/apps/cast/src/services/cast/castService.ts b/apps/cast/src/services/cast/castService.ts new file mode 100644 index 000000000..755c7878a --- /dev/null +++ b/apps/cast/src/services/cast/castService.ts @@ -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 => { + const localSavedcollectionFiles = + (await localForage.getItem( + 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( + 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(COLLECTIONS_TABLE)) || []; + const collection = + localCollections.find( + (localSavedPublicCollection) => + localSavedPublicCollection.key === collectionKey + ) || null; + return collection; +}; + +const saveCollection = async (collection: Collection) => { + const collections = + (await localForage.getItem(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 { + const lastSyncKey = getLastSyncKey(collectionUID); + const lastSyncTime = await localForage.getItem(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(); + 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 => { + 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[] + )), + ]; + + 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 => { + 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(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( + 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]); + } +}; diff --git a/apps/cast/src/services/castDownloadManager.ts b/apps/cast/src/services/castDownloadManager.ts new file mode 100644 index 000000000..c1a786c76 --- /dev/null +++ b/apps/cast/src/services/castDownloadManager.ts @@ -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>(); + + private fileDownloadProgress = new Map(); + + private progressUpdater: (value: Map) => void; + + setProgressUpdater(progressUpdater: (value: Map) => 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(); diff --git a/apps/cast/src/services/events.ts b/apps/cast/src/services/events.ts new file mode 100644 index 000000000..670ecac47 --- /dev/null +++ b/apps/cast/src/services/events.ts @@ -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(); diff --git a/apps/cast/src/services/ffmpeg/ffmpegFactory.ts b/apps/cast/src/services/ffmpeg/ffmpegFactory.ts new file mode 100644 index 000000000..eca7287c6 --- /dev/null +++ b/apps/cast/src/services/ffmpeg/ffmpegFactory.ts @@ -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; +} + +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(); diff --git a/apps/cast/src/services/ffmpeg/ffmpegService.ts b/apps/cast/src/services/ffmpeg/ffmpegService.ts new file mode 100644 index 000000000..4e6a6241f --- /dev/null +++ b/apps/cast/src/services/ffmpeg/ffmpegService.ts @@ -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; + } +} diff --git a/apps/cast/src/services/heicConversionService.ts b/apps/cast/src/services/heicConversionService.ts new file mode 100644 index 000000000..00f49b7db --- /dev/null +++ b/apps/cast/src/services/heicConversionService.ts @@ -0,0 +1,14 @@ +import { logError } from '@ente/shared/sentry'; +import WasmHEICConverterService from './wasmHeicConverter/wasmHEICConverterService'; + +class HeicConversionService { + async convert(heicFileData: Blob): Promise { + try { + return await WasmHEICConverterService.convert(heicFileData); + } catch (e) { + logError(e, 'failed to convert heic file'); + throw e; + } + } +} +export default new HeicConversionService(); diff --git a/apps/cast/src/services/livePhotoService.ts b/apps/cast/src/services/livePhotoService.ts new file mode 100644 index 000000000..613d2861e --- /dev/null +++ b/apps/cast/src/services/livePhotoService.ts @@ -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' }); +}; diff --git a/apps/cast/src/services/queueProcessor.ts b/apps/cast/src/services/queueProcessor.ts new file mode 100644 index 000000000..97332aaee --- /dev/null +++ b/apps/cast/src/services/queueProcessor.ts @@ -0,0 +1,86 @@ +import { CustomError } from '@ente/shared/error'; + +interface RequestQueueItem { + request: (canceller?: RequestCanceller) => Promise; + 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 { + private requestQueue: RequestQueueItem[] = []; + + private requestInProcessing = 0; + + constructor( + private maxParallelProcesses: number, + private processingStrategy = PROCESSING_STRATEGY.FIFO + ) {} + + public queueUpRequest( + request: (canceller?: RequestCanceller) => Promise + ) { + const isCanceled: CancellationStatus = { status: false }; + const canceller: RequestCanceller = { + exec: () => { + isCanceled.status = true; + }, + }; + + const promise = new Promise((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--; + } +} diff --git a/apps/cast/src/services/readerService.ts b/apps/cast/src/services/readerService.ts new file mode 100644 index 000000000..765ea19e1 --- /dev/null +++ b/apps/cast/src/services/readerService.ts @@ -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 { + 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({ + 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 { + 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); + }); +} diff --git a/apps/cast/src/services/typeDetectionService.ts b/apps/cast/src/services/typeDetectionService.ts new file mode 100644 index 000000000..49191ef20 --- /dev/null +++ b/apps/cast/src/services/typeDetectionService.ts @@ -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 { + 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; +} diff --git a/apps/cast/src/services/wasm/ffmpeg.ts b/apps/cast/src/services/wasm/ffmpeg.ts new file mode 100644 index 000000000..bb27ed4db --- /dev/null +++ b/apps/cast/src/services/wasm/ffmpeg.ts @@ -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 = null; + private ffmpegTaskQueue = new QueueProcessor(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( + 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); + } +} diff --git a/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterClient.ts b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterClient.ts new file mode 100644 index 000000000..0ca183f22 --- /dev/null +++ b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterClient.ts @@ -0,0 +1,13 @@ +import * as HeicConvert from 'heic-convert'; +import { getUint8ArrayView } from 'services/readerService'; + +export async function convertHEIC( + fileBlob: Blob, + format: string +): Promise { + const filedata = await getUint8ArrayView(fileBlob); + const result = await HeicConvert({ buffer: filedata, format }); + const convertedFileData = new Uint8Array(result); + const convertedFileBlob = new Blob([convertedFileData]); + return convertedFileBlob; +} diff --git a/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterService.ts b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterService.ts new file mode 100644 index 000000000..a17884a9d --- /dev/null +++ b/apps/cast/src/services/wasmHeicConverter/wasmHEICConverterService.ts @@ -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( + MAX_CONVERSION_IN_PARALLEL + ); + private workerPool: ComlinkWorker[] = []; + private ready: Promise; + + 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 { + await this.ready; + const response = this.convertProcessor.queueUpRequest(() => + retryAsyncFunction(async () => { + const convertWorker = this.workerPool.shift(); + const worker = await convertWorker.remote; + try { + const convertedHEIC = await new Promise( + (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(); diff --git a/apps/cast/src/styles/global.css b/apps/cast/src/styles/global.css new file mode 100644 index 000000000..d79f9f302 --- /dev/null +++ b/apps/cast/src/styles/global.css @@ -0,0 +1,3 @@ +#__next { + height: 100%; +} diff --git a/apps/cast/src/types/cache/index.ts b/apps/cast/src/types/cache/index.ts new file mode 100644 index 000000000..2920ece10 --- /dev/null +++ b/apps/cast/src/types/cache/index.ts @@ -0,0 +1,20 @@ +export interface LimitedCacheStorage { + open: (cacheName: string) => Promise; + delete: (cacheName: string) => Promise; +} + +export interface LimitedCache { + match: (key: string) => Promise; + put: (key: string, data: Response) => Promise; + delete: (key: string) => Promise; +} + +export interface ProxiedLimitedCacheStorage { + open: (cacheName: string) => Promise; + delete: (cacheName: string) => Promise; +} +export interface ProxiedWorkerLimitedCache { + match: (key: string) => Promise; + put: (key: string, data: ArrayBuffer) => Promise; + delete: (key: string) => Promise; +} diff --git a/apps/cast/src/types/cast/index.ts b/apps/cast/src/types/cast/index.ts new file mode 100644 index 000000000..f082e433e --- /dev/null +++ b/apps/cast/src/types/cast/index.ts @@ -0,0 +1,5 @@ +export interface CastPayload { + collectionID: number; + collectionKey: string; + castToken: string; +} diff --git a/apps/cast/src/types/collection/index.ts b/apps/cast/src/types/collection/index.ts new file mode 100644 index 000000000..489c0bdde --- /dev/null +++ b/apps/cast/src/types/collection/index.ts @@ -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; + +export interface RemoveFromCollectionRequest { + collectionID: number; + fileIDs: number[]; +} + +export interface CollectionMagicMetadataProps { + visibility?: VISIBILITY_STATE; + subType?: SUB_TYPE; + order?: number; +} + +export type CollectionMagicMetadata = + MagicMetadataCore; + +export interface CollectionShareeMetadataProps { + visibility?: VISIBILITY_STATE; +} +export type CollectionShareeMagicMetadata = + MagicMetadataCore; + +export interface CollectionPublicMagicMetadataProps { + asc?: boolean; + coverID?: number; +} + +export type CollectionPublicMagicMetadata = + MagicMetadataCore; + +export interface CollectionSummary { + id: number; + name: string; + type: CollectionSummaryType; + coverFile: EnteFile; + latestFile: EnteFile; + fileCount: number; + updationTime: number; + order?: number; +} + +export type CollectionSummaries = Map; +export type CollectionFilesCount = Map; diff --git a/apps/cast/src/types/file/index.ts b/apps/cast/src/types/file/index.ts new file mode 100644 index 000000000..7c55a8af2 --- /dev/null +++ b/apps/cast/src/types/file/index.ts @@ -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; + +export interface FilePublicMagicMetadataProps { + editedTime?: number; + editedName?: string; + caption?: string; + uploaderName?: string; + w?: number; + h?: number; +} + +export type FilePublicMagicMetadata = + MagicMetadataCore; diff --git a/apps/cast/src/types/gallery/index.ts b/apps/cast/src/types/gallery/index.ts new file mode 100644 index 000000000..927340b96 --- /dev/null +++ b/apps/cast/src/types/gallery/index.ts @@ -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>; +export type SetCollections = React.Dispatch>; +export type SetLoading = React.Dispatch>; +// export type SetCollectionSelectorAttributes = React.Dispatch< +// React.SetStateAction +// >; +// export type SetCollectionDownloadProgressAttributes = React.Dispatch< +// React.SetStateAction +// >; + +export type MergedSourceURL = { + original: string; + converted: string; +}; +export enum UploadTypeSelectorIntent { + normalUpload, + import, + collectPhotos, +} +export type GalleryContextType = { + thumbs: Map; + files: Map; + showPlanSelectorModal: () => void; + setActiveCollectionID: (collectionID: number) => void; + syncWithRemote: (force?: boolean, silent?: boolean) => Promise; + setBlockingLoad: (value: boolean) => void; + setIsInSearchMode: (value: boolean) => void; + // photoListHeader: TimeStampListItem; + openExportModal: () => void; + authenticateUser: (callback: () => void) => void; + user: User; + userIDToEmailMap: Map; + emailList: string[]; + openHiddenSection: (callback?: () => void) => void; + isClipSearchResult: boolean; +}; + +export enum CollectionSelectorIntent { + upload, + add, + move, + restore, + unhide, +} diff --git a/apps/cast/src/types/magicMetadata/index.ts b/apps/cast/src/types/magicMetadata/index.ts new file mode 100644 index 000000000..cc01eea84 --- /dev/null +++ b/apps/cast/src/types/magicMetadata/index.ts @@ -0,0 +1,29 @@ +export interface MagicMetadataCore { + version: number; + count: number; + header: string; + data: T; +} + +export type EncryptedMagicMetadata = MagicMetadataCore; + +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; +} diff --git a/apps/cast/src/types/upload/index.ts b/apps/cast/src/types/upload/index.ts new file mode 100644 index 000000000..192315ef1 --- /dev/null +++ b/apps/cast/src/types/upload/index.ts @@ -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; + 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>; + blob: () => Promise; + arrayBuffer: () => Promise; +} + +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; + +export interface UploadURL { + url: string; + objectKey: string; +} + +export interface FileInMemory { + filedata: Uint8Array | DataStream; + thumbnail: Uint8Array; + hasStaticThumbnail: boolean; +} + +export interface FileWithMetadata + extends Omit { + metadata: Metadata; + localID: number; + pubMagicMetadata: FilePublicMagicMetadata; +} + +export interface EncryptedFile { + file: ProcessedFile; + fileKey: B64EncryptionResult; +} +export interface ProcessedFile { + file: LocalFileAttributes; + thumbnail: LocalFileAttributes; + metadata: LocalFileAttributes; + 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; +} diff --git a/apps/cast/src/types/upload/ui.ts b/apps/cast/src/types/upload/ui.ts new file mode 100644 index 000000000..4283e30a1 --- /dev/null +++ b/apps/cast/src/types/upload/ui.ts @@ -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; + +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; + +export type FinishedUploads = Map; + +export type SegregatedFinishedUploads = Map; + +export interface ProgressUpdater { + setPercentComplete: React.Dispatch>; + setUploadCounter: React.Dispatch>; + setUploadStage: React.Dispatch>; + setInProgressUploads: React.Dispatch< + React.SetStateAction + >; + setFinishedUploads: React.Dispatch< + React.SetStateAction + >; + setUploadFilenames: React.Dispatch>; + setHasLivePhotos: React.Dispatch>; + setUploadProgressView: React.Dispatch>; +} diff --git a/apps/cast/src/utils/collection/index.ts b/apps/cast/src/utils/collection/index.ts new file mode 100644 index 000000000..54396ab8e --- /dev/null +++ b/apps/cast/src/utils/collection/index.ts @@ -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 { + return new Map( + 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)); +} diff --git a/apps/cast/src/utils/comlink/ComlinkConvertWorker.ts b/apps/cast/src/utils/comlink/ComlinkConvertWorker.ts new file mode 100644 index 000000000..563a6f0d6 --- /dev/null +++ b/apps/cast/src/utils/comlink/ComlinkConvertWorker.ts @@ -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; + + 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(); diff --git a/apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts b/apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts new file mode 100644 index 000000000..2410d469a --- /dev/null +++ b/apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts @@ -0,0 +1,25 @@ +import { Remote } from 'comlink'; +import { DedicatedCryptoWorker } from 'worker/crypto.worker'; +import { ComlinkWorker } from './comlinkWorker'; + +class ComlinkCryptoWorker { + private comlinkWorkerInstance: Promise>; + + async getInstance() { + if (!this.comlinkWorkerInstance) { + const comlinkWorker = getDedicatedCryptoWorker(); + this.comlinkWorkerInstance = comlinkWorker.remote; + } + return this.comlinkWorkerInstance; + } +} + +export const getDedicatedCryptoWorker = () => { + const cryptoComlinkWorker = new ComlinkWorker( + 'ente-crypto-worker', + new Worker(new URL('worker/crypto.worker.ts', import.meta.url)) + ); + return cryptoComlinkWorker; +}; + +export default new ComlinkCryptoWorker(); diff --git a/apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts b/apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts new file mode 100644 index 000000000..efc71bf99 --- /dev/null +++ b/apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts @@ -0,0 +1,25 @@ +import { Remote } from 'comlink'; +import { DedicatedFFmpegWorker } from 'worker/ffmpeg.worker'; +import { ComlinkWorker } from './comlinkWorker'; + +class ComlinkFFmpegWorker { + private comlinkWorkerInstance: Promise>; + + async getInstance() { + if (!this.comlinkWorkerInstance) { + const comlinkWorker = getDedicatedFFmpegWorker(); + this.comlinkWorkerInstance = comlinkWorker.remote; + } + return this.comlinkWorkerInstance; + } +} + +const getDedicatedFFmpegWorker = () => { + const cryptoComlinkWorker = new ComlinkWorker( + 'ente-ffmpeg-worker', + new Worker(new URL('worker/ffmpeg.worker.ts', import.meta.url)) + ); + return cryptoComlinkWorker; +}; + +export default new ComlinkFFmpegWorker(); diff --git a/apps/cast/src/utils/comlink/comlinkWorker.ts b/apps/cast/src/utils/comlink/comlinkWorker.ts new file mode 100644 index 000000000..f566a3f90 --- /dev/null +++ b/apps/cast/src/utils/comlink/comlinkWorker.ts @@ -0,0 +1,27 @@ +import { addLocalLog } from '@ente/shared/logging'; +import { Remote, wrap } from 'comlink'; +// import { WorkerElectronCacheStorageClient } from 'services/workerElectronCache/client'; + +export class ComlinkWorker InstanceType> { + public remote: Promise>>; + 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(this.worker); + this.remote = new comlink() as Promise>>; + // expose(WorkerElectronCacheStorageClient, this.worker); + } + + public terminate() { + this.worker.terminate(); + addLocalLog(() => `Terminated ${this.name}`); + } +} diff --git a/apps/cast/src/utils/file/blob.ts b/apps/cast/src/utils/file/blob.ts new file mode 100644 index 000000000..cb2e8c7a2 --- /dev/null +++ b/apps/cast/src/utils/file/blob.ts @@ -0,0 +1,15 @@ +export const readAsDataURL = (blob) => + new Promise((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((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = () => resolve(fileReader.result as string); + fileReader.onerror = () => reject(fileReader.error); + fileReader.readAsText(blob); + }); diff --git a/apps/cast/src/utils/file/index.ts b/apps/cast/src/utils/file/index.ts new file mode 100644 index 000000000..c7818b36d --- /dev/null +++ b/apps/cast/src/utils/file/index.ts @@ -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(); + 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 { + 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(); + 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((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(); + 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(); + (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 => { + 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 }; +}; diff --git a/apps/cast/src/utils/file/livePhoto.ts b/apps/cast/src/utils/file/livePhoto.ts new file mode 100644 index 000000000..92fb311b1 --- /dev/null +++ b/apps/cast/src/utils/file/livePhoto.ts @@ -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; + } +} diff --git a/apps/cast/src/utils/magicMetadata/index.ts b/apps/cast/src/utils/magicMetadata/index.ts new file mode 100644 index 000000000..a650d0a82 --- /dev/null +++ b/apps/cast/src/utils/magicMetadata/index.ts @@ -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( + magicMetadataUpdates: T, + originalMagicMetadata?: MagicMetadataCore, + decryptionKey?: string +): Promise> { + const cryptoWorker = await ComlinkCryptoWorker.getInstance(); + + if (!originalMagicMetadata) { + originalMagicMetadata = getNewMagicMetadata(); + } + + 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 = (): MagicMetadataCore => { + return { + version: 1, + data: null, + header: null, + count: 0, + }; +}; + +export const getNonEmptyMagicMetadataProps = (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; +}; diff --git a/apps/cast/src/utils/network/index.ts b/apps/cast/src/utils/network/index.ts new file mode 100644 index 000000000..b0e017dd3 --- /dev/null +++ b/apps/cast/src/utils/network/index.ts @@ -0,0 +1,28 @@ +import { sleep } from '@ente/shared/sleep'; + +const waitTimeBeforeNextAttemptInMilliSeconds = [2000, 5000, 10000]; + +export async function retryAsyncFunction( + request: (abort?: () => void) => Promise, + waitTimeBeforeNextTry?: number[] +): Promise { + 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]); + } + } +} diff --git a/apps/cast/src/utils/photoFrame/index.ts b/apps/cast/src/utils/photoFrame/index.ts new file mode 100644 index 000000000..7637898f9 --- /dev/null +++ b/apps/cast/src/utils/photoFrame/index.ts @@ -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 { + 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 = ` +
+ +
+ `; + } +} + +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 = ` + + `; + } else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + file.html = ` +
+ + +
+ `; + } 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; + } +} diff --git a/apps/cast/src/utils/temp/index.ts b/apps/cast/src/utils/temp/index.ts new file mode 100644 index 000000000..589c66d55 --- /dev/null +++ b/apps/cast/src/utils/temp/index.ts @@ -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}`; +} diff --git a/apps/cast/src/utils/time/format.ts b/apps/cast/src/utils/time/format.ts new file mode 100644 index 000000000..59caefa80 --- /dev/null +++ b/apps/cast/src/utils/time/format.ts @@ -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 + ); +} diff --git a/apps/cast/src/utils/time/index.ts b/apps/cast/src/utils/time/index.ts new file mode 100644 index 000000000..11331a21b --- /dev/null +++ b/apps/cast/src/utils/time/index.ts @@ -0,0 +1,136 @@ +export interface TimeDelta { + hours?: number; + days?: number; + months?: number; + years?: number; +} + +interface DateComponent { + 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 = 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 { + const [year, month, day, hour, minute, second] = + dateTime.match(/\d+/g) ?? []; + + return { year, month, day, hour, minute, second }; +} + +function validateAndGetDateFromComponents( + dateComponent: DateComponent +) { + 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) { + return ( + date.getHours() === dateComponent.hour && + date.getMinutes() === dateComponent.minute && + date.getSeconds() === dateComponent.second + ); +} + +function isDatePartValid(date: Date, dateComponent: DateComponent) { + return ( + date.getFullYear() === dateComponent.year && + date.getMonth() === dateComponent.month && + date.getDate() === dateComponent.day + ); +} + +function convertDateComponentToNumber( + dateComponent: DateComponent +): DateComponent { + 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) { + 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) { + const { hour, minute, second } = dateComponent; + return !isNaN(hour) && !isNaN(minute) && !isNaN(second); +} + +function removeTimeValues( + dateComponent: DateComponent +): DateComponent { + return { ...dateComponent, hour: 0, minute: 0, second: 0 }; +} diff --git a/apps/cast/src/worker/convert.worker.ts b/apps/cast/src/worker/convert.worker.ts new file mode 100644 index 000000000..8dae977a9 --- /dev/null +++ b/apps/cast/src/worker/convert.worker.ts @@ -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); diff --git a/apps/cast/src/worker/crypto.worker.ts b/apps/cast/src/worker/crypto.worker.ts new file mode 100644 index 000000000..784c6e4ef --- /dev/null +++ b/apps/cast/src/worker/crypto.worker.ts @@ -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); diff --git a/apps/cast/src/worker/ffmpeg.worker.ts b/apps/cast/src/worker/ffmpeg.worker.ts new file mode 100644 index 000000000..3c4f4c52e --- /dev/null +++ b/apps/cast/src/worker/ffmpeg.worker.ts @@ -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); diff --git a/apps/cast/tsconfig.json b/apps/cast/tsconfig.json new file mode 100644 index 000000000..cbdd32f74 --- /dev/null +++ b/apps/cast/tsconfig.json @@ -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"] +} diff --git a/apps/photos/public/locales/en/translation.json b/apps/photos/public/locales/en/translation.json index 48718e8e2..a6f46405d 100644 --- a/apps/photos/public/locales/en/translation.json +++ b/apps/photos/public/locales/en/translation.json @@ -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", diff --git a/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx b/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx index 3ec310876..3a3511164 100644 --- a/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx +++ b/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx @@ -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>; } export default function CollectionInfoWithOptions({ @@ -49,6 +51,7 @@ export default function CollectionInfoWithOptions({ return <>; } }; + return ( diff --git a/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx b/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx new file mode 100644 index 000000000..6219d32c4 --- /dev/null +++ b/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx @@ -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 ( + + {view === 'choose' && ( + <> + {browserCanCast && ( + <> + + {t( + 'AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE' + )} + + + { + setView('auto'); + }}> + {t('AUTO_CAST_PAIR')} + + + )} + + {t('PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE')} + + + { + setView('pin'); + }}> + {t('PAIR_WITH_PIN')} + + + )} + {view === 'auto' && ( + + + {t('CHOOSE_DEVICE_FROM_BROWSER')} + { + setView('choose'); + }}> + {t('GO_BACK')} + + + )} + {view === 'auto-cast-error' && ( + + {t('CAST_AUTO_PAIR_FAILED')} + { + setView('choose'); + }}> + {t('GO_BACK')} + + + )} + {view === 'pin' && ( + <> + {t('VISIT_CAST_ENTE_IO')} + {t('ENTER_CAST_PIN_CODE')} + + { + setView('choose'); + }}> + {t('GO_BACK')} + + + )} + + ); +} diff --git a/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx b/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx index d69940163..5c3f4c1dd 100644 --- a/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx +++ b/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx @@ -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={}> {t('SHARE_COLLECTION')} + } + onClick={handleCollectionAction( + CollectionActions.SHOW_ALBUM_CAST_DIALOG, + false + )}> + {t('CAST_ALBUM_TO_TV')} + ); } diff --git a/apps/photos/src/components/Collections/CollectionOptions/index.tsx b/apps/photos/src/components/Collections/CollectionOptions/index.tsx index 2016f5a7b..4b6b7a7cc 100644 --- a/apps/photos/src/components/Collections/CollectionOptions/index.tsx +++ b/apps/photos/src/components/Collections/CollectionOptions/index.tsx @@ -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>; } 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); diff --git a/apps/photos/src/components/Collections/index.tsx b/apps/photos/src/components/Collections/index.tsx index 8678be85b..67d919cd5 100644 --- a/apps/photos/src/components/Collections/index.tsx +++ b/apps/photos/src/components/Collections/index.tsx @@ -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( 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} /> + ); } diff --git a/package.json b/package.json index 51aa8d1d0..ba6aad353 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/shared/hooks/useCastReceiver.tsx b/packages/shared/hooks/useCastReceiver.tsx new file mode 100644 index 000000000..1301939e1 --- /dev/null +++ b/packages/shared/hooks/useCastReceiver.tsx @@ -0,0 +1,44 @@ +declare const cast: any; + +import { useEffect, useState } from 'react'; + +type Receiver = { + cast: typeof cast; +}; + +const load = (() => { + let promise: Promise | 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({ + cast: null, + }); + + useEffect(() => { + load().then((receiver) => { + setReceiver(receiver); + }); + }); + + return receiver; +}; diff --git a/packages/shared/hooks/useCastSender.tsx b/packages/shared/hooks/useCastSender.tsx new file mode 100644 index 000000000..7b9da6b42 --- /dev/null +++ b/packages/shared/hooks/useCastSender.tsx @@ -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 | 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( + { + chrome: null, + cast: null, + } + ); + + useEffect(() => { + loadSender().then((sender) => { + setSender(sender); + }); + }, []); + + return sender; +}; diff --git a/packages/shared/network/api.ts b/packages/shared/network/api.ts index b079fa246..1fa6edf1b 100644 --- a/packages/shared/network/api.ts +++ b/packages/shared/network/api.ts @@ -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) { diff --git a/packages/shared/network/cast.ts b/packages/shared/network/cast.ts new file mode 100644 index 000000000..e3e37583f --- /dev/null +++ b/packages/shared/network/cast.ts @@ -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 { + 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 { + 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(); diff --git a/packages/shared/promise/index.ts b/packages/shared/promise/index.ts new file mode 100644 index 000000000..849f18d94 --- /dev/null +++ b/packages/shared/promise/index.ts @@ -0,0 +1,23 @@ +import { CustomError } from '../error'; + +export const promiseWithTimeout = async ( + request: Promise, + timeout: number +): Promise => { + const timeoutRef = { current: null }; + const rejectOnTimeout = new Promise((_, 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, + ]); +}; diff --git a/packages/shared/sleep/index.ts b/packages/shared/sleep/index.ts new file mode 100644 index 000000000..39e64cb5f --- /dev/null +++ b/packages/shared/sleep/index.ts @@ -0,0 +1,5 @@ +export async function sleep(time: number) { + await new Promise((resolve) => { + setTimeout(() => resolve(null), time); + }); +} diff --git a/yarn.lock b/yarn.lock index 63516b073..08f2bb358 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==