Initial cast commit
This commit is contained in:
parent
775fb7f66e
commit
2415fe4cab
13
apps/cast/.eslintrc.js
Normal file
13
apps/cast/.eslintrc.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
module.exports = {
|
||||||
|
// When root is set to true, ESLint will stop looking for configuration files in parent directories.
|
||||||
|
// This is required here to ensure desktop picks the right eslint config, where this app is
|
||||||
|
// packaged as a submodule.
|
||||||
|
root: true,
|
||||||
|
extends: ['@ente/eslint-config'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: './tsconfig.json',
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
};
|
36
apps/cast/.gitignore
vendored
Normal file
36
apps/cast/.gitignore
vendored
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
40
apps/cast/README.md
Normal file
40
apps/cast/README.md
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||||
|
|
||||||
|
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
6
apps/cast/next.config.js
Normal file
6
apps/cast/next.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
24
apps/cast/package.json
Normal file
24
apps/cast/package.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "cast",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"next": "14.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.0.2"
|
||||||
|
}
|
||||||
|
}
|
BIN
apps/cast/public/favicon.ico
Normal file
BIN
apps/cast/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
1
apps/cast/public/next.svg
Normal file
1
apps/cast/public/next.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
apps/cast/public/vercel.svg
Normal file
1
apps/cast/public/vercel.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
After Width: | Height: | Size: 629 B |
53
apps/cast/sentry.client.config.js
Normal file
53
apps/cast/sentry.client.config.js
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import { getSentryTunnelURL } from 'utils/common/apiUtil';
|
||||||
|
import { getSentryUserID } from 'utils/user';
|
||||||
|
import { runningInBrowser } from 'utils/common';
|
||||||
|
import { getHasOptedOutOfCrashReports } from 'utils/storage/index';
|
||||||
|
import {
|
||||||
|
getSentryDSN,
|
||||||
|
getSentryENV,
|
||||||
|
getSentryRelease,
|
||||||
|
getIsSentryEnabled,
|
||||||
|
} from 'constants/sentry';
|
||||||
|
|
||||||
|
const HAS_OPTED_OUT_OF_CRASH_REPORTING =
|
||||||
|
runningInBrowser() && getHasOptedOutOfCrashReports();
|
||||||
|
|
||||||
|
if (!HAS_OPTED_OUT_OF_CRASH_REPORTING) {
|
||||||
|
const SENTRY_DSN = getSentryDSN();
|
||||||
|
const SENTRY_ENV = getSentryENV();
|
||||||
|
const SENTRY_RELEASE = getSentryRelease();
|
||||||
|
const IS_ENABLED = getIsSentryEnabled();
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
enabled: IS_ENABLED,
|
||||||
|
environment: SENTRY_ENV,
|
||||||
|
release: SENTRY_RELEASE,
|
||||||
|
attachStacktrace: true,
|
||||||
|
autoSessionTracking: false,
|
||||||
|
tunnel: getSentryTunnelURL(),
|
||||||
|
beforeSend(event) {
|
||||||
|
event.request = event.request || {};
|
||||||
|
const currentURL = new URL(document.location.href);
|
||||||
|
currentURL.hash = '';
|
||||||
|
event.request.url = currentURL;
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
integrations: function (i) {
|
||||||
|
return i.filter(function (i) {
|
||||||
|
return i.name !== 'Breadcrumbs';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
Sentry.setUser({ id: await getSentryUserID() });
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
|
}
|
3
apps/cast/sentry.properties
Normal file
3
apps/cast/sentry.properties
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
defaults.url=https://sentry.ente.io/
|
||||||
|
defaults.org=ente
|
||||||
|
defaults.project=photos-web
|
28
apps/cast/sentry.server.config.js
Normal file
28
apps/cast/sentry.server.config.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import {
|
||||||
|
getSentryDSN,
|
||||||
|
getSentryENV,
|
||||||
|
getSentryRelease,
|
||||||
|
getIsSentryEnabled,
|
||||||
|
} from 'constants/sentry';
|
||||||
|
|
||||||
|
import { getSentryUserID } from 'utils/user';
|
||||||
|
|
||||||
|
const SENTRY_DSN = getSentryDSN();
|
||||||
|
const SENTRY_ENV = getSentryENV();
|
||||||
|
const SENTRY_RELEASE = getSentryRelease();
|
||||||
|
const IS_ENABLED = getIsSentryEnabled();
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
enabled: IS_ENABLED,
|
||||||
|
environment: SENTRY_ENV,
|
||||||
|
release: SENTRY_RELEASE,
|
||||||
|
autoSessionTracking: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
Sentry.setUser({ id: await getSentryUserID() });
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
11
apps/cast/sentryConfigUtil.js
Normal file
11
apps/cast/sentryConfigUtil.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
const ENV_DEVELOPMENT = 'development';
|
||||||
|
|
||||||
|
module.exports.getIsSentryEnabled = () => {
|
||||||
|
if (process.env.NEXT_PUBLIC_SENTRY_ENV === ENV_DEVELOPMENT) {
|
||||||
|
return false;
|
||||||
|
} else if (process.env.NEXT_PUBLIC_DISABLE_SENTRY === 'true') {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
1
apps/cast/src/constants/api.ts
Normal file
1
apps/cast/src/constants/api.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const REQUEST_BATCH_SIZE = 1000;
|
59
apps/cast/src/constants/apps.ts
Normal file
59
apps/cast/src/constants/apps.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { PAGES } from 'constants/pages';
|
||||||
|
import { runningInBrowser } from 'utils/common';
|
||||||
|
import { getAlbumsURL, getAuthURL } from 'utils/common/apiUtil';
|
||||||
|
|
||||||
|
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());
|
||||||
|
const authURL = new URL(getAuthURL());
|
||||||
|
if (currentURL.origin === albumsURL.origin) {
|
||||||
|
return { name: APPS.ALBUMS, title: 'ente Photos' };
|
||||||
|
} else if (currentURL.origin === authURL.origin) {
|
||||||
|
return { name: APPS.AUTH, title: 'ente Auth' };
|
||||||
|
} else {
|
||||||
|
return { name: APPS.PHOTOS, title: 'ente Photos' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAppTitle = () => {
|
||||||
|
return getAppNameAndTitle().title;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAppName = () => {
|
||||||
|
return getAppNameAndTitle().name;
|
||||||
|
};
|
5
apps/cast/src/constants/cache.ts
Normal file
5
apps/cast/src/constants/cache.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export enum CACHES {
|
||||||
|
THUMBS = 'thumbs',
|
||||||
|
FACE_CROPS = 'face-crops',
|
||||||
|
FILES = 'files',
|
||||||
|
}
|
100
apps/cast/src/constants/collection.ts
Normal file
100
apps/cast/src/constants/collection.ts
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
export const ARCHIVE_SECTION = -1;
|
||||||
|
export const TRASH_SECTION = -2;
|
||||||
|
export const DUMMY_UNCATEGORIZED_COLLECTION = -3;
|
||||||
|
export const HIDDEN_ITEMS_SECTION = -4;
|
||||||
|
export const ALL_SECTION = 0;
|
||||||
|
export const DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME = 'Hidden';
|
||||||
|
|
||||||
|
export enum CollectionType {
|
||||||
|
folder = 'folder',
|
||||||
|
favorites = 'favorites',
|
||||||
|
album = 'album',
|
||||||
|
uncategorized = 'uncategorized',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CollectionSummaryType {
|
||||||
|
folder = 'folder',
|
||||||
|
favorites = 'favorites',
|
||||||
|
album = 'album',
|
||||||
|
archive = 'archive',
|
||||||
|
trash = 'trash',
|
||||||
|
uncategorized = 'uncategorized',
|
||||||
|
all = 'all',
|
||||||
|
outgoingShare = 'outgoingShare',
|
||||||
|
incomingShareViewer = 'incomingShareViewer',
|
||||||
|
incomingShareCollaborator = 'incomingShareCollaborator',
|
||||||
|
sharedOnlyViaLink = 'sharedOnlyViaLink',
|
||||||
|
archived = 'archived',
|
||||||
|
defaultHidden = 'defaultHidden',
|
||||||
|
hiddenItems = 'hiddenItems',
|
||||||
|
pinned = 'pinned',
|
||||||
|
}
|
||||||
|
export enum COLLECTION_LIST_SORT_BY {
|
||||||
|
NAME,
|
||||||
|
CREATION_TIME_ASCENDING,
|
||||||
|
UPDATION_TIME_DESCENDING,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLLECTION_SHARE_DEFAULT_VALID_DURATION =
|
||||||
|
10 * 24 * 60 * 60 * 1000 * 1000;
|
||||||
|
export const COLLECTION_SHARE_DEFAULT_DEVICE_LIMIT = 4;
|
||||||
|
|
||||||
|
export const COLLECTION_SORT_ORDER = new Map([
|
||||||
|
[CollectionSummaryType.all, 0],
|
||||||
|
[CollectionSummaryType.hiddenItems, 0],
|
||||||
|
[CollectionSummaryType.uncategorized, 1],
|
||||||
|
[CollectionSummaryType.favorites, 2],
|
||||||
|
[CollectionSummaryType.pinned, 3],
|
||||||
|
[CollectionSummaryType.album, 4],
|
||||||
|
[CollectionSummaryType.folder, 4],
|
||||||
|
[CollectionSummaryType.incomingShareViewer, 4],
|
||||||
|
[CollectionSummaryType.incomingShareCollaborator, 4],
|
||||||
|
[CollectionSummaryType.outgoingShare, 4],
|
||||||
|
[CollectionSummaryType.sharedOnlyViaLink, 4],
|
||||||
|
[CollectionSummaryType.archived, 4],
|
||||||
|
[CollectionSummaryType.archive, 5],
|
||||||
|
[CollectionSummaryType.trash, 6],
|
||||||
|
[CollectionSummaryType.defaultHidden, 7],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SYSTEM_COLLECTION_TYPES = new Set([
|
||||||
|
CollectionSummaryType.all,
|
||||||
|
CollectionSummaryType.archive,
|
||||||
|
CollectionSummaryType.trash,
|
||||||
|
CollectionSummaryType.uncategorized,
|
||||||
|
CollectionSummaryType.hiddenItems,
|
||||||
|
CollectionSummaryType.defaultHidden,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ADD_TO_NOT_ALLOWED_COLLECTION = new Set([
|
||||||
|
CollectionSummaryType.all,
|
||||||
|
CollectionSummaryType.archive,
|
||||||
|
CollectionSummaryType.incomingShareViewer,
|
||||||
|
CollectionSummaryType.trash,
|
||||||
|
CollectionSummaryType.uncategorized,
|
||||||
|
CollectionSummaryType.defaultHidden,
|
||||||
|
CollectionSummaryType.hiddenItems,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MOVE_TO_NOT_ALLOWED_COLLECTION = new Set([
|
||||||
|
CollectionSummaryType.all,
|
||||||
|
CollectionSummaryType.archive,
|
||||||
|
CollectionSummaryType.incomingShareViewer,
|
||||||
|
CollectionSummaryType.incomingShareCollaborator,
|
||||||
|
CollectionSummaryType.trash,
|
||||||
|
CollectionSummaryType.uncategorized,
|
||||||
|
CollectionSummaryType.defaultHidden,
|
||||||
|
CollectionSummaryType.hiddenItems,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const OPTIONS_NOT_HAVING_COLLECTION_TYPES = new Set([
|
||||||
|
CollectionSummaryType.all,
|
||||||
|
CollectionSummaryType.archive,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const HIDE_FROM_COLLECTION_BAR_TYPES = new Set([
|
||||||
|
CollectionSummaryType.trash,
|
||||||
|
CollectionSummaryType.archive,
|
||||||
|
CollectionSummaryType.uncategorized,
|
||||||
|
CollectionSummaryType.defaultHidden,
|
||||||
|
]);
|
7
apps/cast/src/constants/crypto.ts
Normal file
7
apps/cast/src/constants/crypto.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export const ENCRYPTION_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
export enum PasswordStrength {
|
||||||
|
WEAK = 'WEAK',
|
||||||
|
MODERATE = 'MODERATE',
|
||||||
|
STRONG = 'STRONG',
|
||||||
|
}
|
43
apps/cast/src/constants/file.ts
Normal file
43
apps/cast/src/constants/file.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
export const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1);
|
||||||
|
export const MAX_EDITED_CREATION_TIME = new Date();
|
||||||
|
|
||||||
|
export const MAX_EDITED_FILE_NAME_LENGTH = 100;
|
||||||
|
export const MAX_CAPTION_SIZE = 5000;
|
||||||
|
|
||||||
|
export const TYPE_HEIC = 'heic';
|
||||||
|
export const TYPE_HEIF = 'heif';
|
||||||
|
export const TYPE_JPEG = 'jpeg';
|
||||||
|
export const TYPE_JPG = 'jpg';
|
||||||
|
|
||||||
|
export enum FILE_TYPE {
|
||||||
|
IMAGE,
|
||||||
|
VIDEO,
|
||||||
|
LIVE_PHOTO,
|
||||||
|
OTHERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RAW_FORMATS = [
|
||||||
|
'heic',
|
||||||
|
'rw2',
|
||||||
|
'tiff',
|
||||||
|
'arw',
|
||||||
|
'cr3',
|
||||||
|
'cr2',
|
||||||
|
'raf',
|
||||||
|
'nef',
|
||||||
|
'psd',
|
||||||
|
'dng',
|
||||||
|
'tif',
|
||||||
|
];
|
||||||
|
export const SUPPORTED_RAW_FORMATS = [
|
||||||
|
'heic',
|
||||||
|
'rw2',
|
||||||
|
'tiff',
|
||||||
|
'arw',
|
||||||
|
'cr3',
|
||||||
|
'cr2',
|
||||||
|
'nef',
|
||||||
|
'psd',
|
||||||
|
'dng',
|
||||||
|
'tif',
|
||||||
|
];
|
15
apps/cast/src/constants/gallery.ts
Normal file
15
apps/cast/src/constants/gallery.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export const GAP_BTW_TILES = 4;
|
||||||
|
export const DATE_CONTAINER_HEIGHT = 48;
|
||||||
|
export const SIZE_AND_COUNT_CONTAINER_HEIGHT = 72;
|
||||||
|
export const IMAGE_CONTAINER_MAX_HEIGHT = 180;
|
||||||
|
export const IMAGE_CONTAINER_MAX_WIDTH = 180;
|
||||||
|
export const MIN_COLUMNS = 4;
|
||||||
|
export const SPACE_BTW_DATES = 44;
|
||||||
|
export const SPACE_BTW_DATES_TO_IMAGE_CONTAINER_WIDTH_RATIO = 0.244;
|
||||||
|
|
||||||
|
export enum PLAN_PERIOD {
|
||||||
|
MONTH = 'month',
|
||||||
|
YEAR = 'year',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYNC_INTERVAL_IN_MICROSECONDS = 1000 * 60 * 5; // 5 minutes
|
8
apps/cast/src/constants/locale.ts
Normal file
8
apps/cast/src/constants/locale.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/** Enums of supported locale */
|
||||||
|
export enum Language {
|
||||||
|
en = 'en',
|
||||||
|
fr = 'fr',
|
||||||
|
zh = 'zh',
|
||||||
|
nl = 'nl',
|
||||||
|
es = 'es',
|
||||||
|
}
|
20
apps/cast/src/constants/pages.ts
Normal file
20
apps/cast/src/constants/pages.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
export enum PAGES {
|
||||||
|
CHANGE_EMAIL = '/change-email',
|
||||||
|
CHANGE_PASSWORD = '/change-password',
|
||||||
|
CREDENTIALS = '/credentials',
|
||||||
|
GALLERY = '/gallery',
|
||||||
|
GENERATE = '/generate',
|
||||||
|
LOGIN = '/login',
|
||||||
|
RECOVER = '/recover',
|
||||||
|
SIGNUP = '/signup',
|
||||||
|
TWO_FACTOR_SETUP = '/two-factor/setup',
|
||||||
|
TWO_FACTOR_VERIFY = '/two-factor/verify',
|
||||||
|
TWO_FACTOR_RECOVER = '/two-factor/recover',
|
||||||
|
VERIFY = '/verify',
|
||||||
|
ROOT = '/',
|
||||||
|
SHARED_ALBUMS = '/shared-albums',
|
||||||
|
// ML_DEBUG = '/ml-debug',
|
||||||
|
DEDUPLICATE = '/deduplicate',
|
||||||
|
// AUTH page is used to show (auth)enticator codes
|
||||||
|
AUTH = '/auth',
|
||||||
|
}
|
15
apps/cast/src/constants/sentry.ts
Normal file
15
apps/cast/src/constants/sentry.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export const ENV_DEVELOPMENT = 'development';
|
||||||
|
export const ENV_PRODUCTION = 'production';
|
||||||
|
|
||||||
|
export const getSentryDSN = () =>
|
||||||
|
process.env.NEXT_PUBLIC_SENTRY_DSN ??
|
||||||
|
'https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2';
|
||||||
|
|
||||||
|
export const getSentryENV = () =>
|
||||||
|
process.env.NEXT_PUBLIC_SENTRY_ENV ?? ENV_PRODUCTION;
|
||||||
|
|
||||||
|
export const getSentryRelease = () => process.env.SENTRY_RELEASE;
|
||||||
|
|
||||||
|
export { getIsSentryEnabled } from '../../sentryConfigUtil';
|
||||||
|
|
||||||
|
export const isDEVSentryENV = () => getSentryENV() === ENV_DEVELOPMENT;
|
142
apps/cast/src/constants/upload.ts
Normal file
142
apps/cast/src/constants/upload.ts
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
import { ENCRYPTION_CHUNK_SIZE } from 'constants/crypto';
|
||||||
|
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=';
|
19
apps/cast/src/constants/urls.ts
Normal file
19
apps/cast/src/constants/urls.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export const ENTE_WEBSITE_LINK = 'https://ente.io';
|
||||||
|
|
||||||
|
export const ML_BLOG_LINK = 'https://ente.io/blog/desktop-ml-beta';
|
||||||
|
|
||||||
|
export const FACE_SEARCH_PRIVACY_POLICY_LINK =
|
||||||
|
'https://ente.io/privacy#8-biometric-information-privacy-policy';
|
||||||
|
|
||||||
|
export const SUPPORT_EMAIL = 'support@ente.io';
|
||||||
|
|
||||||
|
export const APP_DOWNLOAD_URL = 'https://ente.io/download/desktop';
|
||||||
|
|
||||||
|
export const FEEDBACK_EMAIL = 'feedback@ente.io';
|
||||||
|
|
||||||
|
export const DELETE_ACCOUNT_EMAIL = 'account-deletion@ente.io';
|
||||||
|
|
||||||
|
export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues';
|
||||||
|
|
||||||
|
export const DESKTOP_ROADMAP_URL =
|
||||||
|
'https://github.com/ente-io/photos-desktop/issues';
|
6
apps/cast/src/pages/_app.tsx
Normal file
6
apps/cast/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import '/styles/globals.css';
|
||||||
|
import type { AppProps } from 'next/app';
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
|
return <Component {...pageProps} />;
|
||||||
|
}
|
13
apps/cast/src/pages/_document.tsx
Normal file
13
apps/cast/src/pages/_document.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Html, Head, Main, NextScript } from 'next/document';
|
||||||
|
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<Html lang="en">
|
||||||
|
<Head />
|
||||||
|
<body>
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
118
apps/cast/src/pages/index.tsx
Normal file
118
apps/cast/src/pages/index.tsx
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import styles from '/styles/Home.module.css';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Create Next App</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Generated by create next app"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1"
|
||||||
|
/>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<main className={`${styles.main} ${inter.className}`}>
|
||||||
|
<div className={styles.description}>
|
||||||
|
<p>
|
||||||
|
Get started by editing
|
||||||
|
<code className={styles.code}>src/pages/index.tsx</code>
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
By{' '}
|
||||||
|
<Image
|
||||||
|
src="/vercel.svg"
|
||||||
|
alt="Vercel Logo"
|
||||||
|
className={styles.vercelLogo}
|
||||||
|
width={100}
|
||||||
|
height={24}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.center}>
|
||||||
|
<Image
|
||||||
|
className={styles.logo}
|
||||||
|
src="/next.svg"
|
||||||
|
alt="Next.js Logo"
|
||||||
|
width={180}
|
||||||
|
height={37}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||||
|
className={styles.card}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
<h2>
|
||||||
|
Docs <span>-></span>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Find in-depth information about Next.js features
|
||||||
|
and API.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||||
|
className={styles.card}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
<h2>
|
||||||
|
Learn <span>-></span>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Learn about Next.js in an interactive course
|
||||||
|
with quizzes!
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||||
|
className={styles.card}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
<h2>
|
||||||
|
Templates <span>-></span>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Discover and deploy boilerplate example
|
||||||
|
Next.js projects.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||||
|
className={styles.card}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
<h2>
|
||||||
|
Deploy <span>-></span>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Instantly deploy your Next.js site to a shareable
|
||||||
|
URL with Vercel.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
241
apps/cast/src/services/HTTPService.ts
Normal file
241
apps/cast/src/services/HTTPService.ts
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import { addLogLine } from 'utils/logging';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import { ApiError, CustomError, isApiErrorResponse } from 'utils/error';
|
||||||
|
|
||||||
|
interface IHTTPHeaders {
|
||||||
|
[headerKey: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IQueryPrams {
|
||||||
|
[paramName: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to manage all HTTP calls.
|
||||||
|
*/
|
||||||
|
class HTTPService {
|
||||||
|
constructor() {
|
||||||
|
axios.interceptors.response.use(
|
||||||
|
(response) => Promise.resolve(response),
|
||||||
|
(error) => {
|
||||||
|
const config = error.config as AxiosRequestConfig;
|
||||||
|
if (error.response) {
|
||||||
|
const response = error.response as AxiosResponse;
|
||||||
|
let apiError: ApiError;
|
||||||
|
// The request was made and the server responded with a status code
|
||||||
|
// that falls out of the range of 2xx
|
||||||
|
if (isApiErrorResponse(response.data)) {
|
||||||
|
const responseData = response.data;
|
||||||
|
logError(error, 'HTTP Service Error', {
|
||||||
|
url: config.url,
|
||||||
|
method: config.method,
|
||||||
|
xRequestId: response.headers['x-request-id'],
|
||||||
|
httpStatus: response.status,
|
||||||
|
errMessage: responseData.message,
|
||||||
|
errCode: responseData.code,
|
||||||
|
});
|
||||||
|
apiError = new ApiError(
|
||||||
|
responseData.message,
|
||||||
|
responseData.code,
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (response.status >= 400 && response.status < 500) {
|
||||||
|
apiError = new ApiError(
|
||||||
|
CustomError.CLIENT_ERROR,
|
||||||
|
'',
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
apiError = new ApiError(
|
||||||
|
CustomError.ServerError,
|
||||||
|
'',
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logError(apiError, 'HTTP Service Error', {
|
||||||
|
url: config.url,
|
||||||
|
method: config.method,
|
||||||
|
cfRay: response.headers['cf-ray'],
|
||||||
|
xRequestId: response.headers['x-request-id'],
|
||||||
|
httpStatus: response.status,
|
||||||
|
});
|
||||||
|
throw apiError;
|
||||||
|
} else if (error.request) {
|
||||||
|
// The request was made but no response was received
|
||||||
|
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||||
|
// http.ClientRequest in node.js
|
||||||
|
addLogLine(
|
||||||
|
'request failed- no response',
|
||||||
|
`url: ${config.url}`,
|
||||||
|
`method: ${config.method}`
|
||||||
|
);
|
||||||
|
return Promise.reject(error);
|
||||||
|
} else {
|
||||||
|
// Something happened in setting up the request that triggered an Error
|
||||||
|
addLogLine(
|
||||||
|
'request failed- axios error',
|
||||||
|
`url: ${config.url}`,
|
||||||
|
`method: ${config.method}`
|
||||||
|
);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* header object to be append to all api calls.
|
||||||
|
*/
|
||||||
|
private headers: IHTTPHeaders = {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the headers to the given object.
|
||||||
|
*/
|
||||||
|
public setHeaders(headers: IHTTPHeaders) {
|
||||||
|
this.headers = {
|
||||||
|
...this.headers,
|
||||||
|
...headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a header to list of headers.
|
||||||
|
*/
|
||||||
|
public appendHeader(key: string, value: string) {
|
||||||
|
this.headers = {
|
||||||
|
...this.headers,
|
||||||
|
[key]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the given header.
|
||||||
|
*/
|
||||||
|
public removeHeader(key: string) {
|
||||||
|
this.headers[key] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns axios interceptors.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
public getInterceptors() {
|
||||||
|
return axios.interceptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic HTTP request.
|
||||||
|
* This is done so that developer can use any functionality
|
||||||
|
* provided by axios. Here, only the set headers are spread
|
||||||
|
* over what was sent in config.
|
||||||
|
*/
|
||||||
|
public async request(config: AxiosRequestConfig, customConfig?: any) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
config.headers = {
|
||||||
|
...this.headers,
|
||||||
|
...config.headers,
|
||||||
|
};
|
||||||
|
if (customConfig?.cancel) {
|
||||||
|
config.cancelToken = new axios.CancelToken(
|
||||||
|
(c) => (customConfig.cancel.exec = c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await axios({ ...config, ...customConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get request.
|
||||||
|
*/
|
||||||
|
public get(
|
||||||
|
url: string,
|
||||||
|
params?: IQueryPrams,
|
||||||
|
headers?: IHTTPHeaders,
|
||||||
|
customConfig?: any
|
||||||
|
) {
|
||||||
|
return this.request(
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
customConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post request
|
||||||
|
*/
|
||||||
|
public post(
|
||||||
|
url: string,
|
||||||
|
data?: any,
|
||||||
|
params?: IQueryPrams,
|
||||||
|
headers?: IHTTPHeaders,
|
||||||
|
customConfig?: any
|
||||||
|
) {
|
||||||
|
return this.request(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
headers,
|
||||||
|
method: 'POST',
|
||||||
|
params,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
customConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put request
|
||||||
|
*/
|
||||||
|
public put(
|
||||||
|
url: string,
|
||||||
|
data: any,
|
||||||
|
params?: IQueryPrams,
|
||||||
|
headers?: IHTTPHeaders,
|
||||||
|
customConfig?: any
|
||||||
|
) {
|
||||||
|
return this.request(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
headers,
|
||||||
|
method: 'PUT',
|
||||||
|
params,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
customConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete request
|
||||||
|
*/
|
||||||
|
public delete(
|
||||||
|
url: string,
|
||||||
|
data: any,
|
||||||
|
params?: IQueryPrams,
|
||||||
|
headers?: IHTTPHeaders,
|
||||||
|
customConfig?: any
|
||||||
|
) {
|
||||||
|
return this.request(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
headers,
|
||||||
|
method: 'DELETE',
|
||||||
|
params,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
customConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a Singleton Service.
|
||||||
|
// This will help me maintain common headers / functionality
|
||||||
|
// at a central place.
|
||||||
|
export default new HTTPService();
|
32
apps/cast/src/services/InMemoryStore.ts
Normal file
32
apps/cast/src/services/InMemoryStore.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
export enum MS_KEYS {
|
||||||
|
OPT_OUT_OF_CRASH_REPORTS = 'optOutOfCrashReports',
|
||||||
|
SRP_CONFIGURE_IN_PROGRESS = 'srpConfigureInProgress',
|
||||||
|
REDIRECT_URL = 'redirectUrl',
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoreType = Map<Partial<MS_KEYS>, any>;
|
||||||
|
|
||||||
|
class InMemoryStore {
|
||||||
|
private store: StoreType = new Map();
|
||||||
|
|
||||||
|
get(key: MS_KEYS) {
|
||||||
|
return this.store.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: MS_KEYS, value: any) {
|
||||||
|
this.store.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: MS_KEYS) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: MS_KEYS) {
|
||||||
|
return this.store.has(key);
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new InMemoryStore();
|
1467
apps/cast/src/services/collectionService.ts
Normal file
1467
apps/cast/src/services/collectionService.ts
Normal file
File diff suppressed because it is too large
Load diff
12
apps/cast/src/services/events.ts
Normal file
12
apps/cast/src/services/events.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
|
||||||
|
// When registering event handlers,
|
||||||
|
// handle errors to avoid unhandled rejection or propagation to emit call
|
||||||
|
|
||||||
|
export enum Events {
|
||||||
|
LOGOUT = 'logout',
|
||||||
|
FILE_UPLOADED = 'fileUploaded',
|
||||||
|
LOCAL_FILES_UPDATED = 'localFilesUpdated',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventBus = new EventEmitter<Events>();
|
311
apps/cast/src/services/fileService.ts
Normal file
311
apps/cast/src/services/fileService.ts
Normal file
|
@ -0,0 +1,311 @@
|
||||||
|
import { getEndpoint } from 'utils/common/apiUtil';
|
||||||
|
import localForage from 'utils/storage/localForage';
|
||||||
|
|
||||||
|
import { getToken } from 'utils/common/key';
|
||||||
|
import { Collection } from 'types/collection';
|
||||||
|
import HTTPService from './HTTPService';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
getLatestVersionFiles,
|
||||||
|
mergeMetadata,
|
||||||
|
sortFiles,
|
||||||
|
} from 'utils/file';
|
||||||
|
import { eventBus, Events } from './events';
|
||||||
|
import {
|
||||||
|
EnteFile,
|
||||||
|
EncryptedEnteFile,
|
||||||
|
TrashRequest,
|
||||||
|
FileWithUpdatedMagicMetadata,
|
||||||
|
FileWithUpdatedPublicMagicMetadata,
|
||||||
|
} from 'types/file';
|
||||||
|
import { SetFiles } from 'types/gallery';
|
||||||
|
import { BulkUpdateMagicMetadataRequest } from 'types/magicMetadata';
|
||||||
|
import { addLogLine } from 'utils/logging';
|
||||||
|
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||||
|
import {
|
||||||
|
getCollectionLastSyncTime,
|
||||||
|
setCollectionLastSyncTime,
|
||||||
|
} from './collectionService';
|
||||||
|
import { REQUEST_BATCH_SIZE } from 'constants/api';
|
||||||
|
import { batch } from 'utils/common';
|
||||||
|
|
||||||
|
const ENDPOINT = getEndpoint();
|
||||||
|
const FILES_TABLE = 'files';
|
||||||
|
const HIDDEN_FILES_TABLE = 'hidden-files';
|
||||||
|
|
||||||
|
export const getLocalFiles = async (type: 'normal' | 'hidden' = 'normal') => {
|
||||||
|
const tableName = type === 'normal' ? FILES_TABLE : HIDDEN_FILES_TABLE;
|
||||||
|
const files: Array<EnteFile> =
|
||||||
|
(await localForage.getItem<EnteFile[]>(tableName)) || [];
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLocalFiles = async (type: 'normal' | 'hidden', files: EnteFile[]) => {
|
||||||
|
try {
|
||||||
|
const tableName = type === 'normal' ? FILES_TABLE : HIDDEN_FILES_TABLE;
|
||||||
|
await localForage.setItem(tableName, files);
|
||||||
|
try {
|
||||||
|
eventBus.emit(Events.LOCAL_FILES_UPDATED);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'Error in localFileUpdated handlers');
|
||||||
|
}
|
||||||
|
} catch (e1) {
|
||||||
|
try {
|
||||||
|
const storageEstimate = await navigator.storage.estimate();
|
||||||
|
logError(e1, 'failed to save files to indexedDB', {
|
||||||
|
storageEstimate,
|
||||||
|
});
|
||||||
|
addLogLine(`storage estimate ${JSON.stringify(storageEstimate)}`);
|
||||||
|
} catch (e2) {
|
||||||
|
logError(e1, 'failed to save files to indexedDB');
|
||||||
|
logError(e2, 'failed to get storage stats');
|
||||||
|
}
|
||||||
|
throw e1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLocalFiles = async () => {
|
||||||
|
const normalFiles = await getLocalFiles('normal');
|
||||||
|
const hiddenFiles = await getLocalFiles('hidden');
|
||||||
|
return [...normalFiles, ...hiddenFiles];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncFiles = async (
|
||||||
|
type: 'normal' | 'hidden',
|
||||||
|
collections: Collection[],
|
||||||
|
setFiles: SetFiles
|
||||||
|
) => {
|
||||||
|
const localFiles = await getLocalFiles(type);
|
||||||
|
let files = await removeDeletedCollectionFiles(collections, localFiles);
|
||||||
|
if (files.length !== localFiles.length) {
|
||||||
|
await setLocalFiles(type, files);
|
||||||
|
setFiles(sortFiles(mergeMetadata(files)));
|
||||||
|
}
|
||||||
|
for (const collection of collections) {
|
||||||
|
if (!getToken()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lastSyncTime = await getCollectionLastSyncTime(collection);
|
||||||
|
if (collection.updationTime === lastSyncTime) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = await getFiles(collection, lastSyncTime, setFiles);
|
||||||
|
files = getLatestVersionFiles([...files, ...newFiles]);
|
||||||
|
await setLocalFiles(type, files);
|
||||||
|
setCollectionLastSyncTime(collection, collection.updationTime);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFiles = async (
|
||||||
|
collection: Collection,
|
||||||
|
sinceTime: number,
|
||||||
|
setFiles: SetFiles
|
||||||
|
): Promise<EnteFile[]> => {
|
||||||
|
try {
|
||||||
|
let decryptedFiles: EnteFile[] = [];
|
||||||
|
let time = sinceTime;
|
||||||
|
let resp;
|
||||||
|
do {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/collections/v2/diff`,
|
||||||
|
{
|
||||||
|
collectionID: collection.id,
|
||||||
|
sinceTime: time,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const newDecryptedFilesBatch = await Promise.all(
|
||||||
|
resp.data.diff.map(async (file: EncryptedEnteFile) => {
|
||||||
|
if (!file.isDeleted) {
|
||||||
|
return await decryptFile(file, collection.key);
|
||||||
|
} else {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}) as Promise<EnteFile>[]
|
||||||
|
);
|
||||||
|
decryptedFiles = [...decryptedFiles, ...newDecryptedFilesBatch];
|
||||||
|
|
||||||
|
setFiles((files) =>
|
||||||
|
sortFiles(
|
||||||
|
mergeMetadata(
|
||||||
|
getLatestVersionFiles([
|
||||||
|
...(files || []),
|
||||||
|
...decryptedFiles,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (resp.data.diff.length) {
|
||||||
|
time = resp.data.diff.slice(-1)[0].updationTime;
|
||||||
|
}
|
||||||
|
} while (resp.data.hasMore);
|
||||||
|
return decryptedFiles;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'Get files failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDeletedCollectionFiles = async (
|
||||||
|
collections: Collection[],
|
||||||
|
files: EnteFile[]
|
||||||
|
) => {
|
||||||
|
const syncedCollectionIds = new Set<number>();
|
||||||
|
for (const collection of collections) {
|
||||||
|
syncedCollectionIds.add(collection.id);
|
||||||
|
}
|
||||||
|
files = files.filter((file) => syncedCollectionIds.has(file.collectionID));
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const trashFiles = async (filesToTrash: EnteFile[]) => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const batchedFilesToTrash = batch(filesToTrash, REQUEST_BATCH_SIZE);
|
||||||
|
for (const batch of batchedFilesToTrash) {
|
||||||
|
const trashRequest: TrashRequest = {
|
||||||
|
items: batch.map((file) => ({
|
||||||
|
fileID: file.id,
|
||||||
|
collectionID: file.collectionID,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await HTTPService.post(
|
||||||
|
`${ENDPOINT}/files/trash`,
|
||||||
|
trashRequest,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'trash file failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFromTrash = async (filesToDelete: number[]) => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const batchedFilesToDelete = batch(filesToDelete, REQUEST_BATCH_SIZE);
|
||||||
|
|
||||||
|
for (const batch of batchedFilesToDelete) {
|
||||||
|
await HTTPService.post(
|
||||||
|
`${ENDPOINT}/trash/delete`,
|
||||||
|
{ fileIDs: batch },
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'deleteFromTrash failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFileMagicMetadata = async (
|
||||||
|
fileWithUpdatedMagicMetadataList: FileWithUpdatedMagicMetadata[]
|
||||||
|
) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqBody: BulkUpdateMagicMetadataRequest = { metadataList: [] };
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
for (const {
|
||||||
|
file,
|
||||||
|
updatedMagicMetadata,
|
||||||
|
} of fileWithUpdatedMagicMetadataList) {
|
||||||
|
const { file: encryptedMagicMetadata } =
|
||||||
|
await cryptoWorker.encryptMetadata(
|
||||||
|
updatedMagicMetadata.data,
|
||||||
|
file.key
|
||||||
|
);
|
||||||
|
reqBody.metadataList.push({
|
||||||
|
id: file.id,
|
||||||
|
magicMetadata: {
|
||||||
|
version: updatedMagicMetadata.version,
|
||||||
|
count: updatedMagicMetadata.count,
|
||||||
|
data: encryptedMagicMetadata.encryptedData,
|
||||||
|
header: encryptedMagicMetadata.decryptionHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await HTTPService.put(`${ENDPOINT}/files/magic-metadata`, reqBody, null, {
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
});
|
||||||
|
return fileWithUpdatedMagicMetadataList.map(
|
||||||
|
({ file, updatedMagicMetadata }): EnteFile => ({
|
||||||
|
...file,
|
||||||
|
magicMetadata: {
|
||||||
|
...updatedMagicMetadata,
|
||||||
|
version: updatedMagicMetadata.version + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFilePublicMagicMetadata = async (
|
||||||
|
fileWithUpdatedPublicMagicMetadataList: FileWithUpdatedPublicMagicMetadata[]
|
||||||
|
): Promise<EnteFile[]> => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqBody: BulkUpdateMagicMetadataRequest = { metadataList: [] };
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
for (const {
|
||||||
|
file,
|
||||||
|
updatedPublicMagicMetadata: updatePublicMagicMetadata,
|
||||||
|
} of fileWithUpdatedPublicMagicMetadataList) {
|
||||||
|
const { file: encryptedPubMagicMetadata } =
|
||||||
|
await cryptoWorker.encryptMetadata(
|
||||||
|
updatePublicMagicMetadata.data,
|
||||||
|
file.key
|
||||||
|
);
|
||||||
|
reqBody.metadataList.push({
|
||||||
|
id: file.id,
|
||||||
|
magicMetadata: {
|
||||||
|
version: updatePublicMagicMetadata.version,
|
||||||
|
count: updatePublicMagicMetadata.count,
|
||||||
|
data: encryptedPubMagicMetadata.encryptedData,
|
||||||
|
header: encryptedPubMagicMetadata.decryptionHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await HTTPService.put(
|
||||||
|
`${ENDPOINT}/files/public-magic-metadata`,
|
||||||
|
reqBody,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return fileWithUpdatedPublicMagicMetadataList.map(
|
||||||
|
({ file, updatedPublicMagicMetadata }): EnteFile => ({
|
||||||
|
...file,
|
||||||
|
pubMagicMetadata: {
|
||||||
|
...updatedPublicMagicMetadata,
|
||||||
|
version: updatedPublicMagicMetadata.version + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
757
apps/cast/src/services/userService.ts
Normal file
757
apps/cast/src/services/userService.ts
Normal file
|
@ -0,0 +1,757 @@
|
||||||
|
import { PAGES } from 'constants/pages';
|
||||||
|
import {
|
||||||
|
getEndpoint,
|
||||||
|
getFamilyPortalURL,
|
||||||
|
isDevDeployment,
|
||||||
|
} from 'utils/common/apiUtil';
|
||||||
|
import { clearKeys } from 'utils/storage/sessionStorage';
|
||||||
|
import router from 'next/router';
|
||||||
|
import { clearData, getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||||
|
import localForage from 'utils/storage/localForage';
|
||||||
|
import { getToken } from 'utils/common/key';
|
||||||
|
import HTTPService from './HTTPService';
|
||||||
|
import {
|
||||||
|
computeVerifierHelper,
|
||||||
|
generateLoginSubKey,
|
||||||
|
generateSRPClient,
|
||||||
|
getRecoveryKey,
|
||||||
|
} from 'utils/crypto';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import { eventBus, Events } from './events';
|
||||||
|
import {
|
||||||
|
KeyAttributes,
|
||||||
|
RecoveryKey,
|
||||||
|
TwoFactorSecret,
|
||||||
|
TwoFactorVerificationResponse,
|
||||||
|
TwoFactorRecoveryResponse,
|
||||||
|
UserDetails,
|
||||||
|
DeleteChallengeResponse,
|
||||||
|
GetRemoteStoreValueResponse,
|
||||||
|
SetupSRPRequest,
|
||||||
|
CreateSRPSessionResponse,
|
||||||
|
UserVerificationResponse,
|
||||||
|
GetFeatureFlagResponse,
|
||||||
|
SetupSRPResponse,
|
||||||
|
CompleteSRPSetupRequest,
|
||||||
|
CompleteSRPSetupResponse,
|
||||||
|
SRPSetupAttributes,
|
||||||
|
SRPAttributes,
|
||||||
|
UpdateSRPAndKeysRequest,
|
||||||
|
UpdateSRPAndKeysResponse,
|
||||||
|
GetSRPAttributesResponse,
|
||||||
|
} from 'types/user';
|
||||||
|
import { ApiError, CustomError } from 'utils/error';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
// import safeStorageService from './electron/safeStorage';
|
||||||
|
// import { deleteAllCache } from 'utils/storage/cache';
|
||||||
|
import { B64EncryptionResult } from 'types/crypto';
|
||||||
|
import { getLocalFamilyData, isPartOfFamily } from 'utils/user/family';
|
||||||
|
import { AxiosResponse, HttpStatusCode } from 'axios';
|
||||||
|
import { APPS, getAppName } from 'constants/apps';
|
||||||
|
import { addLocalLog } from 'utils/logging';
|
||||||
|
import { convertBase64ToBuffer, convertBufferToBase64 } from 'utils/user';
|
||||||
|
import { setLocalMapEnabled } from 'utils/storage';
|
||||||
|
import InMemoryStore, { MS_KEYS } from './InMemoryStore';
|
||||||
|
|
||||||
|
const ENDPOINT = getEndpoint();
|
||||||
|
|
||||||
|
const HAS_SET_KEYS = 'hasSetKeys';
|
||||||
|
|
||||||
|
export const sendOtt = (email: string) => {
|
||||||
|
const appName = getAppName();
|
||||||
|
return HTTPService.post(`${ENDPOINT}/users/ott`, {
|
||||||
|
email,
|
||||||
|
client: appName === APPS.AUTH ? 'totp' : 'web',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPublicKey = async (email: string) => {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/public-key`,
|
||||||
|
{ email },
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data.publicKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPaymentToken = async () => {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/payment-token`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data['paymentToken'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFamiliesToken = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/families-token`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data['familiesToken'];
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get family token');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRoadmapRedirectURL = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/roadmap/v2`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data['url'];
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get roadmap url');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyOtt = (email: string, ott: string) =>
|
||||||
|
HTTPService.post(`${ENDPOINT}/users/verify-email`, { email, ott });
|
||||||
|
|
||||||
|
export const putAttributes = (token: string, keyAttributes: KeyAttributes) =>
|
||||||
|
HTTPService.put(`${ENDPOINT}/users/attributes`, { keyAttributes }, null, {
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setRecoveryKey = (token: string, recoveryKey: RecoveryKey) =>
|
||||||
|
HTTPService.put(`${ENDPOINT}/users/recovery-key`, recoveryKey, null, {
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logoutUser = async () => {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
// ignore server logout result as logoutUser can be triggered before sign up or on token expiry
|
||||||
|
await _logout();
|
||||||
|
} catch (e) {
|
||||||
|
//ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
InMemoryStore.clear();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'clear InMemoryStore failed');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
clearKeys();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'clearKeys failed');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
clearData();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'clearData failed');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// await deleteAllCache();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'deleteAllCache failed');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await clearFiles();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'clearFiles failed');
|
||||||
|
}
|
||||||
|
if (isElectron()) {
|
||||||
|
try {
|
||||||
|
// safeStorageService.clearElectronStore();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'clearElectronStore failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
eventBus.emit(Events.LOGOUT);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'Error in logout handlers');
|
||||||
|
}
|
||||||
|
router.push(PAGES.ROOT);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'logoutUser failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearFiles = async () => {
|
||||||
|
await localForage.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTokenValid = async (token: string) => {
|
||||||
|
try {
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/session-validity/v2`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (resp.data[HAS_SET_KEYS] === undefined) {
|
||||||
|
throw Error('resp.data.hasSetKey undefined');
|
||||||
|
}
|
||||||
|
if (!resp.data['hasSetKeys']) {
|
||||||
|
try {
|
||||||
|
await putAttributes(
|
||||||
|
token,
|
||||||
|
getData(LS_KEYS.ORIGINAL_KEY_ATTRIBUTES)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'put attribute failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'hasSetKeys not set in session validity response');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'session-validity api call failed');
|
||||||
|
if (
|
||||||
|
e instanceof ApiError &&
|
||||||
|
e.httpStatusCode === HttpStatusCode.Unauthorized
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupTwoFactor = async () => {
|
||||||
|
const resp = await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/two-factor/setup`,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': getToken(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data as TwoFactorSecret;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enableTwoFactor = async (
|
||||||
|
code: string,
|
||||||
|
recoveryEncryptedTwoFactorSecret: B64EncryptionResult
|
||||||
|
) => {
|
||||||
|
await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/two-factor/enable`,
|
||||||
|
{
|
||||||
|
code,
|
||||||
|
encryptedTwoFactorSecret:
|
||||||
|
recoveryEncryptedTwoFactorSecret.encryptedData,
|
||||||
|
twoFactorSecretDecryptionNonce:
|
||||||
|
recoveryEncryptedTwoFactorSecret.nonce,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': getToken(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyTwoFactor = async (code: string, sessionID: string) => {
|
||||||
|
const resp = await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/two-factor/verify`,
|
||||||
|
{
|
||||||
|
code,
|
||||||
|
sessionID,
|
||||||
|
},
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return resp.data as TwoFactorVerificationResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recoverTwoFactor = async (sessionID: string) => {
|
||||||
|
const resp = await HTTPService.get(`${ENDPOINT}/users/two-factor/recover`, {
|
||||||
|
sessionID,
|
||||||
|
});
|
||||||
|
return resp.data as TwoFactorRecoveryResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeTwoFactor = async (sessionID: string, secret: string) => {
|
||||||
|
const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/remove`, {
|
||||||
|
sessionID,
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
return resp.data as TwoFactorVerificationResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disableTwoFactor = async () => {
|
||||||
|
await HTTPService.post(`${ENDPOINT}/users/two-factor/disable`, null, null, {
|
||||||
|
'X-Auth-Token': getToken(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTwoFactorStatus = async () => {
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/two-factor/status`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': getToken(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data['status'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const _logout = async () => {
|
||||||
|
if (!getToken()) return true;
|
||||||
|
try {
|
||||||
|
await HTTPService.post(`${ENDPOINT}/users/logout`, null, null, {
|
||||||
|
'X-Auth-Token': getToken(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, '/users/logout failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendOTTForEmailChange = async (email: string) => {
|
||||||
|
if (!getToken()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await HTTPService.post(`${ENDPOINT}/users/ott`, {
|
||||||
|
email,
|
||||||
|
client: 'web',
|
||||||
|
purpose: 'change',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeEmail = async (email: string, ott: string) => {
|
||||||
|
if (!getToken()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/change-email`,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
ott,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': getToken(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserDetailsV2 = async (): Promise<UserDetails> => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/details/v2`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get user details v2');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFamilyPortalRedirectURL = async () => {
|
||||||
|
try {
|
||||||
|
const jwtToken = await getFamiliesToken();
|
||||||
|
const isFamilyCreated = isPartOfFamily(getLocalFamilyData());
|
||||||
|
return `${getFamilyPortalURL()}?token=${jwtToken}&isFamilyCreated=${isFamilyCreated}&redirectURL=${
|
||||||
|
window.location.origin
|
||||||
|
}/gallery`;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'unable to generate to family portal URL');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAccountDeleteChallenge = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const resp = await HTTPService.get(
|
||||||
|
`${ENDPOINT}/users/delete-challenge`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data as DeleteChallengeResponse;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get account delete challenge');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAccount = async (
|
||||||
|
challenge: string,
|
||||||
|
reason: string,
|
||||||
|
feedback: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await HTTPService.delete(
|
||||||
|
`${ENDPOINT}/users/delete`,
|
||||||
|
{ challenge, reason, feedback },
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'deleteAccount api call failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure that the keys in local storage are not malformed by verifying that the
|
||||||
|
// recoveryKey can be decrypted with the masterKey.
|
||||||
|
// Note: This is not bullet-proof.
|
||||||
|
export const validateKey = async () => {
|
||||||
|
try {
|
||||||
|
await getRecoveryKey();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
await logoutUser();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFaceSearchEnabledStatus = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
const resp: AxiosResponse<GetRemoteStoreValueResponse> =
|
||||||
|
await HTTPService.get(
|
||||||
|
`${ENDPOINT}/remote-store`,
|
||||||
|
{
|
||||||
|
key: 'faceSearchEnabled',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data.value === 'true';
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get face search enabled status');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFaceSearchEnabledStatus = async (newStatus: boolean) => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
await HTTPService.post(
|
||||||
|
`${ENDPOINT}/remote-store/update`,
|
||||||
|
{
|
||||||
|
key: 'faceSearchEnabled',
|
||||||
|
value: newStatus.toString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to update face search enabled status');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncMapEnabled = async () => {
|
||||||
|
try {
|
||||||
|
const status = await getMapEnabledStatus();
|
||||||
|
setLocalMapEnabled(status);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to sync map enabled status');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMapEnabledStatus = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
const resp: AxiosResponse<GetRemoteStoreValueResponse> =
|
||||||
|
await HTTPService.get(
|
||||||
|
`${ENDPOINT}/remote-store`,
|
||||||
|
{
|
||||||
|
key: 'mapEnabled',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data.value === 'true';
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get map enabled status');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateMapEnabledStatus = async (newStatus: boolean) => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
await HTTPService.post(
|
||||||
|
`${ENDPOINT}/remote-store/update`,
|
||||||
|
{
|
||||||
|
key: 'mapEnabled',
|
||||||
|
value: newStatus.toString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to update map enabled status');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getDisableCFUploadProxyFlag(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const disableCFUploadProxy =
|
||||||
|
process.env.NEXT_PUBLIC_DISABLE_CF_UPLOAD_PROXY;
|
||||||
|
if (isDevDeployment() && typeof disableCFUploadProxy !== 'undefined') {
|
||||||
|
return disableCFUploadProxy === 'true';
|
||||||
|
}
|
||||||
|
const featureFlags = (
|
||||||
|
await fetch('https://static.ente.io/feature_flags.json')
|
||||||
|
).json() as GetFeatureFlagResponse;
|
||||||
|
return featureFlags.disableCFUploadProxy;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get feature flags');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSRPAttributes = async (
|
||||||
|
email: string
|
||||||
|
): Promise<SRPAttributes | null> => {
|
||||||
|
try {
|
||||||
|
const resp = await HTTPService.get(`${ENDPOINT}/users/srp/attributes`, {
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return (resp.data as GetSRPAttributesResponse).attributes;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to get SRP attributes');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const configureSRP = async ({
|
||||||
|
srpSalt,
|
||||||
|
srpUserID,
|
||||||
|
srpVerifier,
|
||||||
|
loginSubKey,
|
||||||
|
}: SRPSetupAttributes) => {
|
||||||
|
try {
|
||||||
|
const srpConfigureInProgress = InMemoryStore.get(
|
||||||
|
MS_KEYS.SRP_CONFIGURE_IN_PROGRESS
|
||||||
|
);
|
||||||
|
if (srpConfigureInProgress) {
|
||||||
|
throw Error('SRP configure already in progress');
|
||||||
|
}
|
||||||
|
InMemoryStore.set(MS_KEYS.SRP_CONFIGURE_IN_PROGRESS, true);
|
||||||
|
const srpClient = await generateSRPClient(
|
||||||
|
srpSalt,
|
||||||
|
srpUserID,
|
||||||
|
loginSubKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const srpA = convertBufferToBase64(srpClient.computeA());
|
||||||
|
|
||||||
|
addLocalLog(() => `srp a: ${srpA}`);
|
||||||
|
const token = getToken();
|
||||||
|
const { setupID, srpB } = await startSRPSetup(token, {
|
||||||
|
srpA,
|
||||||
|
srpUserID,
|
||||||
|
srpSalt,
|
||||||
|
srpVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
srpClient.setB(convertBase64ToBuffer(srpB));
|
||||||
|
|
||||||
|
const srpM1 = convertBufferToBase64(srpClient.computeM1());
|
||||||
|
|
||||||
|
const { srpM2 } = await completeSRPSetup(token, {
|
||||||
|
srpM1,
|
||||||
|
setupID,
|
||||||
|
});
|
||||||
|
|
||||||
|
srpClient.checkM2(convertBase64ToBuffer(srpM2));
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'srp configure failed');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
InMemoryStore.set(MS_KEYS.SRP_CONFIGURE_IN_PROGRESS, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startSRPSetup = async (
|
||||||
|
token: string,
|
||||||
|
setupSRPRequest: SetupSRPRequest
|
||||||
|
): Promise<SetupSRPResponse> => {
|
||||||
|
try {
|
||||||
|
const resp = await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/srp/setup`,
|
||||||
|
setupSRPRequest,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return resp.data as SetupSRPResponse;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to post SRP attributes');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeSRPSetup = async (
|
||||||
|
token: string,
|
||||||
|
completeSRPSetupRequest: CompleteSRPSetupRequest
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const resp = await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/srp/complete`,
|
||||||
|
completeSRPSetupRequest,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data as CompleteSRPSetupResponse;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to complete SRP setup');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginViaSRP = async (
|
||||||
|
srpAttributes: SRPAttributes,
|
||||||
|
kek: string
|
||||||
|
): Promise<UserVerificationResponse> => {
|
||||||
|
try {
|
||||||
|
const loginSubKey = await generateLoginSubKey(kek);
|
||||||
|
const srpClient = await generateSRPClient(
|
||||||
|
srpAttributes.srpSalt,
|
||||||
|
srpAttributes.srpUserID,
|
||||||
|
loginSubKey
|
||||||
|
);
|
||||||
|
const srpVerifier = computeVerifierHelper(
|
||||||
|
srpAttributes.srpSalt,
|
||||||
|
srpAttributes.srpUserID,
|
||||||
|
loginSubKey
|
||||||
|
);
|
||||||
|
addLocalLog(() => `srp verifier: ${srpVerifier}`);
|
||||||
|
const srpA = srpClient.computeA();
|
||||||
|
const { srpB, sessionID } = await createSRPSession(
|
||||||
|
srpAttributes.srpUserID,
|
||||||
|
convertBufferToBase64(srpA)
|
||||||
|
);
|
||||||
|
srpClient.setB(convertBase64ToBuffer(srpB));
|
||||||
|
|
||||||
|
const m1 = srpClient.computeM1();
|
||||||
|
addLocalLog(() => `srp m1: ${convertBufferToBase64(m1)}`);
|
||||||
|
const { srpM2, ...rest } = await verifySRPSession(
|
||||||
|
sessionID,
|
||||||
|
srpAttributes.srpUserID,
|
||||||
|
convertBufferToBase64(m1)
|
||||||
|
);
|
||||||
|
addLocalLog(() => `srp verify session successful,srpM2: ${srpM2}`);
|
||||||
|
|
||||||
|
srpClient.checkM2(convertBase64ToBuffer(srpM2));
|
||||||
|
|
||||||
|
addLocalLog(() => `srp server verify successful`);
|
||||||
|
|
||||||
|
return rest;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'srp verify failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSRPSession = async (srpUserID: string, srpA: string) => {
|
||||||
|
try {
|
||||||
|
const resp = await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/srp/create-session`,
|
||||||
|
{
|
||||||
|
srpUserID,
|
||||||
|
srpA,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data as CreateSRPSessionResponse;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'createSRPSession failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifySRPSession = async (
|
||||||
|
sessionID: string,
|
||||||
|
srpUserID: string,
|
||||||
|
srpM1: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const resp = await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/srp/verify-session`,
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
srpUserID,
|
||||||
|
srpM1,
|
||||||
|
},
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return resp.data as UserVerificationResponse;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'verifySRPSession failed');
|
||||||
|
if (
|
||||||
|
e instanceof ApiError &&
|
||||||
|
e.httpStatusCode === HttpStatusCode.Forbidden
|
||||||
|
) {
|
||||||
|
throw Error(CustomError.INCORRECT_PASSWORD);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSRPAndKeys = async (
|
||||||
|
token: string,
|
||||||
|
updateSRPAndKeyRequest: UpdateSRPAndKeysRequest
|
||||||
|
): Promise<UpdateSRPAndKeysResponse> => {
|
||||||
|
const resp = await HTTPService.post(
|
||||||
|
`${ENDPOINT}/users/srp/update`,
|
||||||
|
updateSRPAndKeyRequest,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return resp.data as UpdateSRPAndKeysResponse;
|
||||||
|
};
|
229
apps/cast/src/styles/Home.module.css
Normal file
229
apps/cast/src/styles/Home.module.css
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: inherit;
|
||||||
|
justify-content: inherit;
|
||||||
|
align-items: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-width: var(--max-width);
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description p {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: rgba(var(--callout-rgb), 0.5);
|
||||||
|
border: 1px solid rgba(var(--callout-border-rgb), 0.3);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(25%, auto));
|
||||||
|
max-width: 100%;
|
||||||
|
width: var(--max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: rgba(var(--card-rgb), 0);
|
||||||
|
border: 1px solid rgba(var(--card-border-rgb), 0);
|
||||||
|
transition: background 200ms, border 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card span {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 30ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
padding: 4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center::before {
|
||||||
|
background: var(--secondary-glow);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 480px;
|
||||||
|
height: 360px;
|
||||||
|
margin-left: -400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center::after {
|
||||||
|
background: var(--primary-glow);
|
||||||
|
width: 240px;
|
||||||
|
height: 180px;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center::before,
|
||||||
|
.center::after {
|
||||||
|
content: '';
|
||||||
|
left: 50%;
|
||||||
|
position: absolute;
|
||||||
|
filter: blur(45px);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* Enable hover only on non-touch devices */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.card:hover {
|
||||||
|
background: rgba(var(--card-rgb), 0.1);
|
||||||
|
border: 1px solid rgba(var(--card-border-rgb), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover span {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
.card:hover span {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.content {
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-bottom: 120px;
|
||||||
|
max-width: 320px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
padding: 8rem 0 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center::before {
|
||||||
|
transform: none;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description p,
|
||||||
|
.description div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description p {
|
||||||
|
align-items: center;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
padding: 2rem 1rem 1.4rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(var(--background-start-rgb), 1),
|
||||||
|
rgba(var(--callout-rgb), 0.5)
|
||||||
|
);
|
||||||
|
background-clip: padding-box;
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description div {
|
||||||
|
align-items: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
inset: auto 0 0;
|
||||||
|
padding: 2rem;
|
||||||
|
height: 200px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
rgb(var(--background-end-rgb)) 40%
|
||||||
|
);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet and Smaller Desktop */
|
||||||
|
@media (min-width: 701px) and (max-width: 1120px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, 50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.vercelLogo {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
110
apps/cast/src/styles/globals.css
Normal file
110
apps/cast/src/styles/globals.css
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
:root {
|
||||||
|
--max-width: 1100px;
|
||||||
|
--border-radius: 12px;
|
||||||
|
--font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
|
||||||
|
'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
|
||||||
|
'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
|
||||||
|
|
||||||
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
--background-start-rgb: 214, 219, 220;
|
||||||
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
|
||||||
|
--primary-glow: conic-gradient(
|
||||||
|
from 180deg at 50% 50%,
|
||||||
|
#16abff33 0deg,
|
||||||
|
#0885ff33 55deg,
|
||||||
|
#54d6ff33 120deg,
|
||||||
|
#0071ff33 160deg,
|
||||||
|
transparent 360deg
|
||||||
|
);
|
||||||
|
--secondary-glow: radial-gradient(
|
||||||
|
rgba(255, 255, 255, 1),
|
||||||
|
rgba(255, 255, 255, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
--tile-start-rgb: 239, 245, 249;
|
||||||
|
--tile-end-rgb: 228, 232, 233;
|
||||||
|
--tile-border: conic-gradient(
|
||||||
|
#00000080,
|
||||||
|
#00000040,
|
||||||
|
#00000030,
|
||||||
|
#00000020,
|
||||||
|
#00000010,
|
||||||
|
#00000010,
|
||||||
|
#00000080
|
||||||
|
);
|
||||||
|
|
||||||
|
--callout-rgb: 238, 240, 241;
|
||||||
|
--callout-border-rgb: 172, 175, 176;
|
||||||
|
--card-rgb: 180, 185, 188;
|
||||||
|
--card-border-rgb: 131, 134, 135;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 255, 255, 255;
|
||||||
|
--background-start-rgb: 0, 0, 0;
|
||||||
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
|
||||||
|
--primary-glow: radial-gradient(
|
||||||
|
rgba(1, 65, 255, 0.4),
|
||||||
|
rgba(1, 65, 255, 0)
|
||||||
|
);
|
||||||
|
--secondary-glow: linear-gradient(
|
||||||
|
to bottom right,
|
||||||
|
rgba(1, 65, 255, 0),
|
||||||
|
rgba(1, 65, 255, 0),
|
||||||
|
rgba(1, 65, 255, 0.3)
|
||||||
|
);
|
||||||
|
|
||||||
|
--tile-start-rgb: 2, 13, 46;
|
||||||
|
--tile-end-rgb: 2, 5, 19;
|
||||||
|
--tile-border: conic-gradient(
|
||||||
|
#ffffff80,
|
||||||
|
#ffffff40,
|
||||||
|
#ffffff30,
|
||||||
|
#ffffff20,
|
||||||
|
#ffffff10,
|
||||||
|
#ffffff10,
|
||||||
|
#ffffff80
|
||||||
|
);
|
||||||
|
|
||||||
|
--callout-rgb: 20, 20, 20;
|
||||||
|
--callout-border-rgb: 108, 108, 108;
|
||||||
|
--card-rgb: 100, 100, 100;
|
||||||
|
--card-border-rgb: 200, 200, 200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgb(var(--background-end-rgb))
|
||||||
|
)
|
||||||
|
rgb(var(--background-start-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
25
apps/cast/src/types/billing/index.ts
Normal file
25
apps/cast/src/types/billing/index.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { PLAN_PERIOD } from 'constants/gallery';
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
id: number;
|
||||||
|
userID: number;
|
||||||
|
productID: string;
|
||||||
|
storage: number;
|
||||||
|
originalTransactionID: string;
|
||||||
|
expiryTime: number;
|
||||||
|
paymentProvider: string;
|
||||||
|
attributes: {
|
||||||
|
isCancelled: boolean;
|
||||||
|
};
|
||||||
|
price: string;
|
||||||
|
period: PLAN_PERIOD;
|
||||||
|
}
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
androidID: string;
|
||||||
|
iosID: string;
|
||||||
|
storage: number;
|
||||||
|
price: string;
|
||||||
|
period: PLAN_PERIOD;
|
||||||
|
stripeID: string;
|
||||||
|
}
|
20
apps/cast/src/types/cache/index.ts
vendored
Normal file
20
apps/cast/src/types/cache/index.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
export interface LimitedCacheStorage {
|
||||||
|
open: (cacheName: string) => Promise<LimitedCache>;
|
||||||
|
delete: (cacheName: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitedCache {
|
||||||
|
match: (key: string) => Promise<Response>;
|
||||||
|
put: (key: string, data: Response) => Promise<void>;
|
||||||
|
delete: (key: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxiedLimitedCacheStorage {
|
||||||
|
open: (cacheName: string) => Promise<ProxiedWorkerLimitedCache>;
|
||||||
|
delete: (cacheName: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
export interface ProxiedWorkerLimitedCache {
|
||||||
|
match: (key: string) => Promise<ArrayBuffer>;
|
||||||
|
put: (key: string, data: ArrayBuffer) => Promise<void>;
|
||||||
|
delete: (key: string) => Promise<boolean>;
|
||||||
|
}
|
156
apps/cast/src/types/collection/index.ts
Normal file
156
apps/cast/src/types/collection/index.ts
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicURL {
|
||||||
|
url: string;
|
||||||
|
deviceLimit: number;
|
||||||
|
validTill: number;
|
||||||
|
enableDownload: boolean;
|
||||||
|
enableCollect: boolean;
|
||||||
|
passwordEnabled: boolean;
|
||||||
|
nonce?: string;
|
||||||
|
opsLimit?: number;
|
||||||
|
memLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePublicURL {
|
||||||
|
collectionID: number;
|
||||||
|
disablePassword?: boolean;
|
||||||
|
enableDownload?: boolean;
|
||||||
|
enableCollect?: boolean;
|
||||||
|
validTill?: number;
|
||||||
|
deviceLimit?: number;
|
||||||
|
passHash?: string;
|
||||||
|
nonce?: string;
|
||||||
|
opsLimit?: number;
|
||||||
|
memLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePublicAccessTokenRequest {
|
||||||
|
collectionID: number;
|
||||||
|
validTill?: number;
|
||||||
|
deviceLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedFileKey {
|
||||||
|
id: number;
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddToCollectionRequest {
|
||||||
|
collectionID: number;
|
||||||
|
files: EncryptedFileKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveToCollectionRequest {
|
||||||
|
fromCollectionID: number;
|
||||||
|
toCollectionID: number;
|
||||||
|
files: EncryptedFileKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface collectionAttributes {
|
||||||
|
encryptedPath?: string;
|
||||||
|
pathDecryptionNonce?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionToFileMap = Map<number, EnteFile>;
|
||||||
|
|
||||||
|
export interface RemoveFromCollectionRequest {
|
||||||
|
collectionID: number;
|
||||||
|
fileIDs: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionMagicMetadataProps {
|
||||||
|
visibility?: VISIBILITY_STATE;
|
||||||
|
subType?: SUB_TYPE;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionMagicMetadata =
|
||||||
|
MagicMetadataCore<CollectionMagicMetadataProps>;
|
||||||
|
|
||||||
|
export interface CollectionShareeMetadataProps {
|
||||||
|
visibility?: VISIBILITY_STATE;
|
||||||
|
}
|
||||||
|
export type CollectionShareeMagicMetadata =
|
||||||
|
MagicMetadataCore<CollectionShareeMetadataProps>;
|
||||||
|
|
||||||
|
export interface CollectionPublicMagicMetadataProps {
|
||||||
|
asc?: boolean;
|
||||||
|
coverID?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionPublicMagicMetadata =
|
||||||
|
MagicMetadataCore<CollectionPublicMagicMetadataProps>;
|
||||||
|
|
||||||
|
export interface CollectionSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: CollectionSummaryType;
|
||||||
|
coverFile: EnteFile;
|
||||||
|
latestFile: EnteFile;
|
||||||
|
fileCount: number;
|
||||||
|
updationTime: number;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionSummaries = Map<number, CollectionSummary>;
|
||||||
|
export type CollectionFilesCount = Map<number, number>;
|
19
apps/cast/src/types/crypto/index.ts
Normal file
19
apps/cast/src/types/crypto/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { DataStream } from 'types/upload';
|
||||||
|
|
||||||
|
export interface LocalFileAttributes<
|
||||||
|
T extends string | Uint8Array | DataStream
|
||||||
|
> {
|
||||||
|
encryptedData: T;
|
||||||
|
decryptionHeader: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptionResult<T extends string | Uint8Array | DataStream> {
|
||||||
|
file: LocalFileAttributes<T>;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface B64EncryptionResult {
|
||||||
|
encryptedData: string;
|
||||||
|
key: string;
|
||||||
|
nonce: string;
|
||||||
|
}
|
422
apps/cast/src/types/crypto/libsodium.ts
Normal file
422
apps/cast/src/types/crypto/libsodium.ts
Normal file
|
@ -0,0 +1,422 @@
|
||||||
|
import sodium, { StateAddress } from 'libsodium-wrappers';
|
||||||
|
import { ENCRYPTION_CHUNK_SIZE } from 'constants/crypto';
|
||||||
|
import { B64EncryptionResult } from 'types/crypto';
|
||||||
|
import { CustomError } from 'utils/error';
|
||||||
|
|
||||||
|
export async function decryptChaChaOneShot(
|
||||||
|
data: Uint8Array,
|
||||||
|
header: Uint8Array,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
||||||
|
header,
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
const pullResult = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
|
pullState,
|
||||||
|
data,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return pullResult.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptChaCha(
|
||||||
|
data: Uint8Array,
|
||||||
|
header: Uint8Array,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
||||||
|
header,
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
const decryptionChunkSize =
|
||||||
|
ENCRYPTION_CHUNK_SIZE +
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
|
||||||
|
let bytesRead = 0;
|
||||||
|
const decryptedData = [];
|
||||||
|
let tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
while (tag !== sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
|
||||||
|
let chunkSize = decryptionChunkSize;
|
||||||
|
if (bytesRead + chunkSize > data.length) {
|
||||||
|
chunkSize = data.length - bytesRead;
|
||||||
|
}
|
||||||
|
const buffer = data.slice(bytesRead, bytesRead + chunkSize);
|
||||||
|
const pullResult = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
|
pullState,
|
||||||
|
buffer
|
||||||
|
);
|
||||||
|
if (!pullResult.message) {
|
||||||
|
throw new Error(CustomError.PROCESSING_FAILED);
|
||||||
|
}
|
||||||
|
for (let index = 0; index < pullResult.message.length; index++) {
|
||||||
|
decryptedData.push(pullResult.message[index]);
|
||||||
|
}
|
||||||
|
tag = pullResult.tag;
|
||||||
|
bytesRead += chunkSize;
|
||||||
|
}
|
||||||
|
return Uint8Array.from(decryptedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initChunkDecryption(header: Uint8Array, key: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
||||||
|
header,
|
||||||
|
key
|
||||||
|
);
|
||||||
|
const decryptionChunkSize =
|
||||||
|
ENCRYPTION_CHUNK_SIZE +
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
|
||||||
|
const tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
return { pullState, decryptionChunkSize, tag };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptFileChunk(
|
||||||
|
data: Uint8Array,
|
||||||
|
pullState: StateAddress
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullResult = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
|
pullState,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (!pullResult.message) {
|
||||||
|
throw new Error(CustomError.PROCESSING_FAILED);
|
||||||
|
}
|
||||||
|
const newTag = pullResult.tag;
|
||||||
|
return { decryptedData: pullResult.message, newTag };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptChaChaOneShot(data: Uint8Array, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
|
||||||
|
const uintkey: Uint8Array = await fromB64(key);
|
||||||
|
const initPushResult =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(uintkey);
|
||||||
|
const [pushState, header] = [initPushResult.state, initPushResult.header];
|
||||||
|
|
||||||
|
const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
pushState,
|
||||||
|
data,
|
||||||
|
null,
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key: await toB64(uintkey),
|
||||||
|
file: {
|
||||||
|
encryptedData: pushResult,
|
||||||
|
decryptionHeader: await toB64(header),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptChaCha(data: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
|
||||||
|
const uintkey: Uint8Array =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
|
||||||
|
const initPushResult =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(uintkey);
|
||||||
|
const [pushState, header] = [initPushResult.state, initPushResult.header];
|
||||||
|
let bytesRead = 0;
|
||||||
|
let tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
|
||||||
|
const encryptedData = [];
|
||||||
|
|
||||||
|
while (tag !== sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
|
||||||
|
let chunkSize = ENCRYPTION_CHUNK_SIZE;
|
||||||
|
if (bytesRead + chunkSize >= data.length) {
|
||||||
|
chunkSize = data.length - bytesRead;
|
||||||
|
tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = data.slice(bytesRead, bytesRead + chunkSize);
|
||||||
|
bytesRead += chunkSize;
|
||||||
|
const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
pushState,
|
||||||
|
buffer,
|
||||||
|
null,
|
||||||
|
tag
|
||||||
|
);
|
||||||
|
for (let index = 0; index < pushResult.length; index++) {
|
||||||
|
encryptedData.push(pushResult[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: await toB64(uintkey),
|
||||||
|
file: {
|
||||||
|
encryptedData: new Uint8Array(encryptedData),
|
||||||
|
decryptionHeader: await toB64(header),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initChunkEncryption() {
|
||||||
|
await sodium.ready;
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const initPushResult =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||||
|
const [pushState, header] = [initPushResult.state, initPushResult.header];
|
||||||
|
return {
|
||||||
|
key: await toB64(key),
|
||||||
|
decryptionHeader: await toB64(header),
|
||||||
|
pushState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptFileChunk(
|
||||||
|
data: Uint8Array,
|
||||||
|
pushState: sodium.StateAddress,
|
||||||
|
isFinalChunk: boolean
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const tag = isFinalChunk
|
||||||
|
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
|
||||||
|
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
pushState,
|
||||||
|
data,
|
||||||
|
null,
|
||||||
|
tag
|
||||||
|
);
|
||||||
|
|
||||||
|
return pushResult;
|
||||||
|
}
|
||||||
|
export async function encryptToB64(data: string, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const encrypted = await encrypt(await fromB64(data), await fromB64(key));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedData: await toB64(encrypted.encryptedData),
|
||||||
|
key: await toB64(encrypted.key),
|
||||||
|
nonce: await toB64(encrypted.nonce),
|
||||||
|
} as B64EncryptionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyAndEncryptToB64(data: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const key = sodium.crypto_secretbox_keygen();
|
||||||
|
return await encryptToB64(data, await toB64(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptUTF8(data: string, key: string) {
|
||||||
|
const b64Data = await toB64(await fromUTF8(data));
|
||||||
|
return await encryptToB64(b64Data, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptB64(data: string, nonce: string, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const decrypted = await decrypt(
|
||||||
|
await fromB64(data),
|
||||||
|
await fromB64(nonce),
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
return await toB64(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptToUTF8(data: string, nonce: string, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const decrypted = await decrypt(
|
||||||
|
await fromB64(data),
|
||||||
|
await fromB64(nonce),
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
return sodium.to_string(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encrypt(data: Uint8Array, key: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const encryptedData = sodium.crypto_secretbox_easy(data, nonce, key);
|
||||||
|
return {
|
||||||
|
encryptedData,
|
||||||
|
key,
|
||||||
|
nonce,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decrypt(data: Uint8Array, nonce: Uint8Array, key: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.crypto_secretbox_open_easy(data, nonce, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initChunkHashing() {
|
||||||
|
await sodium.ready;
|
||||||
|
const hashState = sodium.crypto_generichash_init(
|
||||||
|
null,
|
||||||
|
sodium.crypto_generichash_BYTES_MAX
|
||||||
|
);
|
||||||
|
return hashState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashFileChunk(
|
||||||
|
hashState: sodium.StateAddress,
|
||||||
|
chunk: Uint8Array
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
sodium.crypto_generichash_update(hashState, chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeChunkHashing(hashState: sodium.StateAddress) {
|
||||||
|
await sodium.ready;
|
||||||
|
const hash = sodium.crypto_generichash_final(
|
||||||
|
hashState,
|
||||||
|
sodium.crypto_generichash_BYTES_MAX
|
||||||
|
);
|
||||||
|
const hashString = toB64(hash);
|
||||||
|
return hashString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveKey(
|
||||||
|
passphrase: string,
|
||||||
|
salt: string,
|
||||||
|
opsLimit: number,
|
||||||
|
memLimit: number
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_pwhash(
|
||||||
|
sodium.crypto_secretbox_KEYBYTES,
|
||||||
|
await fromUTF8(passphrase),
|
||||||
|
await fromB64(salt),
|
||||||
|
opsLimit,
|
||||||
|
memLimit,
|
||||||
|
sodium.crypto_pwhash_ALG_ARGON2ID13
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveSensitiveKey(passphrase: string, salt: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const minMemLimit = sodium.crypto_pwhash_MEMLIMIT_MIN;
|
||||||
|
let opsLimit = sodium.crypto_pwhash_OPSLIMIT_SENSITIVE;
|
||||||
|
let memLimit = sodium.crypto_pwhash_MEMLIMIT_SENSITIVE;
|
||||||
|
while (memLimit > minMemLimit) {
|
||||||
|
try {
|
||||||
|
const key = await deriveKey(passphrase, salt, opsLimit, memLimit);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
opsLimit,
|
||||||
|
memLimit,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
opsLimit *= 2;
|
||||||
|
memLimit /= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveInteractiveKey(passphrase: string, salt: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const key = await toB64(
|
||||||
|
sodium.crypto_pwhash(
|
||||||
|
sodium.crypto_secretbox_KEYBYTES,
|
||||||
|
await fromUTF8(passphrase),
|
||||||
|
await fromB64(salt),
|
||||||
|
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||||
|
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||||
|
sodium.crypto_pwhash_ALG_ARGON2ID13
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
opsLimit: sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||||
|
memLimit: sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateEncryptionKey() {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(sodium.crypto_kdf_keygen());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSaltToDeriveKey() {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyPair() {
|
||||||
|
await sodium.ready;
|
||||||
|
const keyPair: sodium.KeyPair = sodium.crypto_box_keypair();
|
||||||
|
return {
|
||||||
|
privateKey: await toB64(keyPair.privateKey),
|
||||||
|
publicKey: await toB64(keyPair.publicKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function boxSealOpen(
|
||||||
|
input: string,
|
||||||
|
publicKey: string,
|
||||||
|
secretKey: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_box_seal_open(
|
||||||
|
await fromB64(input),
|
||||||
|
await fromB64(publicKey),
|
||||||
|
await fromB64(secretKey)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function boxSeal(input: string, publicKey: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_box_seal(await fromB64(input), await fromB64(publicKey))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSubKey(
|
||||||
|
key: string,
|
||||||
|
subKeyLength: number,
|
||||||
|
subKeyID: number,
|
||||||
|
context: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_kdf_derive_from_key(
|
||||||
|
subKeyLength,
|
||||||
|
subKeyID,
|
||||||
|
context,
|
||||||
|
await fromB64(key)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fromB64(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.from_base64(input, sodium.base64_variants.ORIGINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toB64(input: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_base64(input, sodium.base64_variants.ORIGINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toURLSafeB64(input: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_base64(input, sodium.base64_variants.URLSAFE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fromUTF8(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.from_string(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toUTF8(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_string(await fromB64(input));
|
||||||
|
}
|
||||||
|
export async function toHex(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_hex(await fromB64(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fromHex(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(sodium.from_hex(input));
|
||||||
|
}
|
103
apps/cast/src/types/file/index.ts
Normal file
103
apps/cast/src/types/file/index.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import {
|
||||||
|
EncryptedMagicMetadata,
|
||||||
|
MagicMetadataCore,
|
||||||
|
VISIBILITY_STATE,
|
||||||
|
} from 'types/magicMetadata';
|
||||||
|
import { Metadata } from 'types/upload';
|
||||||
|
|
||||||
|
export interface MetadataFileAttributes {
|
||||||
|
encryptedData: string;
|
||||||
|
decryptionHeader: string;
|
||||||
|
}
|
||||||
|
export interface S3FileAttributes {
|
||||||
|
objectKey: string;
|
||||||
|
decryptionHeader: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
fileSize: number;
|
||||||
|
thumbSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedEnteFile {
|
||||||
|
id: number;
|
||||||
|
collectionID: number;
|
||||||
|
ownerID: number;
|
||||||
|
file: S3FileAttributes;
|
||||||
|
thumbnail: S3FileAttributes;
|
||||||
|
metadata: MetadataFileAttributes;
|
||||||
|
info: FileInfo;
|
||||||
|
magicMetadata: EncryptedMagicMetadata;
|
||||||
|
pubMagicMetadata: EncryptedMagicMetadata;
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
updationTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnteFile
|
||||||
|
extends Omit<
|
||||||
|
EncryptedEnteFile,
|
||||||
|
| 'metadata'
|
||||||
|
| 'pubMagicMetadata'
|
||||||
|
| 'magicMetadata'
|
||||||
|
| 'encryptedKey'
|
||||||
|
| 'keyDecryptionNonce'
|
||||||
|
> {
|
||||||
|
metadata: Metadata;
|
||||||
|
magicMetadata: FileMagicMetadata;
|
||||||
|
pubMagicMetadata: FilePublicMagicMetadata;
|
||||||
|
isTrashed?: boolean;
|
||||||
|
key: string;
|
||||||
|
src?: string;
|
||||||
|
msrc?: string;
|
||||||
|
html?: string;
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
title?: string;
|
||||||
|
deleteBy?: number;
|
||||||
|
isSourceLoaded?: boolean;
|
||||||
|
originalVideoURL?: string;
|
||||||
|
originalImageURL?: string;
|
||||||
|
dataIndex?: number;
|
||||||
|
conversionFailed?: boolean;
|
||||||
|
isConverted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrashRequest {
|
||||||
|
items: TrashRequestItems[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrashRequestItems {
|
||||||
|
fileID: number;
|
||||||
|
collectionID: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWithUpdatedMagicMetadata {
|
||||||
|
file: EnteFile;
|
||||||
|
updatedMagicMetadata: FileMagicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWithUpdatedPublicMagicMetadata {
|
||||||
|
file: EnteFile;
|
||||||
|
updatedPublicMagicMetadata: FilePublicMagicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMagicMetadataProps {
|
||||||
|
visibility?: VISIBILITY_STATE;
|
||||||
|
filePaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileMagicMetadata = MagicMetadataCore<FileMagicMetadataProps>;
|
||||||
|
|
||||||
|
export interface FilePublicMagicMetadataProps {
|
||||||
|
editedTime?: number;
|
||||||
|
editedName?: string;
|
||||||
|
caption?: string;
|
||||||
|
uploaderName?: string;
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilePublicMagicMetadata =
|
||||||
|
MagicMetadataCore<FilePublicMagicMetadataProps>;
|
57
apps/cast/src/types/gallery/index.ts
Normal file
57
apps/cast/src/types/gallery/index.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
// import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
|
||||||
|
// import { CollectionSelectorAttributes } from 'components/Collections/CollectionSelector';
|
||||||
|
// import { TimeStampListItem } from 'components/PhotoList';
|
||||||
|
import { Collection } from 'types/collection';
|
||||||
|
import { EnteFile } from 'types/file';
|
||||||
|
import { User } from 'types/user';
|
||||||
|
|
||||||
|
export type SelectedState = {
|
||||||
|
[k: number]: boolean;
|
||||||
|
ownCount: number;
|
||||||
|
count: number;
|
||||||
|
collectionID: number;
|
||||||
|
};
|
||||||
|
export type SetFiles = React.Dispatch<React.SetStateAction<EnteFile[]>>;
|
||||||
|
export type SetCollections = React.Dispatch<React.SetStateAction<Collection[]>>;
|
||||||
|
export type SetLoading = React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
// export type SetCollectionSelectorAttributes = React.Dispatch<
|
||||||
|
// React.SetStateAction<CollectionSelectorAttributes>
|
||||||
|
// >;
|
||||||
|
// export type SetCollectionDownloadProgressAttributes = React.Dispatch<
|
||||||
|
// React.SetStateAction<CollectionDownloadProgressAttributes>
|
||||||
|
// >;
|
||||||
|
|
||||||
|
export type MergedSourceURL = {
|
||||||
|
original: string;
|
||||||
|
converted: string;
|
||||||
|
};
|
||||||
|
export enum UploadTypeSelectorIntent {
|
||||||
|
normalUpload,
|
||||||
|
import,
|
||||||
|
collectPhotos,
|
||||||
|
}
|
||||||
|
export type GalleryContextType = {
|
||||||
|
thumbs: Map<number, string>;
|
||||||
|
files: Map<number, MergedSourceURL>;
|
||||||
|
showPlanSelectorModal: () => void;
|
||||||
|
setActiveCollectionID: (collectionID: number) => void;
|
||||||
|
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
|
||||||
|
setBlockingLoad: (value: boolean) => void;
|
||||||
|
setIsInSearchMode: (value: boolean) => void;
|
||||||
|
// photoListHeader: TimeStampListItem;
|
||||||
|
openExportModal: () => void;
|
||||||
|
authenticateUser: (callback: () => void) => void;
|
||||||
|
user: User;
|
||||||
|
userIDToEmailMap: Map<number, string>;
|
||||||
|
emailList: string[];
|
||||||
|
openHiddenSection: (callback?: () => void) => void;
|
||||||
|
isClipSearchResult: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum CollectionSelectorIntent {
|
||||||
|
upload,
|
||||||
|
add,
|
||||||
|
move,
|
||||||
|
restore,
|
||||||
|
unhide,
|
||||||
|
}
|
29
apps/cast/src/types/magicMetadata/index.ts
Normal file
29
apps/cast/src/types/magicMetadata/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
export interface MagicMetadataCore<T> {
|
||||||
|
version: number;
|
||||||
|
count: number;
|
||||||
|
header: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncryptedMagicMetadata = MagicMetadataCore<string>;
|
||||||
|
|
||||||
|
export enum VISIBILITY_STATE {
|
||||||
|
VISIBLE = 0,
|
||||||
|
ARCHIVED = 1,
|
||||||
|
HIDDEN = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SUB_TYPE {
|
||||||
|
DEFAULT = 0,
|
||||||
|
DEFAULT_HIDDEN = 1,
|
||||||
|
QUICK_LINK_COLLECTION = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkUpdateMagicMetadataRequest {
|
||||||
|
metadataList: UpdateMagicMetadataRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMagicMetadataRequest {
|
||||||
|
id: number;
|
||||||
|
magicMetadata: EncryptedMagicMetadata;
|
||||||
|
}
|
167
apps/cast/src/types/upload/index.ts
Normal file
167
apps/cast/src/types/upload/index.ts
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import { FILE_TYPE } from 'constants/file';
|
||||||
|
import { Collection } from 'types/collection';
|
||||||
|
import { B64EncryptionResult, LocalFileAttributes } from 'types/crypto';
|
||||||
|
import {
|
||||||
|
MetadataFileAttributes,
|
||||||
|
S3FileAttributes,
|
||||||
|
FilePublicMagicMetadata,
|
||||||
|
FilePublicMagicMetadataProps,
|
||||||
|
} from 'types/file';
|
||||||
|
import { EncryptedMagicMetadata } from 'types/magicMetadata';
|
||||||
|
|
||||||
|
export interface DataStream {
|
||||||
|
stream: ReadableStream<Uint8Array>;
|
||||||
|
chunkCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDataStream(object: any): object is DataStream {
|
||||||
|
return 'stream' in object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Logger = (message: string) => void;
|
||||||
|
|
||||||
|
export interface Metadata {
|
||||||
|
title: string;
|
||||||
|
creationTime: number;
|
||||||
|
modificationTime: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
fileType: FILE_TYPE;
|
||||||
|
hasStaticThumbnail?: boolean;
|
||||||
|
hash?: string;
|
||||||
|
imageHash?: string;
|
||||||
|
videoHash?: string;
|
||||||
|
localID?: number;
|
||||||
|
version?: number;
|
||||||
|
deviceFolder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedMetadataJSON {
|
||||||
|
creationTime: number;
|
||||||
|
modificationTime: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultipartUploadURLs {
|
||||||
|
objectKey: string;
|
||||||
|
partURLs: string[];
|
||||||
|
completeURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileTypeInfo {
|
||||||
|
fileType: FILE_TYPE;
|
||||||
|
exactType: string;
|
||||||
|
mimeType?: string;
|
||||||
|
imageType?: string;
|
||||||
|
videoType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ElectronFile is a custom interface that is used to represent
|
||||||
|
* any file on disk as a File-like object in the Electron desktop app.
|
||||||
|
*
|
||||||
|
* This was added to support the auto-resuming of failed uploads
|
||||||
|
* which needed absolute paths to the files which the
|
||||||
|
* normal File interface does not provide.
|
||||||
|
*/
|
||||||
|
export interface ElectronFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: number;
|
||||||
|
stream: () => Promise<ReadableStream<Uint8Array>>;
|
||||||
|
blob: () => Promise<Blob>;
|
||||||
|
arrayBuffer: () => Promise<Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadAsset {
|
||||||
|
isLivePhoto?: boolean;
|
||||||
|
file?: File | ElectronFile;
|
||||||
|
livePhotoAssets?: LivePhotoAssets;
|
||||||
|
isElectron?: boolean;
|
||||||
|
}
|
||||||
|
export interface LivePhotoAssets {
|
||||||
|
image: globalThis.File | ElectronFile;
|
||||||
|
video: globalThis.File | ElectronFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWithCollection extends UploadAsset {
|
||||||
|
localID: number;
|
||||||
|
collection?: Collection;
|
||||||
|
collectionID?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedMetadataJSONMap = Map<string, ParsedMetadataJSON>;
|
||||||
|
|
||||||
|
export interface UploadURL {
|
||||||
|
url: string;
|
||||||
|
objectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInMemory {
|
||||||
|
filedata: Uint8Array | DataStream;
|
||||||
|
thumbnail: Uint8Array;
|
||||||
|
hasStaticThumbnail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWithMetadata
|
||||||
|
extends Omit<FileInMemory, 'hasStaticThumbnail'> {
|
||||||
|
metadata: Metadata;
|
||||||
|
localID: number;
|
||||||
|
pubMagicMetadata: FilePublicMagicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedFile {
|
||||||
|
file: ProcessedFile;
|
||||||
|
fileKey: B64EncryptionResult;
|
||||||
|
}
|
||||||
|
export interface ProcessedFile {
|
||||||
|
file: LocalFileAttributes<Uint8Array | DataStream>;
|
||||||
|
thumbnail: LocalFileAttributes<Uint8Array>;
|
||||||
|
metadata: LocalFileAttributes<string>;
|
||||||
|
pubMagicMetadata: EncryptedMagicMetadata;
|
||||||
|
localID: number;
|
||||||
|
}
|
||||||
|
export interface BackupedFile {
|
||||||
|
file: S3FileAttributes;
|
||||||
|
thumbnail: S3FileAttributes;
|
||||||
|
metadata: MetadataFileAttributes;
|
||||||
|
pubMagicMetadata: EncryptedMagicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFile extends BackupedFile {
|
||||||
|
collectionID: number;
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedExtractedMetadata {
|
||||||
|
location: Location;
|
||||||
|
creationTime: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is used to prompt the user the make upload strategy choice
|
||||||
|
export interface ImportSuggestion {
|
||||||
|
rootFolderName: string;
|
||||||
|
hasNestedFolders: boolean;
|
||||||
|
hasRootLevelFileWithFolder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicUploadProps {
|
||||||
|
token: string;
|
||||||
|
passwordToken: string;
|
||||||
|
accessedThroughSharedURL: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractMetadataResult {
|
||||||
|
metadata: Metadata;
|
||||||
|
publicMagicMetadata: FilePublicMagicMetadataProps;
|
||||||
|
}
|
43
apps/cast/src/types/upload/ui.ts
Normal file
43
apps/cast/src/types/upload/ui.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { UPLOAD_RESULT, UPLOAD_STAGES } from 'constants/upload';
|
||||||
|
|
||||||
|
export type FileID = number;
|
||||||
|
export type FileName = string;
|
||||||
|
|
||||||
|
export type PercentageUploaded = number;
|
||||||
|
export type UploadFileNames = Map<FileID, FileName>;
|
||||||
|
|
||||||
|
export interface UploadCounter {
|
||||||
|
finished: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InProgressUpload {
|
||||||
|
localFileID: FileID;
|
||||||
|
progress: PercentageUploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinishedUpload {
|
||||||
|
localFileID: FileID;
|
||||||
|
result: UPLOAD_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InProgressUploads = Map<FileID, PercentageUploaded>;
|
||||||
|
|
||||||
|
export type FinishedUploads = Map<FileID, UPLOAD_RESULT>;
|
||||||
|
|
||||||
|
export type SegregatedFinishedUploads = Map<UPLOAD_RESULT, FileID[]>;
|
||||||
|
|
||||||
|
export interface ProgressUpdater {
|
||||||
|
setPercentComplete: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
setUploadCounter: React.Dispatch<React.SetStateAction<UploadCounter>>;
|
||||||
|
setUploadStage: React.Dispatch<React.SetStateAction<UPLOAD_STAGES>>;
|
||||||
|
setInProgressUploads: React.Dispatch<
|
||||||
|
React.SetStateAction<InProgressUpload[]>
|
||||||
|
>;
|
||||||
|
setFinishedUploads: React.Dispatch<
|
||||||
|
React.SetStateAction<SegregatedFinishedUploads>
|
||||||
|
>;
|
||||||
|
setUploadFilenames: React.Dispatch<React.SetStateAction<UploadFileNames>>;
|
||||||
|
setHasLivePhotos: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setUploadProgressView: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
168
apps/cast/src/types/user/index.ts
Normal file
168
apps/cast/src/types/user/index.ts
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import { Subscription } from 'types/billing';
|
||||||
|
|
||||||
|
export interface KeyAttributes {
|
||||||
|
kekSalt: string;
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
opsLimit: number;
|
||||||
|
memLimit: number;
|
||||||
|
publicKey: string;
|
||||||
|
encryptedSecretKey: string;
|
||||||
|
secretKeyDecryptionNonce: string;
|
||||||
|
masterKeyEncryptedWithRecoveryKey: string;
|
||||||
|
masterKeyDecryptionNonce: string;
|
||||||
|
recoveryKeyEncryptedWithMasterKey: string;
|
||||||
|
recoveryKeyDecryptionNonce: string;
|
||||||
|
}
|
||||||
|
export interface KEK {
|
||||||
|
key: string;
|
||||||
|
opsLimit: number;
|
||||||
|
memLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatedKey {
|
||||||
|
kekSalt: string;
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
memLimit: number;
|
||||||
|
opsLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSRPAndKeysRequest {
|
||||||
|
srpM1: string;
|
||||||
|
setupID: string;
|
||||||
|
updatedKeyAttr: UpdatedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSRPAndKeysResponse {
|
||||||
|
srpM2: string;
|
||||||
|
setupID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoveryKey {
|
||||||
|
masterKeyEncryptedWithRecoveryKey: string;
|
||||||
|
masterKeyDecryptionNonce: string;
|
||||||
|
recoveryKeyEncryptedWithMasterKey: string;
|
||||||
|
recoveryKeyDecryptionNonce: string;
|
||||||
|
}
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
encryptedToken: string;
|
||||||
|
isTwoFactorEnabled: boolean;
|
||||||
|
twoFactorSessionID: string;
|
||||||
|
}
|
||||||
|
export interface UserVerificationResponse {
|
||||||
|
id: number;
|
||||||
|
keyAttributes?: KeyAttributes;
|
||||||
|
encryptedToken?: string;
|
||||||
|
token?: string;
|
||||||
|
twoFactorSessionID: string;
|
||||||
|
srpM2?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorVerificationResponse {
|
||||||
|
id: number;
|
||||||
|
keyAttributes: KeyAttributes;
|
||||||
|
encryptedToken?: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorSecret {
|
||||||
|
secretCode: string;
|
||||||
|
qrCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorRecoveryResponse {
|
||||||
|
encryptedSecret: string;
|
||||||
|
secretDecryptionNonce: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FamilyMember {
|
||||||
|
email: string;
|
||||||
|
usage: number;
|
||||||
|
id: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FamilyData {
|
||||||
|
storage: number;
|
||||||
|
expiry: number;
|
||||||
|
members: FamilyMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetails {
|
||||||
|
email: string;
|
||||||
|
usage: number;
|
||||||
|
fileCount: number;
|
||||||
|
sharedCollectionCount: number;
|
||||||
|
subscription: Subscription;
|
||||||
|
familyData?: FamilyData;
|
||||||
|
storageBonus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteChallengeResponse {
|
||||||
|
allowDelete: boolean;
|
||||||
|
encryptedChallenge: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRemoteStoreValueResponse {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRemoteStoreValueRequest {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SRPAttributes {
|
||||||
|
srpUserID: string;
|
||||||
|
srpSalt: string;
|
||||||
|
memLimit: number;
|
||||||
|
opsLimit: number;
|
||||||
|
kekSalt: string;
|
||||||
|
isEmailMFAEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSRPAttributesResponse {
|
||||||
|
attributes: SRPAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SRPSetupAttributes {
|
||||||
|
srpSalt: string;
|
||||||
|
srpVerifier: string;
|
||||||
|
srpUserID: string;
|
||||||
|
loginSubKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupSRPRequest {
|
||||||
|
srpUserID: string;
|
||||||
|
srpSalt: string;
|
||||||
|
srpVerifier: string;
|
||||||
|
srpA: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupSRPResponse {
|
||||||
|
setupID: string;
|
||||||
|
srpB: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteSRPSetupRequest {
|
||||||
|
setupID: string;
|
||||||
|
srpM1: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteSRPSetupResponse {
|
||||||
|
setupID: string;
|
||||||
|
srpM2: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSRPSessionResponse {
|
||||||
|
sessionID: string;
|
||||||
|
srpB: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetFeatureFlagResponse {
|
||||||
|
disableCFUploadProxy?: boolean;
|
||||||
|
}
|
617
apps/cast/src/utils/collection/index.ts
Normal file
617
apps/cast/src/utils/collection/index.ts
Normal file
|
@ -0,0 +1,617 @@
|
||||||
|
import {
|
||||||
|
addToCollection,
|
||||||
|
createAlbum,
|
||||||
|
getAllLocalCollections,
|
||||||
|
getLocalCollections,
|
||||||
|
getNonEmptyCollections,
|
||||||
|
moveToCollection,
|
||||||
|
removeFromCollection,
|
||||||
|
restoreToCollection,
|
||||||
|
unhideToCollection,
|
||||||
|
updateCollectionMagicMetadata,
|
||||||
|
updatePublicCollectionMagicMetadata,
|
||||||
|
updateSharedCollectionMagicMetadata,
|
||||||
|
} from 'services/collectionService';
|
||||||
|
import { downloadFiles, downloadFilesDesktop } from 'utils/file';
|
||||||
|
import { getAllLocalFiles, getLocalFiles } from 'services/fileService';
|
||||||
|
import { EnteFile } from 'types/file';
|
||||||
|
import { CustomError } from 'utils/error';
|
||||||
|
import { User } from 'types/user';
|
||||||
|
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import {
|
||||||
|
COLLECTION_ROLE,
|
||||||
|
Collection,
|
||||||
|
CollectionMagicMetadataProps,
|
||||||
|
CollectionPublicMagicMetadataProps,
|
||||||
|
CollectionSummaries,
|
||||||
|
} from 'types/collection';
|
||||||
|
import {
|
||||||
|
CollectionSummaryType,
|
||||||
|
CollectionType,
|
||||||
|
HIDE_FROM_COLLECTION_BAR_TYPES,
|
||||||
|
OPTIONS_NOT_HAVING_COLLECTION_TYPES,
|
||||||
|
SYSTEM_COLLECTION_TYPES,
|
||||||
|
MOVE_TO_NOT_ALLOWED_COLLECTION,
|
||||||
|
ADD_TO_NOT_ALLOWED_COLLECTION,
|
||||||
|
HIDDEN_ITEMS_SECTION,
|
||||||
|
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
|
||||||
|
} from 'constants/collection';
|
||||||
|
import { getUnixTimeInMicroSecondsWithDelta } from 'utils/time';
|
||||||
|
import { SUB_TYPE, VISIBILITY_STATE } from 'types/magicMetadata';
|
||||||
|
import { isArchivedCollection, updateMagicMetadata } from 'utils/magicMetadata';
|
||||||
|
import { getAlbumsURL } from 'utils/common/apiUtil';
|
||||||
|
import bs58 from 'bs58';
|
||||||
|
import { t } from 'i18next';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
|
||||||
|
import ElectronService from 'services/electron/common';
|
||||||
|
import {
|
||||||
|
getCollectionExportPath,
|
||||||
|
getUniqueCollectionExportName,
|
||||||
|
} from 'utils/export';
|
||||||
|
import exportService from 'services/export';
|
||||||
|
import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
|
||||||
|
|
||||||
|
export enum COLLECTION_OPS_TYPE {
|
||||||
|
ADD,
|
||||||
|
MOVE,
|
||||||
|
REMOVE,
|
||||||
|
RESTORE,
|
||||||
|
UNHIDE,
|
||||||
|
}
|
||||||
|
export async function handleCollectionOps(
|
||||||
|
type: COLLECTION_OPS_TYPE,
|
||||||
|
collection: Collection,
|
||||||
|
selectedFiles: EnteFile[],
|
||||||
|
selectedCollectionID: number
|
||||||
|
) {
|
||||||
|
switch (type) {
|
||||||
|
case COLLECTION_OPS_TYPE.ADD:
|
||||||
|
await addToCollection(collection, selectedFiles);
|
||||||
|
break;
|
||||||
|
case COLLECTION_OPS_TYPE.MOVE:
|
||||||
|
await moveToCollection(
|
||||||
|
selectedCollectionID,
|
||||||
|
collection,
|
||||||
|
selectedFiles
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case COLLECTION_OPS_TYPE.REMOVE:
|
||||||
|
await removeFromCollection(collection.id, selectedFiles);
|
||||||
|
break;
|
||||||
|
case COLLECTION_OPS_TYPE.RESTORE:
|
||||||
|
await restoreToCollection(collection, selectedFiles);
|
||||||
|
break;
|
||||||
|
case COLLECTION_OPS_TYPE.UNHIDE:
|
||||||
|
await unhideToCollection(collection, selectedFiles);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Error(CustomError.INVALID_COLLECTION_OPERATION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedCollection(
|
||||||
|
collectionID: number,
|
||||||
|
collections: Collection[]
|
||||||
|
) {
|
||||||
|
return collections.find((collection) => collection.id === collectionID);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCollectionHelper(
|
||||||
|
collectionID: number,
|
||||||
|
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const allFiles = await getAllLocalFiles();
|
||||||
|
const collectionFiles = allFiles.filter(
|
||||||
|
(file) => file.collectionID === collectionID
|
||||||
|
);
|
||||||
|
const allCollections = await getAllLocalCollections();
|
||||||
|
const collection = allCollections.find(
|
||||||
|
(collection) => collection.id === collectionID
|
||||||
|
);
|
||||||
|
if (!collection) {
|
||||||
|
throw Error('collection not found');
|
||||||
|
}
|
||||||
|
await downloadCollectionFiles(
|
||||||
|
collection.name,
|
||||||
|
collection.id,
|
||||||
|
isHiddenCollection(collection),
|
||||||
|
collectionFiles,
|
||||||
|
setCollectionDownloadProgressAttributes
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'download collection failed ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadDefaultHiddenCollectionHelper(
|
||||||
|
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const hiddenCollections = await getLocalCollections('hidden');
|
||||||
|
const defaultHiddenCollectionsIds =
|
||||||
|
getDefaultHiddenCollectionIDs(hiddenCollections);
|
||||||
|
const hiddenFiles = await getLocalFiles('hidden');
|
||||||
|
const defaultHiddenCollectionFiles = hiddenFiles.filter((file) =>
|
||||||
|
defaultHiddenCollectionsIds.has(file.collectionID)
|
||||||
|
);
|
||||||
|
await downloadCollectionFiles(
|
||||||
|
DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME,
|
||||||
|
HIDDEN_ITEMS_SECTION,
|
||||||
|
true,
|
||||||
|
defaultHiddenCollectionFiles,
|
||||||
|
setCollectionDownloadProgressAttributes
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'download hidden files failed ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCollectionFiles(
|
||||||
|
collectionName: string,
|
||||||
|
collectionID: number,
|
||||||
|
isHidden: boolean,
|
||||||
|
collectionFiles: EnteFile[],
|
||||||
|
setCollectionDownloadProgressAttributes: SetCollectionDownloadProgressAttributes
|
||||||
|
) {
|
||||||
|
if (!collectionFiles.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canceller = new AbortController();
|
||||||
|
const increaseSuccess = () => {
|
||||||
|
if (canceller.signal.aborted) return;
|
||||||
|
setCollectionDownloadProgressAttributes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
success: prev.success + 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const increaseFailed = () => {
|
||||||
|
if (canceller.signal.aborted) return;
|
||||||
|
setCollectionDownloadProgressAttributes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
failed: prev.failed + 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const isCancelled = () => canceller.signal.aborted;
|
||||||
|
const initialProgressAttributes: CollectionDownloadProgressAttributes = {
|
||||||
|
collectionName,
|
||||||
|
collectionID,
|
||||||
|
isHidden,
|
||||||
|
canceller,
|
||||||
|
total: collectionFiles.length,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
downloadDirPath: null,
|
||||||
|
};
|
||||||
|
if (isElectron()) {
|
||||||
|
const selectedDir = await ElectronService.selectDirectory();
|
||||||
|
if (!selectedDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const downloadDirPath = await createCollectionDownloadFolder(
|
||||||
|
selectedDir,
|
||||||
|
collectionName
|
||||||
|
);
|
||||||
|
setCollectionDownloadProgressAttributes({
|
||||||
|
...initialProgressAttributes,
|
||||||
|
downloadDirPath,
|
||||||
|
});
|
||||||
|
await downloadFilesDesktop(
|
||||||
|
collectionFiles,
|
||||||
|
{ increaseSuccess, increaseFailed, isCancelled },
|
||||||
|
downloadDirPath
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setCollectionDownloadProgressAttributes(initialProgressAttributes);
|
||||||
|
await downloadFiles(collectionFiles, {
|
||||||
|
increaseSuccess,
|
||||||
|
increaseFailed,
|
||||||
|
isCancelled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCollectionDownloadFolder(
|
||||||
|
downloadDirPath: string,
|
||||||
|
collectionName: string
|
||||||
|
) {
|
||||||
|
const collectionDownloadName = getUniqueCollectionExportName(
|
||||||
|
downloadDirPath,
|
||||||
|
collectionName
|
||||||
|
);
|
||||||
|
const collectionDownloadPath = getCollectionExportPath(
|
||||||
|
downloadDirPath,
|
||||||
|
collectionDownloadName
|
||||||
|
);
|
||||||
|
await exportService.checkExistsAndCreateDir(collectionDownloadPath);
|
||||||
|
return collectionDownloadPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendCollectionKeyToShareURL(
|
||||||
|
url: string,
|
||||||
|
collectionKey: string
|
||||||
|
) {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharableURL = new URL(url);
|
||||||
|
const albumsURL = new URL(getAlbumsURL());
|
||||||
|
|
||||||
|
sharableURL.protocol = albumsURL.protocol;
|
||||||
|
sharableURL.host = albumsURL.host;
|
||||||
|
sharableURL.pathname = albumsURL.pathname;
|
||||||
|
|
||||||
|
const bytes = Buffer.from(collectionKey, 'base64');
|
||||||
|
sharableURL.hash = bs58.encode(bytes);
|
||||||
|
return sharableURL.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _intSelectOption = (i: number) => {
|
||||||
|
const label = i === 0 ? t('NO_DEVICE_LIMIT') : i.toString();
|
||||||
|
return { label, value: i };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDeviceLimitOptions() {
|
||||||
|
return [0, 2, 5, 10, 25, 50].map((i) => _intSelectOption(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shareExpiryOptions = () => [
|
||||||
|
{ label: t('NEVER'), value: () => 0 },
|
||||||
|
{
|
||||||
|
label: t('AFTER_TIME.HOUR'),
|
||||||
|
value: () => getUnixTimeInMicroSecondsWithDelta({ hours: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('AFTER_TIME.DAY'),
|
||||||
|
value: () => getUnixTimeInMicroSecondsWithDelta({ days: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('AFTER_TIME.WEEK'),
|
||||||
|
value: () => getUnixTimeInMicroSecondsWithDelta({ days: 7 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('AFTER_TIME.MONTH'),
|
||||||
|
value: () => getUnixTimeInMicroSecondsWithDelta({ months: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('AFTER_TIME.YEAR'),
|
||||||
|
value: () => getUnixTimeInMicroSecondsWithDelta({ years: 1 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const changeCollectionVisibility = async (
|
||||||
|
collection: Collection,
|
||||||
|
visibility: VISIBILITY_STATE
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
|
||||||
|
visibility,
|
||||||
|
};
|
||||||
|
|
||||||
|
const user: User = getData(LS_KEYS.USER);
|
||||||
|
if (collection.owner.id === user.id) {
|
||||||
|
const updatedMagicMetadata = await updateMagicMetadata(
|
||||||
|
updatedMagicMetadataProps,
|
||||||
|
collection.magicMetadata,
|
||||||
|
collection.key
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateCollectionMagicMetadata(
|
||||||
|
collection,
|
||||||
|
updatedMagicMetadata
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const updatedMagicMetadata = await updateMagicMetadata(
|
||||||
|
updatedMagicMetadataProps,
|
||||||
|
collection.sharedMagicMetadata,
|
||||||
|
collection.key
|
||||||
|
);
|
||||||
|
await updateSharedCollectionMagicMetadata(
|
||||||
|
collection,
|
||||||
|
updatedMagicMetadata
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'change collection visibility failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeCollectionSortOrder = async (
|
||||||
|
collection: Collection,
|
||||||
|
asc: boolean
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const updatedPublicMagicMetadataProps: CollectionPublicMagicMetadataProps =
|
||||||
|
{
|
||||||
|
asc,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPubMagicMetadata = await updateMagicMetadata(
|
||||||
|
updatedPublicMagicMetadataProps,
|
||||||
|
collection.pubMagicMetadata,
|
||||||
|
collection.key
|
||||||
|
);
|
||||||
|
|
||||||
|
await updatePublicCollectionMagicMetadata(
|
||||||
|
collection,
|
||||||
|
updatedPubMagicMetadata
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'change collection sort order failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeCollectionOrder = async (
|
||||||
|
collection: Collection,
|
||||||
|
order: number
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedMagicMetadata = await updateMagicMetadata(
|
||||||
|
updatedMagicMetadataProps,
|
||||||
|
collection.magicMetadata,
|
||||||
|
collection.key
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateCollectionMagicMetadata(collection, updatedMagicMetadata);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'change collection order failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeCollectionSubType = async (
|
||||||
|
collection: Collection,
|
||||||
|
subType: SUB_TYPE
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
|
||||||
|
subType: subType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedMagicMetadata = await updateMagicMetadata(
|
||||||
|
updatedMagicMetadataProps,
|
||||||
|
collection.magicMetadata,
|
||||||
|
collection.key
|
||||||
|
);
|
||||||
|
await updateCollectionMagicMetadata(collection, updatedMagicMetadata);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'change collection subType failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getArchivedCollections = (collections: Collection[]) => {
|
||||||
|
return new Set<number>(
|
||||||
|
collections
|
||||||
|
.filter(isArchivedCollection)
|
||||||
|
.map((collection) => collection.id)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultHiddenCollectionIDs = (collections: Collection[]) => {
|
||||||
|
return new Set<number>(
|
||||||
|
collections
|
||||||
|
.filter(isDefaultHiddenCollection)
|
||||||
|
.map((collection) => collection.id)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasNonSystemCollections = (
|
||||||
|
collectionSummaries: CollectionSummaries
|
||||||
|
) => {
|
||||||
|
for (const collectionSummary of collectionSummaries.values()) {
|
||||||
|
if (!isSystemCollection(collectionSummary.type)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isMoveToAllowedCollection = (type: CollectionSummaryType) => {
|
||||||
|
return !MOVE_TO_NOT_ALLOWED_COLLECTION.has(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAddToAllowedCollection = (type: CollectionSummaryType) => {
|
||||||
|
return !ADD_TO_NOT_ALLOWED_COLLECTION.has(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSystemCollection = (type: CollectionSummaryType) => {
|
||||||
|
return SYSTEM_COLLECTION_TYPES.has(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldShowOptions = (type: CollectionSummaryType) => {
|
||||||
|
return !OPTIONS_NOT_HAVING_COLLECTION_TYPES.has(type);
|
||||||
|
};
|
||||||
|
export const showEmptyTrashQuickOption = (type: CollectionSummaryType) => {
|
||||||
|
return type === CollectionSummaryType.trash;
|
||||||
|
};
|
||||||
|
export const showDownloadQuickOption = (type: CollectionSummaryType) => {
|
||||||
|
return (
|
||||||
|
type === CollectionSummaryType.folder ||
|
||||||
|
type === CollectionSummaryType.favorites ||
|
||||||
|
type === CollectionSummaryType.album ||
|
||||||
|
type === CollectionSummaryType.uncategorized ||
|
||||||
|
type === CollectionSummaryType.hiddenItems ||
|
||||||
|
type === CollectionSummaryType.incomingShareViewer ||
|
||||||
|
type === CollectionSummaryType.incomingShareCollaborator ||
|
||||||
|
type === CollectionSummaryType.outgoingShare ||
|
||||||
|
type === CollectionSummaryType.sharedOnlyViaLink ||
|
||||||
|
type === CollectionSummaryType.archived ||
|
||||||
|
type === CollectionSummaryType.pinned
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const showShareQuickOption = (type: CollectionSummaryType) => {
|
||||||
|
return (
|
||||||
|
type === CollectionSummaryType.folder ||
|
||||||
|
type === CollectionSummaryType.album ||
|
||||||
|
type === CollectionSummaryType.outgoingShare ||
|
||||||
|
type === CollectionSummaryType.sharedOnlyViaLink ||
|
||||||
|
type === CollectionSummaryType.archived ||
|
||||||
|
type === CollectionSummaryType.incomingShareViewer ||
|
||||||
|
type === CollectionSummaryType.incomingShareCollaborator ||
|
||||||
|
type === CollectionSummaryType.pinned
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const shouldBeShownOnCollectionBar = (type: CollectionSummaryType) => {
|
||||||
|
return !HIDE_FROM_COLLECTION_BAR_TYPES.has(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserOwnedCollections = (collections: Collection[]) => {
|
||||||
|
const user: User = getData(LS_KEYS.USER);
|
||||||
|
if (!user?.id) {
|
||||||
|
throw Error('user missing');
|
||||||
|
}
|
||||||
|
return collections.filter((collection) => collection.owner.id === user.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDefaultHiddenCollection = (collection: Collection) =>
|
||||||
|
collection.magicMetadata?.data.subType === SUB_TYPE.DEFAULT_HIDDEN;
|
||||||
|
|
||||||
|
export const isHiddenCollection = (collection: Collection) =>
|
||||||
|
collection.magicMetadata?.data.visibility === VISIBILITY_STATE.HIDDEN;
|
||||||
|
|
||||||
|
export const isQuickLinkCollection = (collection: Collection) =>
|
||||||
|
collection.magicMetadata?.data.subType === SUB_TYPE.QUICK_LINK_COLLECTION;
|
||||||
|
|
||||||
|
export function isOutgoingShare(collection: Collection, user: User): boolean {
|
||||||
|
return collection.owner.id === user.id && collection.sharees?.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIncomingShare(collection: Collection, user: User) {
|
||||||
|
return collection.owner.id !== user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIncomingViewerShare(collection: Collection, user: User) {
|
||||||
|
const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
|
||||||
|
return sharee?.role === COLLECTION_ROLE.VIEWER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIncomingCollabShare(collection: Collection, user: User) {
|
||||||
|
const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
|
||||||
|
return sharee?.role === COLLECTION_ROLE.COLLABORATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSharedOnlyViaLink(collection: Collection) {
|
||||||
|
return collection.publicURLs?.length && !collection.sharees?.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidMoveTarget(
|
||||||
|
sourceCollectionID: number,
|
||||||
|
targetCollection: Collection,
|
||||||
|
user: User
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
sourceCollectionID !== targetCollection.id &&
|
||||||
|
!isHiddenCollection(targetCollection) &&
|
||||||
|
!isQuickLinkCollection(targetCollection) &&
|
||||||
|
!isIncomingShare(targetCollection, user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidReplacementAlbum(
|
||||||
|
collection: Collection,
|
||||||
|
user: User,
|
||||||
|
wantedCollectionName: string
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
collection.name === wantedCollectionName &&
|
||||||
|
(collection.type === CollectionType.album ||
|
||||||
|
collection.type === CollectionType.folder) &&
|
||||||
|
!isHiddenCollection(collection) &&
|
||||||
|
!isQuickLinkCollection(collection) &&
|
||||||
|
!isIncomingShare(collection, user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCollectionNameMap(
|
||||||
|
collections: Collection[]
|
||||||
|
): Map<number, string> {
|
||||||
|
return new Map<number, string>(
|
||||||
|
collections.map((collection) => [collection.id, collection.name])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNonEmptyPersonalCollections(
|
||||||
|
collections: Collection[],
|
||||||
|
personalFiles: EnteFile[],
|
||||||
|
user: User
|
||||||
|
): Collection[] {
|
||||||
|
if (!user?.id) {
|
||||||
|
throw Error('user missing');
|
||||||
|
}
|
||||||
|
const nonEmptyCollections = getNonEmptyCollections(
|
||||||
|
collections,
|
||||||
|
personalFiles
|
||||||
|
);
|
||||||
|
const personalCollections = nonEmptyCollections.filter(
|
||||||
|
(collection) => collection.owner.id === user?.id
|
||||||
|
);
|
||||||
|
return personalCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNonHiddenCollections(
|
||||||
|
collections: Collection[]
|
||||||
|
): Collection[] {
|
||||||
|
return collections.filter((collection) => !isHiddenCollection(collection));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHiddenCollections(collections: Collection[]): Collection[] {
|
||||||
|
return collections.filter((collection) => isHiddenCollection(collection));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function splitNormalAndHiddenCollections(
|
||||||
|
collections: Collection[]
|
||||||
|
): Promise<{
|
||||||
|
normalCollections: Collection[];
|
||||||
|
hiddenCollections: Collection[];
|
||||||
|
}> {
|
||||||
|
const normalCollections = [];
|
||||||
|
const hiddenCollections = [];
|
||||||
|
for (const collection of collections) {
|
||||||
|
if (isHiddenCollection(collection)) {
|
||||||
|
hiddenCollections.push(collection);
|
||||||
|
} else {
|
||||||
|
normalCollections.push(collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { normalCollections, hiddenCollections };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constructCollectionNameMap(
|
||||||
|
collections: Collection[]
|
||||||
|
): Map<number, string> {
|
||||||
|
return new Map<number, string>(
|
||||||
|
(collections ?? []).map((collection) => [
|
||||||
|
collection.id,
|
||||||
|
getCollectionUserFacingName(collection),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCollectionUserFacingName = (collection: Collection) => {
|
||||||
|
if (isDefaultHiddenCollection(collection)) {
|
||||||
|
return DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME;
|
||||||
|
}
|
||||||
|
return collection.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrCreateAlbum = async (
|
||||||
|
albumName: string,
|
||||||
|
existingCollections: Collection[]
|
||||||
|
) => {
|
||||||
|
const user: User = getData(LS_KEYS.USER);
|
||||||
|
if (!user?.id) {
|
||||||
|
throw Error('user missing');
|
||||||
|
}
|
||||||
|
for (const collection of existingCollections) {
|
||||||
|
if (isValidReplacementAlbum(collection, user, albumName)) {
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createAlbum(albumName);
|
||||||
|
};
|
25
apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
Normal file
25
apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { Remote } from 'comlink';
|
||||||
|
import { DedicatedCryptoWorker } from 'worker/crypto.worker';
|
||||||
|
import { ComlinkWorker } from './comlinkWorker';
|
||||||
|
|
||||||
|
class ComlinkCryptoWorker {
|
||||||
|
private comlinkWorkerInstance: Promise<Remote<DedicatedCryptoWorker>>;
|
||||||
|
|
||||||
|
async getInstance() {
|
||||||
|
if (!this.comlinkWorkerInstance) {
|
||||||
|
const comlinkWorker = getDedicatedCryptoWorker();
|
||||||
|
this.comlinkWorkerInstance = comlinkWorker.remote;
|
||||||
|
}
|
||||||
|
return this.comlinkWorkerInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDedicatedCryptoWorker = () => {
|
||||||
|
const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedCryptoWorker>(
|
||||||
|
'ente-crypto-worker',
|
||||||
|
new Worker(new URL('worker/crypto.worker.ts', import.meta.url))
|
||||||
|
);
|
||||||
|
return cryptoComlinkWorker;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default new ComlinkCryptoWorker();
|
27
apps/cast/src/utils/comlink/comlinkWorker.ts
Normal file
27
apps/cast/src/utils/comlink/comlinkWorker.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { Remote, wrap } from 'comlink';
|
||||||
|
// import { WorkerElectronCacheStorageClient } from 'services/workerElectronCache/client';
|
||||||
|
import { addLocalLog } from 'utils/logging';
|
||||||
|
|
||||||
|
export class ComlinkWorker<T extends new () => InstanceType<T>> {
|
||||||
|
public remote: Promise<Remote<InstanceType<T>>>;
|
||||||
|
private worker: Worker;
|
||||||
|
private name: string;
|
||||||
|
|
||||||
|
constructor(name: string, worker: Worker) {
|
||||||
|
this.name = name;
|
||||||
|
this.worker = worker;
|
||||||
|
|
||||||
|
this.worker.onerror = (errorEvent) => {
|
||||||
|
console.error('Got error event from worker', errorEvent);
|
||||||
|
};
|
||||||
|
addLocalLog(() => `Initiated ${this.name}`);
|
||||||
|
const comlink = wrap<T>(this.worker);
|
||||||
|
this.remote = new comlink() as Promise<Remote<InstanceType<T>>>;
|
||||||
|
// expose(WorkerElectronCacheStorageClient, this.worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
public terminate() {
|
||||||
|
this.worker.terminate();
|
||||||
|
addLocalLog(() => `Terminated ${this.name}`);
|
||||||
|
}
|
||||||
|
}
|
113
apps/cast/src/utils/common/apiUtil.ts
Normal file
113
apps/cast/src/utils/common/apiUtil.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||||
|
|
||||||
|
export const getEndpoint = () => {
|
||||||
|
let endpoint = getData(LS_KEYS.API_ENDPOINT);
|
||||||
|
if (endpoint) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||||
|
if (isDevDeployment() && endpoint) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
return 'https://api.ente.io';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileURL = (id: number) => {
|
||||||
|
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||||
|
if (isDevDeployment() && endpoint) {
|
||||||
|
return `${endpoint}/files/download/${id}`;
|
||||||
|
}
|
||||||
|
return `https://files.ente.io/?fileID=${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPublicCollectionFileURL = (id: number) => {
|
||||||
|
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||||
|
if (isDevDeployment() && endpoint) {
|
||||||
|
return `${endpoint}/public-collection/files/download/${id}`;
|
||||||
|
}
|
||||||
|
return `https://public-albums.ente.io/download/?fileID=${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getThumbnailURL = (id: number) => {
|
||||||
|
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||||
|
if (isDevDeployment() && endpoint) {
|
||||||
|
return `${endpoint}/files/preview/${id}`;
|
||||||
|
}
|
||||||
|
return `https://thumbnails.ente.io/?fileID=${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPublicCollectionThumbnailURL = (id: number) => {
|
||||||
|
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||||
|
if (isDevDeployment() && endpoint) {
|
||||||
|
return `${endpoint}/public-collection/files/preview/${id}`;
|
||||||
|
}
|
||||||
|
return `https://public-albums.ente.io/preview/?fileID=${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUploadEndpoint = () => {
|
||||||
|
const endpoint = process.env.NEXT_PUBLIC_ENTE_UPLOAD_ENDPOINT;
|
||||||
|
if (isDevDeployment() && endpoint) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
return `https://uploader.ente.io`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPaymentsURL = () => {
|
||||||
|
const paymentsURL = process.env.NEXT_PUBLIC_ENTE_PAYMENT_ENDPOINT;
|
||||||
|
if (isDevDeployment() && paymentsURL) {
|
||||||
|
return paymentsURL;
|
||||||
|
}
|
||||||
|
return `https://payments.ente.io`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAlbumsURL = () => {
|
||||||
|
const albumsURL = process.env.NEXT_PUBLIC_ENTE_ALBUM_ENDPOINT;
|
||||||
|
if (isDevDeployment() && albumsURL) {
|
||||||
|
return albumsURL;
|
||||||
|
}
|
||||||
|
return `https://albums.ente.io`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// getFamilyPortalURL returns the endpoint for the family dashboard which can be used to
|
||||||
|
// create or manage family.
|
||||||
|
export const getFamilyPortalURL = () => {
|
||||||
|
const familyURL = process.env.NEXT_PUBLIC_ENTE_FAMILY_PORTAL_ENDPOINT;
|
||||||
|
if (isDevDeployment() && familyURL) {
|
||||||
|
return familyURL;
|
||||||
|
}
|
||||||
|
return `https://family.ente.io`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// getAuthenticatorURL returns the endpoint for the authenticator which can be used to
|
||||||
|
// view authenticator codes.
|
||||||
|
export const getAuthURL = () => {
|
||||||
|
const authURL = process.env.NEXT_PUBLIC_ENTE_AUTH_ENDPOINT;
|
||||||
|
if (isDevDeployment() && authURL) {
|
||||||
|
return authURL;
|
||||||
|
}
|
||||||
|
return `https://auth.ente.io`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSentryTunnelURL = () => {
|
||||||
|
return `https://sentry-reporter.ente.io`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
It's a dev deployment (and should use the environment override for endpoints ) in three cases:
|
||||||
|
1. when the URL opened is that of the staging web app, or
|
||||||
|
2. when the URL opened is that of the staging album app, or
|
||||||
|
3. if the app is running locally (hence node_env is development)
|
||||||
|
4. if the app is running in test mode
|
||||||
|
*/
|
||||||
|
export const isDevDeployment = () => {
|
||||||
|
if (globalThis?.location) {
|
||||||
|
return (
|
||||||
|
process.env.NEXT_PUBLIC_ENTE_WEB_ENDPOINT ===
|
||||||
|
globalThis.location.origin ||
|
||||||
|
process.env.NEXT_PUBLIC_ENTE_ALBUM_ENDPOINT ===
|
||||||
|
globalThis.location.origin ||
|
||||||
|
process.env.NEXT_PUBLIC_IS_TEST_APP === 'true' ||
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
149
apps/cast/src/utils/common/index.ts
Normal file
149
apps/cast/src/utils/common/index.ts
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import { CustomError } from 'utils/error';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
import { APP_DOWNLOAD_URL } from 'constants/urls';
|
||||||
|
|
||||||
|
export function checkConnectivity() {
|
||||||
|
if (navigator.onLine) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(CustomError.NO_INTERNET_CONNECTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runningInBrowser() {
|
||||||
|
return typeof window !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runningInWorker() {
|
||||||
|
return typeof importScripts === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runningInElectron() {
|
||||||
|
return isElectron();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runningInChrome(includeMobile: boolean) {
|
||||||
|
try {
|
||||||
|
const userAgentData = navigator['userAgentData'];
|
||||||
|
const chromeBrand = userAgentData?.brands?.filter(
|
||||||
|
(b) => b.brand === 'Google Chrome' || b.brand === 'Chromium'
|
||||||
|
)?.[0];
|
||||||
|
return chromeBrand && (includeMobile || userAgentData.mobile === false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in runningInChrome: ', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offscreenCanvasSupported() {
|
||||||
|
return !(typeof OffscreenCanvas === 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webglSupported() {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = canvas.getContext('webgl');
|
||||||
|
return gl && gl instanceof WebGLRenderingContext;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in webglSupported: ', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sleep(time: number) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(null), time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadApp() {
|
||||||
|
openLink(APP_DOWNLOAD_URL, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reverseString(title: string) {
|
||||||
|
return title
|
||||||
|
?.split(' ')
|
||||||
|
.reduce((reversedString, currWord) => `${currWord} ${reversedString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initiateEmail(email: string) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = 'mailto:' + email;
|
||||||
|
a.rel = 'noreferrer noopener';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
export const promiseWithTimeout = async <T>(
|
||||||
|
request: Promise<T>,
|
||||||
|
timeout: number
|
||||||
|
): Promise<T> => {
|
||||||
|
const timeoutRef = { current: null };
|
||||||
|
const rejectOnTimeout = new Promise<null>((_, reject) => {
|
||||||
|
timeoutRef.current = setTimeout(
|
||||||
|
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const requestWithTimeOutCancellation = async () => {
|
||||||
|
const resp = await request;
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
return resp;
|
||||||
|
};
|
||||||
|
return await Promise.race([
|
||||||
|
requestWithTimeOutCancellation(),
|
||||||
|
rejectOnTimeout,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preloadImage = (imgBasePath: string) => {
|
||||||
|
const srcSet = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
srcSet.push(`${imgBasePath}/${i}x.png ${i}x`);
|
||||||
|
}
|
||||||
|
new Image().srcset = srcSet.join(',');
|
||||||
|
};
|
||||||
|
export function openLink(href: string, newTab?: boolean) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = href;
|
||||||
|
if (newTab) {
|
||||||
|
a.target = '_blank';
|
||||||
|
}
|
||||||
|
a.rel = 'noreferrer noopener';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitAndRun(
|
||||||
|
waitPromise: Promise<void>,
|
||||||
|
task: () => Promise<void>
|
||||||
|
) {
|
||||||
|
if (waitPromise && isPromise(waitPromise)) {
|
||||||
|
await waitPromise;
|
||||||
|
}
|
||||||
|
await task();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromise(p: any) {
|
||||||
|
if (typeof p === 'object' && typeof p.then === 'function') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClipboardItemPresent() {
|
||||||
|
return typeof ClipboardItem !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batch<T>(arr: T[], batchSize: number): T[][] {
|
||||||
|
const batches: T[][] = [];
|
||||||
|
for (let i = 0; i < arr.length; i += batchSize) {
|
||||||
|
batches.push(arr.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeMaps = <K, V>(map1: Map<K, V>, map2: Map<K, V>) => {
|
||||||
|
const mergedMap = new Map<K, V>(map1);
|
||||||
|
map2.forEach((value, key) => {
|
||||||
|
mergedMap.set(key, value);
|
||||||
|
});
|
||||||
|
return mergedMap;
|
||||||
|
};
|
26
apps/cast/src/utils/common/key.ts
Normal file
26
apps/cast/src/utils/common/key.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { B64EncryptionResult } from 'types/crypto';
|
||||||
|
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||||
|
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||||
|
import { getKey, SESSION_KEYS } from 'utils/storage/sessionStorage';
|
||||||
|
import { CustomError } from '../error';
|
||||||
|
|
||||||
|
export const getActualKey = async () => {
|
||||||
|
try {
|
||||||
|
const encryptionKeyAttributes: B64EncryptionResult = getKey(
|
||||||
|
SESSION_KEYS.ENCRYPTION_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const key = await cryptoWorker.decryptB64(
|
||||||
|
encryptionKeyAttributes.encryptedData,
|
||||||
|
encryptionKeyAttributes.nonce,
|
||||||
|
encryptionKeyAttributes.key
|
||||||
|
);
|
||||||
|
return key;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(CustomError.KEY_MISSING);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToken = () => getData(LS_KEYS.USER)?.token;
|
||||||
|
export const getUserID = () => getData(LS_KEYS.USER)?.id;
|
365
apps/cast/src/utils/crypto/index.ts
Normal file
365
apps/cast/src/utils/crypto/index.ts
Normal file
|
@ -0,0 +1,365 @@
|
||||||
|
import { KeyAttributes, SRPSetupAttributes } from 'types/user';
|
||||||
|
import { SESSION_KEYS, setKey } from 'utils/storage/sessionStorage';
|
||||||
|
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
|
||||||
|
import { getActualKey, getToken } from 'utils/common/key';
|
||||||
|
import { setRecoveryKey } from 'services/userService';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
// import safeStorageService from 'services/electron/safeStorage';
|
||||||
|
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||||
|
import { PasswordStrength } from 'constants/crypto';
|
||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
import { SRP, SrpClient } from 'fast-srp-hap';
|
||||||
|
import { convertBase64ToBuffer, convertBufferToBase64 } from 'utils/user';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { addLocalLog } from 'utils/logging';
|
||||||
|
|
||||||
|
const SRP_PARAMS = SRP.params['4096'];
|
||||||
|
|
||||||
|
const LOGIN_SUB_KEY_LENGTH = 32;
|
||||||
|
const LOGIN_SUB_KEY_ID = 1;
|
||||||
|
const LOGIN_SUB_KEY_CONTEXT = 'loginctx';
|
||||||
|
const LOGIN_SUB_KEY_BYTE_LENGTH = 16;
|
||||||
|
|
||||||
|
export async function generateKeyAndSRPAttributes(passphrase: string): Promise<{
|
||||||
|
keyAttributes: KeyAttributes;
|
||||||
|
masterKey: string;
|
||||||
|
srpSetupAttributes: SRPSetupAttributes;
|
||||||
|
}> {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const masterKey = await cryptoWorker.generateEncryptionKey();
|
||||||
|
const recoveryKey = await cryptoWorker.generateEncryptionKey();
|
||||||
|
const kekSalt = await cryptoWorker.generateSaltToDeriveKey();
|
||||||
|
const kek = await cryptoWorker.deriveSensitiveKey(passphrase, kekSalt);
|
||||||
|
|
||||||
|
const masterKeyEncryptedWithKek = await cryptoWorker.encryptToB64(
|
||||||
|
masterKey,
|
||||||
|
kek.key
|
||||||
|
);
|
||||||
|
const masterKeyEncryptedWithRecoveryKey = await cryptoWorker.encryptToB64(
|
||||||
|
masterKey,
|
||||||
|
recoveryKey
|
||||||
|
);
|
||||||
|
const recoveryKeyEncryptedWithMasterKey = await cryptoWorker.encryptToB64(
|
||||||
|
recoveryKey,
|
||||||
|
masterKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyPair = await cryptoWorker.generateKeyPair();
|
||||||
|
const encryptedKeyPairAttributes = await cryptoWorker.encryptToB64(
|
||||||
|
keyPair.privateKey,
|
||||||
|
masterKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginSubKey = await generateLoginSubKey(kek.key);
|
||||||
|
|
||||||
|
const srpSetupAttributes = await generateSRPSetupAttributes(loginSubKey);
|
||||||
|
|
||||||
|
const keyAttributes: KeyAttributes = {
|
||||||
|
kekSalt,
|
||||||
|
encryptedKey: masterKeyEncryptedWithKek.encryptedData,
|
||||||
|
keyDecryptionNonce: masterKeyEncryptedWithKek.nonce,
|
||||||
|
publicKey: keyPair.publicKey,
|
||||||
|
encryptedSecretKey: encryptedKeyPairAttributes.encryptedData,
|
||||||
|
secretKeyDecryptionNonce: encryptedKeyPairAttributes.nonce,
|
||||||
|
opsLimit: kek.opsLimit,
|
||||||
|
memLimit: kek.memLimit,
|
||||||
|
masterKeyEncryptedWithRecoveryKey:
|
||||||
|
masterKeyEncryptedWithRecoveryKey.encryptedData,
|
||||||
|
masterKeyDecryptionNonce: masterKeyEncryptedWithRecoveryKey.nonce,
|
||||||
|
recoveryKeyEncryptedWithMasterKey:
|
||||||
|
recoveryKeyEncryptedWithMasterKey.encryptedData,
|
||||||
|
recoveryKeyDecryptionNonce: recoveryKeyEncryptedWithMasterKey.nonce,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyAttributes,
|
||||||
|
masterKey,
|
||||||
|
srpSetupAttributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// We encrypt the masterKey, with an intermediate key derived from the
|
||||||
|
// passphrase (with Interactive mem and ops limits) to avoid saving it to local
|
||||||
|
// storage in plain text. This means that on the web user will always have to
|
||||||
|
// enter their passphrase to access their masterKey.
|
||||||
|
export async function generateAndSaveIntermediateKeyAttributes(
|
||||||
|
passphrase: string,
|
||||||
|
existingKeyAttributes: KeyAttributes,
|
||||||
|
key: string
|
||||||
|
): Promise<KeyAttributes> {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const intermediateKekSalt = await cryptoWorker.generateSaltToDeriveKey();
|
||||||
|
const intermediateKek = await cryptoWorker.deriveInteractiveKey(
|
||||||
|
passphrase,
|
||||||
|
intermediateKekSalt
|
||||||
|
);
|
||||||
|
const encryptedKeyAttributes = await cryptoWorker.encryptToB64(
|
||||||
|
key,
|
||||||
|
intermediateKek.key
|
||||||
|
);
|
||||||
|
|
||||||
|
const intermediateKeyAttributes = Object.assign(existingKeyAttributes, {
|
||||||
|
kekSalt: intermediateKekSalt,
|
||||||
|
encryptedKey: encryptedKeyAttributes.encryptedData,
|
||||||
|
keyDecryptionNonce: encryptedKeyAttributes.nonce,
|
||||||
|
opsLimit: intermediateKek.opsLimit,
|
||||||
|
memLimit: intermediateKek.memLimit,
|
||||||
|
});
|
||||||
|
setData(LS_KEYS.KEY_ATTRIBUTES, intermediateKeyAttributes);
|
||||||
|
return intermediateKeyAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveKeyInSessionStore = async (
|
||||||
|
keyType: SESSION_KEYS,
|
||||||
|
key: string,
|
||||||
|
fromDesktop?: boolean
|
||||||
|
) => {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const sessionKeyAttributes = await cryptoWorker.generateKeyAndEncryptToB64(
|
||||||
|
key
|
||||||
|
);
|
||||||
|
setKey(keyType, sessionKeyAttributes);
|
||||||
|
if (
|
||||||
|
isElectron() &&
|
||||||
|
!fromDesktop &&
|
||||||
|
keyType === SESSION_KEYS.ENCRYPTION_KEY
|
||||||
|
) {
|
||||||
|
// safeStorageService.setEncryptionKey(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecoveryKey = async () => {
|
||||||
|
let recoveryKey: string = null;
|
||||||
|
try {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
|
||||||
|
const keyAttributes: KeyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
|
||||||
|
const {
|
||||||
|
recoveryKeyEncryptedWithMasterKey,
|
||||||
|
recoveryKeyDecryptionNonce,
|
||||||
|
} = keyAttributes;
|
||||||
|
const masterKey = await getActualKey();
|
||||||
|
if (recoveryKeyEncryptedWithMasterKey) {
|
||||||
|
recoveryKey = await cryptoWorker.decryptB64(
|
||||||
|
recoveryKeyEncryptedWithMasterKey,
|
||||||
|
recoveryKeyDecryptionNonce,
|
||||||
|
masterKey
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
recoveryKey = await createNewRecoveryKey();
|
||||||
|
}
|
||||||
|
recoveryKey = await cryptoWorker.toHex(recoveryKey);
|
||||||
|
return recoveryKey;
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'getRecoveryKey failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Used only for legacy users for whom we did not generate recovery keys during
|
||||||
|
// sign up
|
||||||
|
async function createNewRecoveryKey() {
|
||||||
|
const masterKey = await getActualKey();
|
||||||
|
const existingAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
|
||||||
|
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
|
||||||
|
const recoveryKey = await cryptoWorker.generateEncryptionKey();
|
||||||
|
const encryptedMasterKey = await cryptoWorker.encryptToB64(
|
||||||
|
masterKey,
|
||||||
|
recoveryKey
|
||||||
|
);
|
||||||
|
const encryptedRecoveryKey = await cryptoWorker.encryptToB64(
|
||||||
|
recoveryKey,
|
||||||
|
masterKey
|
||||||
|
);
|
||||||
|
const recoveryKeyAttributes = {
|
||||||
|
masterKeyEncryptedWithRecoveryKey: encryptedMasterKey.encryptedData,
|
||||||
|
masterKeyDecryptionNonce: encryptedMasterKey.nonce,
|
||||||
|
recoveryKeyEncryptedWithMasterKey: encryptedRecoveryKey.encryptedData,
|
||||||
|
recoveryKeyDecryptionNonce: encryptedRecoveryKey.nonce,
|
||||||
|
};
|
||||||
|
await setRecoveryKey(getToken(), recoveryKeyAttributes);
|
||||||
|
|
||||||
|
const updatedKeyAttributes = Object.assign(
|
||||||
|
existingAttributes,
|
||||||
|
recoveryKeyAttributes
|
||||||
|
);
|
||||||
|
setData(LS_KEYS.KEY_ATTRIBUTES, updatedKeyAttributes);
|
||||||
|
|
||||||
|
return recoveryKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptAndStoreToken(
|
||||||
|
keyAttributes: KeyAttributes,
|
||||||
|
masterKey: string
|
||||||
|
) {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const user = getData(LS_KEYS.USER);
|
||||||
|
let decryptedToken = null;
|
||||||
|
const { encryptedToken } = user;
|
||||||
|
if (encryptedToken && encryptedToken.length > 0) {
|
||||||
|
const secretKey = await cryptoWorker.decryptB64(
|
||||||
|
keyAttributes.encryptedSecretKey,
|
||||||
|
keyAttributes.secretKeyDecryptionNonce,
|
||||||
|
masterKey
|
||||||
|
);
|
||||||
|
const urlUnsafeB64DecryptedToken = await cryptoWorker.boxSealOpen(
|
||||||
|
encryptedToken,
|
||||||
|
keyAttributes.publicKey,
|
||||||
|
secretKey
|
||||||
|
);
|
||||||
|
const decryptedTokenBytes = await cryptoWorker.fromB64(
|
||||||
|
urlUnsafeB64DecryptedToken
|
||||||
|
);
|
||||||
|
decryptedToken = await cryptoWorker.toURLSafeB64(decryptedTokenBytes);
|
||||||
|
setData(LS_KEYS.USER, {
|
||||||
|
...user,
|
||||||
|
token: decryptedToken,
|
||||||
|
encryptedToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptWithRecoveryKey(key: string) {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const hexRecoveryKey = await getRecoveryKey();
|
||||||
|
const recoveryKey = await cryptoWorker.fromHex(hexRecoveryKey);
|
||||||
|
const encryptedKey = await cryptoWorker.encryptToB64(key, recoveryKey);
|
||||||
|
return encryptedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptDeleteAccountChallenge(
|
||||||
|
encryptedChallenge: string
|
||||||
|
) {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const masterKey = await getActualKey();
|
||||||
|
const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES);
|
||||||
|
const secretKey = await cryptoWorker.decryptB64(
|
||||||
|
keyAttributes.encryptedSecretKey,
|
||||||
|
keyAttributes.secretKeyDecryptionNonce,
|
||||||
|
masterKey
|
||||||
|
);
|
||||||
|
const b64DecryptedChallenge = await cryptoWorker.boxSealOpen(
|
||||||
|
encryptedChallenge,
|
||||||
|
keyAttributes.publicKey,
|
||||||
|
secretKey
|
||||||
|
);
|
||||||
|
const utf8DecryptedChallenge = atob(b64DecryptedChallenge);
|
||||||
|
return utf8DecryptedChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimatePasswordStrength(password: string): PasswordStrength {
|
||||||
|
if (!password) {
|
||||||
|
return PasswordStrength.WEAK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zxcvbnResult = zxcvbn(password);
|
||||||
|
if (zxcvbnResult.score < 2) {
|
||||||
|
return PasswordStrength.WEAK;
|
||||||
|
} else if (zxcvbnResult.score < 3) {
|
||||||
|
return PasswordStrength.MODERATE;
|
||||||
|
} else {
|
||||||
|
return PasswordStrength.STRONG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isWeakPassword = (password: string) => {
|
||||||
|
return estimatePasswordStrength(password) === PasswordStrength.WEAK;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateLoginSubKey = async (kek: string) => {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const kekSubKeyString = await cryptoWorker.generateSubKey(
|
||||||
|
kek,
|
||||||
|
LOGIN_SUB_KEY_LENGTH,
|
||||||
|
LOGIN_SUB_KEY_ID,
|
||||||
|
LOGIN_SUB_KEY_CONTEXT
|
||||||
|
);
|
||||||
|
const kekSubKey = await cryptoWorker.fromB64(kekSubKeyString);
|
||||||
|
|
||||||
|
// use first 16 bytes of generated kekSubKey as loginSubKey
|
||||||
|
const loginSubKey = await cryptoWorker.toB64(
|
||||||
|
kekSubKey.slice(0, LOGIN_SUB_KEY_BYTE_LENGTH)
|
||||||
|
);
|
||||||
|
|
||||||
|
return loginSubKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateSRPSetupAttributes = async (
|
||||||
|
loginSubKey: string
|
||||||
|
): Promise<SRPSetupAttributes> => {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
|
||||||
|
const srpSalt = await cryptoWorker.generateSaltToDeriveKey();
|
||||||
|
|
||||||
|
const srpUserID = uuidv4();
|
||||||
|
|
||||||
|
const srpVerifierBuffer = SRP.computeVerifier(
|
||||||
|
SRP_PARAMS,
|
||||||
|
convertBase64ToBuffer(srpSalt),
|
||||||
|
Buffer.from(srpUserID),
|
||||||
|
convertBase64ToBuffer(loginSubKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const srpVerifier = convertBufferToBase64(srpVerifierBuffer);
|
||||||
|
|
||||||
|
addLocalLog(
|
||||||
|
() => `SRP setup attributes generated',
|
||||||
|
${JSON.stringify({
|
||||||
|
srpSalt,
|
||||||
|
srpUserID,
|
||||||
|
srpVerifier,
|
||||||
|
loginSubKey,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
srpUserID,
|
||||||
|
srpSalt,
|
||||||
|
srpVerifier,
|
||||||
|
loginSubKey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeVerifierHelper = (
|
||||||
|
srpSalt: string,
|
||||||
|
srpUserID: string,
|
||||||
|
loginSubKey: string
|
||||||
|
) => {
|
||||||
|
const srpVerifierBuffer = SRP.computeVerifier(
|
||||||
|
SRP_PARAMS,
|
||||||
|
convertBase64ToBuffer(srpSalt),
|
||||||
|
Buffer.from(srpUserID),
|
||||||
|
convertBase64ToBuffer(loginSubKey)
|
||||||
|
);
|
||||||
|
return convertBufferToBase64(srpVerifierBuffer);
|
||||||
|
};
|
||||||
|
export const generateSRPClient = async (
|
||||||
|
srpSalt: string,
|
||||||
|
srpUserID: string,
|
||||||
|
loginSubKey: string
|
||||||
|
) => {
|
||||||
|
return new Promise<SrpClient>((resolve, reject) => {
|
||||||
|
SRP.genKey(function (err, secret1) {
|
||||||
|
try {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
const srpClient = new SrpClient(
|
||||||
|
SRP_PARAMS,
|
||||||
|
convertBase64ToBuffer(srpSalt),
|
||||||
|
Buffer.from(srpUserID),
|
||||||
|
convertBase64ToBuffer(loginSubKey),
|
||||||
|
secret1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
resolve(srpClient);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
422
apps/cast/src/utils/crypto/libsodium.ts
Normal file
422
apps/cast/src/utils/crypto/libsodium.ts
Normal file
|
@ -0,0 +1,422 @@
|
||||||
|
import sodium, { StateAddress } from 'libsodium-wrappers';
|
||||||
|
import { ENCRYPTION_CHUNK_SIZE } from 'constants/crypto';
|
||||||
|
import { B64EncryptionResult } from 'types/crypto';
|
||||||
|
import { CustomError } from 'utils/error';
|
||||||
|
|
||||||
|
export async function decryptChaChaOneShot(
|
||||||
|
data: Uint8Array,
|
||||||
|
header: Uint8Array,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
||||||
|
header,
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
const pullResult = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
|
pullState,
|
||||||
|
data,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return pullResult.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptChaCha(
|
||||||
|
data: Uint8Array,
|
||||||
|
header: Uint8Array,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
||||||
|
header,
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
const decryptionChunkSize =
|
||||||
|
ENCRYPTION_CHUNK_SIZE +
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
|
||||||
|
let bytesRead = 0;
|
||||||
|
const decryptedData = [];
|
||||||
|
let tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
while (tag !== sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
|
||||||
|
let chunkSize = decryptionChunkSize;
|
||||||
|
if (bytesRead + chunkSize > data.length) {
|
||||||
|
chunkSize = data.length - bytesRead;
|
||||||
|
}
|
||||||
|
const buffer = data.slice(bytesRead, bytesRead + chunkSize);
|
||||||
|
const pullResult = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
|
pullState,
|
||||||
|
buffer
|
||||||
|
);
|
||||||
|
if (!pullResult.message) {
|
||||||
|
throw new Error(CustomError.PROCESSING_FAILED);
|
||||||
|
}
|
||||||
|
for (let index = 0; index < pullResult.message.length; index++) {
|
||||||
|
decryptedData.push(pullResult.message[index]);
|
||||||
|
}
|
||||||
|
tag = pullResult.tag;
|
||||||
|
bytesRead += chunkSize;
|
||||||
|
}
|
||||||
|
return Uint8Array.from(decryptedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initChunkDecryption(header: Uint8Array, key: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
||||||
|
header,
|
||||||
|
key
|
||||||
|
);
|
||||||
|
const decryptionChunkSize =
|
||||||
|
ENCRYPTION_CHUNK_SIZE +
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
|
||||||
|
const tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
return { pullState, decryptionChunkSize, tag };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptFileChunk(
|
||||||
|
data: Uint8Array,
|
||||||
|
pullState: StateAddress
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const pullResult = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
|
pullState,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (!pullResult.message) {
|
||||||
|
throw new Error(CustomError.PROCESSING_FAILED);
|
||||||
|
}
|
||||||
|
const newTag = pullResult.tag;
|
||||||
|
return { decryptedData: pullResult.message, newTag };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptChaChaOneShot(data: Uint8Array, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
|
||||||
|
const uintkey: Uint8Array = await fromB64(key);
|
||||||
|
const initPushResult =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(uintkey);
|
||||||
|
const [pushState, header] = [initPushResult.state, initPushResult.header];
|
||||||
|
|
||||||
|
const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
pushState,
|
||||||
|
data,
|
||||||
|
null,
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key: await toB64(uintkey),
|
||||||
|
file: {
|
||||||
|
encryptedData: pushResult,
|
||||||
|
decryptionHeader: await toB64(header),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptChaCha(data: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
|
||||||
|
const uintkey: Uint8Array =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
|
||||||
|
const initPushResult =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(uintkey);
|
||||||
|
const [pushState, header] = [initPushResult.state, initPushResult.header];
|
||||||
|
let bytesRead = 0;
|
||||||
|
let tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
|
||||||
|
const encryptedData = [];
|
||||||
|
|
||||||
|
while (tag !== sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
|
||||||
|
let chunkSize = ENCRYPTION_CHUNK_SIZE;
|
||||||
|
if (bytesRead + chunkSize >= data.length) {
|
||||||
|
chunkSize = data.length - bytesRead;
|
||||||
|
tag = sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = data.slice(bytesRead, bytesRead + chunkSize);
|
||||||
|
bytesRead += chunkSize;
|
||||||
|
const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
pushState,
|
||||||
|
buffer,
|
||||||
|
null,
|
||||||
|
tag
|
||||||
|
);
|
||||||
|
for (let index = 0; index < pushResult.length; index++) {
|
||||||
|
encryptedData.push(pushResult[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: await toB64(uintkey),
|
||||||
|
file: {
|
||||||
|
encryptedData: new Uint8Array(encryptedData),
|
||||||
|
decryptionHeader: await toB64(header),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initChunkEncryption() {
|
||||||
|
await sodium.ready;
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const initPushResult =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||||
|
const [pushState, header] = [initPushResult.state, initPushResult.header];
|
||||||
|
return {
|
||||||
|
key: await toB64(key),
|
||||||
|
decryptionHeader: await toB64(header),
|
||||||
|
pushState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptFileChunk(
|
||||||
|
data: Uint8Array,
|
||||||
|
pushState: sodium.StateAddress,
|
||||||
|
isFinalChunk: boolean
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
const tag = isFinalChunk
|
||||||
|
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
|
||||||
|
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
||||||
|
const pushResult = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
pushState,
|
||||||
|
data,
|
||||||
|
null,
|
||||||
|
tag
|
||||||
|
);
|
||||||
|
|
||||||
|
return pushResult;
|
||||||
|
}
|
||||||
|
export async function encryptToB64(data: string, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const encrypted = await encrypt(await fromB64(data), await fromB64(key));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedData: await toB64(encrypted.encryptedData),
|
||||||
|
key: await toB64(encrypted.key),
|
||||||
|
nonce: await toB64(encrypted.nonce),
|
||||||
|
} as B64EncryptionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyAndEncryptToB64(data: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const key = sodium.crypto_secretbox_keygen();
|
||||||
|
return await encryptToB64(data, await toB64(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptUTF8(data: string, key: string) {
|
||||||
|
const b64Data = await toB64(await fromUTF8(data));
|
||||||
|
return await encryptToB64(b64Data, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptB64(data: string, nonce: string, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const decrypted = await decrypt(
|
||||||
|
await fromB64(data),
|
||||||
|
await fromB64(nonce),
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
return await toB64(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptToUTF8(data: string, nonce: string, key: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const decrypted = await decrypt(
|
||||||
|
await fromB64(data),
|
||||||
|
await fromB64(nonce),
|
||||||
|
await fromB64(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
return sodium.to_string(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encrypt(data: Uint8Array, key: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const encryptedData = sodium.crypto_secretbox_easy(data, nonce, key);
|
||||||
|
return {
|
||||||
|
encryptedData,
|
||||||
|
key,
|
||||||
|
nonce,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decrypt(data: Uint8Array, nonce: Uint8Array, key: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.crypto_secretbox_open_easy(data, nonce, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initChunkHashing() {
|
||||||
|
await sodium.ready;
|
||||||
|
const hashState = sodium.crypto_generichash_init(
|
||||||
|
null,
|
||||||
|
sodium.crypto_generichash_BYTES_MAX
|
||||||
|
);
|
||||||
|
return hashState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashFileChunk(
|
||||||
|
hashState: sodium.StateAddress,
|
||||||
|
chunk: Uint8Array
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
sodium.crypto_generichash_update(hashState, chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeChunkHashing(hashState: sodium.StateAddress) {
|
||||||
|
await sodium.ready;
|
||||||
|
const hash = sodium.crypto_generichash_final(
|
||||||
|
hashState,
|
||||||
|
sodium.crypto_generichash_BYTES_MAX
|
||||||
|
);
|
||||||
|
const hashString = toB64(hash);
|
||||||
|
return hashString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveKey(
|
||||||
|
passphrase: string,
|
||||||
|
salt: string,
|
||||||
|
opsLimit: number,
|
||||||
|
memLimit: number
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_pwhash(
|
||||||
|
sodium.crypto_secretbox_KEYBYTES,
|
||||||
|
await fromUTF8(passphrase),
|
||||||
|
await fromB64(salt),
|
||||||
|
opsLimit,
|
||||||
|
memLimit,
|
||||||
|
sodium.crypto_pwhash_ALG_ARGON2ID13
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveSensitiveKey(passphrase: string, salt: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const minMemLimit = sodium.crypto_pwhash_MEMLIMIT_MIN;
|
||||||
|
let opsLimit = sodium.crypto_pwhash_OPSLIMIT_SENSITIVE;
|
||||||
|
let memLimit = sodium.crypto_pwhash_MEMLIMIT_SENSITIVE;
|
||||||
|
while (memLimit > minMemLimit) {
|
||||||
|
try {
|
||||||
|
const key = await deriveKey(passphrase, salt, opsLimit, memLimit);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
opsLimit,
|
||||||
|
memLimit,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
opsLimit *= 2;
|
||||||
|
memLimit /= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveInteractiveKey(passphrase: string, salt: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
const key = await toB64(
|
||||||
|
sodium.crypto_pwhash(
|
||||||
|
sodium.crypto_secretbox_KEYBYTES,
|
||||||
|
await fromUTF8(passphrase),
|
||||||
|
await fromB64(salt),
|
||||||
|
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||||
|
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||||
|
sodium.crypto_pwhash_ALG_ARGON2ID13
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
opsLimit: sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||||
|
memLimit: sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateEncryptionKey() {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(sodium.crypto_kdf_keygen());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSaltToDeriveKey() {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyPair() {
|
||||||
|
await sodium.ready;
|
||||||
|
const keyPair: sodium.KeyPair = sodium.crypto_box_keypair();
|
||||||
|
return {
|
||||||
|
privateKey: await toB64(keyPair.privateKey),
|
||||||
|
publicKey: await toB64(keyPair.publicKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function boxSealOpen(
|
||||||
|
input: string,
|
||||||
|
publicKey: string,
|
||||||
|
secretKey: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_box_seal_open(
|
||||||
|
await fromB64(input),
|
||||||
|
await fromB64(publicKey),
|
||||||
|
await fromB64(secretKey)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function boxSeal(input: string, publicKey: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_box_seal(await fromB64(input), await fromB64(publicKey))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSubKey(
|
||||||
|
key: string,
|
||||||
|
subKeyLength: number,
|
||||||
|
subKeyID: number,
|
||||||
|
context: string
|
||||||
|
) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(
|
||||||
|
sodium.crypto_kdf_derive_from_key(
|
||||||
|
subKeyLength,
|
||||||
|
subKeyID,
|
||||||
|
context,
|
||||||
|
await fromB64(key)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fromB64(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.from_base64(input, sodium.base64_variants.ORIGINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toB64(input: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_base64(input, sodium.base64_variants.ORIGINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toURLSafeB64(input: Uint8Array) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_base64(input, sodium.base64_variants.URLSAFE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fromUTF8(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.from_string(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toUTF8(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_string(await fromB64(input));
|
||||||
|
}
|
||||||
|
export async function toHex(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium.to_hex(await fromB64(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fromHex(input: string) {
|
||||||
|
await sodium.ready;
|
||||||
|
return await toB64(sodium.from_hex(input));
|
||||||
|
}
|
163
apps/cast/src/utils/error/index.ts
Normal file
163
apps/cast/src/utils/error/index.ts
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
export class ApiErrorResponse {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export class ApiError extends Error {
|
||||||
|
httpStatusCode: number;
|
||||||
|
errCode: string;
|
||||||
|
|
||||||
|
constructor(message: string, errCode: string, httpStatus: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.errCode = errCode;
|
||||||
|
this.httpStatusCode = httpStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiErrorResponse(object: any): object is ApiErrorResponse {
|
||||||
|
return object && 'code' in object && 'message' in object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomError = {
|
||||||
|
THUMBNAIL_GENERATION_FAILED: 'thumbnail generation failed',
|
||||||
|
VIDEO_PLAYBACK_FAILED: 'video playback failed',
|
||||||
|
ETAG_MISSING: 'no header/etag present in response body',
|
||||||
|
KEY_MISSING: 'encrypted key missing from localStorage',
|
||||||
|
FAILED_TO_LOAD_WEB_WORKER: 'failed to load web worker',
|
||||||
|
CHUNK_MORE_THAN_EXPECTED: 'chunks more than expected',
|
||||||
|
CHUNK_LESS_THAN_EXPECTED: 'chunks less than expected',
|
||||||
|
UNSUPPORTED_FILE_FORMAT: 'unsupported file format',
|
||||||
|
FILE_TOO_LARGE: 'file too large',
|
||||||
|
SUBSCRIPTION_EXPIRED: 'subscription expired',
|
||||||
|
STORAGE_QUOTA_EXCEEDED: 'storage quota exceeded',
|
||||||
|
SESSION_EXPIRED: 'session expired',
|
||||||
|
INVALID_MIME_TYPE: (type: string) => `invalid mime type -${type}`,
|
||||||
|
SIGNUP_FAILED: 'signup failed',
|
||||||
|
FAV_COLLECTION_MISSING: 'favorite collection missing',
|
||||||
|
INVALID_COLLECTION_OPERATION: 'invalid collection operation',
|
||||||
|
TO_MOVE_FILES_FROM_MULTIPLE_COLLECTIONS:
|
||||||
|
'to move files from multiple collections',
|
||||||
|
WAIT_TIME_EXCEEDED: 'operation wait time exceeded',
|
||||||
|
REQUEST_CANCELLED: 'request canceled',
|
||||||
|
REQUEST_FAILED: 'request failed',
|
||||||
|
TOKEN_EXPIRED: 'token expired',
|
||||||
|
TOKEN_MISSING: 'token missing',
|
||||||
|
TOO_MANY_REQUESTS: 'too many requests',
|
||||||
|
BAD_REQUEST: 'bad request',
|
||||||
|
SUBSCRIPTION_NEEDED: 'subscription not present',
|
||||||
|
NOT_FOUND: 'not found ',
|
||||||
|
NO_METADATA: 'no metadata',
|
||||||
|
TOO_LARGE_LIVE_PHOTO_ASSETS: 'too large live photo assets',
|
||||||
|
NOT_A_DATE: 'not a date',
|
||||||
|
NOT_A_LOCATION: 'not a location',
|
||||||
|
FILE_ID_NOT_FOUND: 'file with id not found',
|
||||||
|
WEAK_DEVICE: 'password decryption failed on the device',
|
||||||
|
INCORRECT_PASSWORD: 'incorrect password',
|
||||||
|
UPLOAD_CANCELLED: 'upload cancelled',
|
||||||
|
REQUEST_TIMEOUT: 'request taking too long',
|
||||||
|
HIDDEN_COLLECTION_SYNC_FILE_ATTEMPTED:
|
||||||
|
'hidden collection sync file attempted',
|
||||||
|
UNKNOWN_ERROR: 'Something went wrong, please try again',
|
||||||
|
TYPE_DETECTION_FAILED: (fileFormat: string) =>
|
||||||
|
`type detection failed ${fileFormat}`,
|
||||||
|
WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED:
|
||||||
|
'Windows native image processing is not supported',
|
||||||
|
NETWORK_ERROR: 'Network Error',
|
||||||
|
NOT_FILE_OWNER: 'not file owner',
|
||||||
|
UPDATE_EXPORTED_RECORD_FAILED: 'update file exported record failed',
|
||||||
|
EXPORT_STOPPED: 'export stopped',
|
||||||
|
NO_EXPORT_FOLDER_SELECTED: 'no export folder selected',
|
||||||
|
EXPORT_FOLDER_DOES_NOT_EXIST: 'export folder does not exist',
|
||||||
|
NO_INTERNET_CONNECTION: 'no internet connection',
|
||||||
|
AUTH_KEY_NOT_FOUND: 'auth key not found',
|
||||||
|
EXIF_DATA_NOT_FOUND: 'exif data not found',
|
||||||
|
SELECT_FOLDER_ABORTED: 'select folder aborted',
|
||||||
|
NON_MEDIA_FILE: 'non media file',
|
||||||
|
NOT_AVAILABLE_ON_WEB: 'not available on web',
|
||||||
|
UNSUPPORTED_RAW_FORMAT: 'unsupported raw format',
|
||||||
|
NON_PREVIEWABLE_FILE: 'non previewable file',
|
||||||
|
PROCESSING_FAILED: 'processing failed',
|
||||||
|
EXPORT_RECORD_JSON_PARSING_FAILED: 'export record json parsing failed',
|
||||||
|
TWO_FACTOR_ENABLED: 'two factor enabled',
|
||||||
|
CLIENT_ERROR: 'client error',
|
||||||
|
ServerError: 'server error',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function handleUploadError(error): Error {
|
||||||
|
const parsedError = parseUploadErrorCodes(error);
|
||||||
|
|
||||||
|
// breaking errors
|
||||||
|
switch (parsedError.message) {
|
||||||
|
case CustomError.SUBSCRIPTION_EXPIRED:
|
||||||
|
case CustomError.STORAGE_QUOTA_EXCEEDED:
|
||||||
|
case CustomError.SESSION_EXPIRED:
|
||||||
|
case CustomError.UPLOAD_CANCELLED:
|
||||||
|
throw parsedError;
|
||||||
|
}
|
||||||
|
return parsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorWithContext(originalError: Error, context: string) {
|
||||||
|
const errorWithContext = new Error(context);
|
||||||
|
errorWithContext.stack =
|
||||||
|
errorWithContext.stack.split('\n').slice(2, 4).join('\n') +
|
||||||
|
'\n' +
|
||||||
|
originalError.stack;
|
||||||
|
return errorWithContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUploadErrorCodes(error) {
|
||||||
|
let parsedMessage = null;
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
switch (error.httpStatusCode) {
|
||||||
|
case HttpStatusCode.PaymentRequired:
|
||||||
|
parsedMessage = CustomError.SUBSCRIPTION_EXPIRED;
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.UpgradeRequired:
|
||||||
|
parsedMessage = CustomError.STORAGE_QUOTA_EXCEEDED;
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.Unauthorized:
|
||||||
|
parsedMessage = CustomError.SESSION_EXPIRED;
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.PayloadTooLarge:
|
||||||
|
parsedMessage = CustomError.FILE_TOO_LARGE;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
parsedMessage = `${CustomError.UNKNOWN_ERROR} statusCode:${error.httpStatusCode}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedMessage = error.message;
|
||||||
|
}
|
||||||
|
return new Error(parsedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseSharingErrorCodes = (error) => {
|
||||||
|
let parsedMessage = null;
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
switch (error.httpStatusCode) {
|
||||||
|
case HttpStatusCode.BadRequest:
|
||||||
|
parsedMessage = CustomError.BAD_REQUEST;
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.PaymentRequired:
|
||||||
|
parsedMessage = CustomError.SUBSCRIPTION_NEEDED;
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.NotFound:
|
||||||
|
parsedMessage = CustomError.NOT_FOUND;
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.Unauthorized:
|
||||||
|
case HttpStatusCode.Gone:
|
||||||
|
parsedMessage = CustomError.TOKEN_EXPIRED;
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.TooManyRequests:
|
||||||
|
parsedMessage = CustomError.TOO_MANY_REQUESTS;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
parsedMessage = `${CustomError.UNKNOWN_ERROR} statusCode:${error.httpStatusCode}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedMessage = error.message;
|
||||||
|
}
|
||||||
|
return new Error(parsedMessage);
|
||||||
|
};
|
15
apps/cast/src/utils/file/blob.ts
Normal file
15
apps/cast/src/utils/file/blob.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export const readAsDataURL = (blob) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.onload = () => resolve(fileReader.result as string);
|
||||||
|
fileReader.onerror = () => reject(fileReader.error);
|
||||||
|
fileReader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const readAsText = (blob) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.onload = () => resolve(fileReader.result as string);
|
||||||
|
fileReader.onerror = () => reject(fileReader.error);
|
||||||
|
fileReader.readAsText(blob);
|
||||||
|
});
|
967
apps/cast/src/utils/file/index.ts
Normal file
967
apps/cast/src/utils/file/index.ts
Normal file
|
@ -0,0 +1,967 @@
|
||||||
|
import { SelectedState } from 'types/gallery';
|
||||||
|
import {
|
||||||
|
EnteFile,
|
||||||
|
EncryptedEnteFile,
|
||||||
|
FileWithUpdatedMagicMetadata,
|
||||||
|
FileMagicMetadata,
|
||||||
|
FileMagicMetadataProps,
|
||||||
|
FilePublicMagicMetadata,
|
||||||
|
FilePublicMagicMetadataProps,
|
||||||
|
} from 'types/file';
|
||||||
|
import { decodeLivePhoto } from 'services/livePhotoService';
|
||||||
|
import { getFileType } from 'services/typeDetectionService';
|
||||||
|
import DownloadManager from 'services/downloadManager';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import { User } from 'types/user';
|
||||||
|
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||||
|
import { updateFileCreationDateInEXIF } from 'services/upload/exifService';
|
||||||
|
import {
|
||||||
|
TYPE_JPEG,
|
||||||
|
TYPE_JPG,
|
||||||
|
TYPE_HEIC,
|
||||||
|
TYPE_HEIF,
|
||||||
|
FILE_TYPE,
|
||||||
|
SUPPORTED_RAW_FORMATS,
|
||||||
|
RAW_FORMATS,
|
||||||
|
} from 'constants/file';
|
||||||
|
import PublicCollectionDownloadManager from 'services/publicCollectionDownloadManager';
|
||||||
|
import heicConversionService from 'services/heicConversionService';
|
||||||
|
import * as ffmpegService from 'services/ffmpeg/ffmpegService';
|
||||||
|
import { VISIBILITY_STATE } from 'types/magicMetadata';
|
||||||
|
import { isArchivedFile, updateMagicMetadata } from 'utils/magicMetadata';
|
||||||
|
|
||||||
|
import { addLocalLog, addLogLine } from 'utils/logging';
|
||||||
|
import { CustomError } from 'utils/error';
|
||||||
|
import { convertBytesToHumanReadable } from './size';
|
||||||
|
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||||
|
import {
|
||||||
|
deleteFromTrash,
|
||||||
|
trashFiles,
|
||||||
|
updateFileMagicMetadata,
|
||||||
|
updateFilePublicMagicMetadata,
|
||||||
|
} from 'services/fileService';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
import imageProcessor from 'services/electron/imageProcessor';
|
||||||
|
import { isPlaybackPossible } from 'utils/photoFrame';
|
||||||
|
import { FileTypeInfo } from 'types/upload';
|
||||||
|
import { moveToHiddenCollection } from 'services/collectionService';
|
||||||
|
|
||||||
|
import ElectronFSService from 'services/electron/fs';
|
||||||
|
import { getFileExportPath, getUniqueFileExportName } from 'utils/export';
|
||||||
|
|
||||||
|
const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
|
||||||
|
|
||||||
|
export enum FILE_OPS_TYPE {
|
||||||
|
DOWNLOAD,
|
||||||
|
FIX_TIME,
|
||||||
|
ARCHIVE,
|
||||||
|
UNARCHIVE,
|
||||||
|
HIDE,
|
||||||
|
TRASH,
|
||||||
|
DELETE_PERMANENTLY,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadAsFile(filename: string, content: string) {
|
||||||
|
const file = new Blob([content], {
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
const fileURL = URL.createObjectURL(file);
|
||||||
|
downloadUsingAnchor(fileURL, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpdatedEXIFFileForDownload(
|
||||||
|
fileReader: FileReader,
|
||||||
|
file: EnteFile,
|
||||||
|
fileStream: ReadableStream<Uint8Array>
|
||||||
|
): Promise<ReadableStream<Uint8Array>> {
|
||||||
|
const extension = getFileExtension(file.metadata.title);
|
||||||
|
if (
|
||||||
|
file.metadata.fileType === FILE_TYPE.IMAGE &&
|
||||||
|
file.pubMagicMetadata?.data.editedTime &&
|
||||||
|
(extension === TYPE_JPEG || extension === TYPE_JPG)
|
||||||
|
) {
|
||||||
|
const fileBlob = await new Response(fileStream).blob();
|
||||||
|
const updatedFileBlob = await updateFileCreationDateInEXIF(
|
||||||
|
fileReader,
|
||||||
|
fileBlob,
|
||||||
|
new Date(file.pubMagicMetadata.data.editedTime / 1000)
|
||||||
|
);
|
||||||
|
return updatedFileBlob.stream();
|
||||||
|
} else {
|
||||||
|
return fileStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFile(
|
||||||
|
file: EnteFile,
|
||||||
|
accessedThroughSharedURL: boolean,
|
||||||
|
token?: string,
|
||||||
|
passwordToken?: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let fileBlob: Blob;
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
if (accessedThroughSharedURL) {
|
||||||
|
const fileURL =
|
||||||
|
await PublicCollectionDownloadManager.getCachedOriginalFile(
|
||||||
|
file
|
||||||
|
)[0];
|
||||||
|
if (!fileURL) {
|
||||||
|
fileBlob = await new Response(
|
||||||
|
await PublicCollectionDownloadManager.downloadFile(
|
||||||
|
token,
|
||||||
|
passwordToken,
|
||||||
|
file
|
||||||
|
)
|
||||||
|
).blob();
|
||||||
|
} else {
|
||||||
|
fileBlob = await (await fetch(fileURL)).blob();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileURL = await DownloadManager.getCachedOriginalFile(
|
||||||
|
file
|
||||||
|
)[0];
|
||||||
|
if (!fileURL) {
|
||||||
|
fileBlob = await new Response(
|
||||||
|
await DownloadManager.downloadFile(file)
|
||||||
|
).blob();
|
||||||
|
} else {
|
||||||
|
fileBlob = await (await fetch(fileURL)).blob();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
||||||
|
const livePhoto = await decodeLivePhoto(file, fileBlob);
|
||||||
|
const image = new File([livePhoto.image], livePhoto.imageNameTitle);
|
||||||
|
const imageType = await getFileType(image);
|
||||||
|
const tempImageURL = URL.createObjectURL(
|
||||||
|
new Blob([livePhoto.image], { type: imageType.mimeType })
|
||||||
|
);
|
||||||
|
const video = new File([livePhoto.video], livePhoto.videoNameTitle);
|
||||||
|
const videoType = await getFileType(video);
|
||||||
|
const tempVideoURL = URL.createObjectURL(
|
||||||
|
new Blob([livePhoto.video], { type: videoType.mimeType })
|
||||||
|
);
|
||||||
|
downloadUsingAnchor(tempImageURL, livePhoto.imageNameTitle);
|
||||||
|
downloadUsingAnchor(tempVideoURL, livePhoto.videoNameTitle);
|
||||||
|
} else {
|
||||||
|
const fileType = await getFileType(
|
||||||
|
new File([fileBlob], file.metadata.title)
|
||||||
|
);
|
||||||
|
fileBlob = await new Response(
|
||||||
|
await getUpdatedEXIFFileForDownload(
|
||||||
|
fileReader,
|
||||||
|
file,
|
||||||
|
fileBlob.stream()
|
||||||
|
)
|
||||||
|
).blob();
|
||||||
|
fileBlob = new Blob([fileBlob], { type: fileType.mimeType });
|
||||||
|
const tempURL = URL.createObjectURL(fileBlob);
|
||||||
|
downloadUsingAnchor(tempURL, file.metadata.title);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to download file');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadUsingAnchor(link: string, name: string) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = link;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(link);
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupFilesBasedOnCollectionID(files: EnteFile[]) {
|
||||||
|
const collectionWiseFiles = new Map<number, EnteFile[]>();
|
||||||
|
for (const file of files) {
|
||||||
|
if (!collectionWiseFiles.has(file.collectionID)) {
|
||||||
|
collectionWiseFiles.set(file.collectionID, []);
|
||||||
|
}
|
||||||
|
collectionWiseFiles.get(file.collectionID).push(file);
|
||||||
|
}
|
||||||
|
return collectionWiseFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedFileIds(selectedFiles: SelectedState) {
|
||||||
|
const filesIDs: number[] = [];
|
||||||
|
for (const [key, val] of Object.entries(selectedFiles)) {
|
||||||
|
if (typeof val === 'boolean' && val) {
|
||||||
|
filesIDs.push(Number(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Set(filesIDs);
|
||||||
|
}
|
||||||
|
export function getSelectedFiles(
|
||||||
|
selected: SelectedState,
|
||||||
|
files: EnteFile[]
|
||||||
|
): EnteFile[] {
|
||||||
|
const selectedFilesIDs = getSelectedFileIds(selected);
|
||||||
|
return files.filter((file) => selectedFilesIDs.has(file.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortFiles(files: EnteFile[], sortAsc = false) {
|
||||||
|
// sort based on the time of creation time of the file,
|
||||||
|
// for files with same creation time, sort based on the time of last modification
|
||||||
|
const factor = sortAsc ? -1 : 1;
|
||||||
|
return files.sort((a, b) => {
|
||||||
|
if (a.metadata.creationTime === b.metadata.creationTime) {
|
||||||
|
return (
|
||||||
|
factor *
|
||||||
|
(b.metadata.modificationTime - a.metadata.modificationTime)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return factor * (b.metadata.creationTime - a.metadata.creationTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortTrashFiles(files: EnteFile[]) {
|
||||||
|
return files.sort((a, b) => {
|
||||||
|
if (a.deleteBy === b.deleteBy) {
|
||||||
|
if (a.metadata.creationTime === b.metadata.creationTime) {
|
||||||
|
return (
|
||||||
|
b.metadata.modificationTime - a.metadata.modificationTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return b.metadata.creationTime - a.metadata.creationTime;
|
||||||
|
}
|
||||||
|
return a.deleteBy - b.deleteBy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptFile(
|
||||||
|
file: EncryptedEnteFile,
|
||||||
|
collectionKey: string
|
||||||
|
): Promise<EnteFile> {
|
||||||
|
try {
|
||||||
|
const worker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
const {
|
||||||
|
encryptedKey,
|
||||||
|
keyDecryptionNonce,
|
||||||
|
metadata,
|
||||||
|
magicMetadata,
|
||||||
|
pubMagicMetadata,
|
||||||
|
...restFileProps
|
||||||
|
} = file;
|
||||||
|
const fileKey = await worker.decryptB64(
|
||||||
|
encryptedKey,
|
||||||
|
keyDecryptionNonce,
|
||||||
|
collectionKey
|
||||||
|
);
|
||||||
|
const fileMetadata = await worker.decryptMetadata(
|
||||||
|
metadata.encryptedData,
|
||||||
|
metadata.decryptionHeader,
|
||||||
|
fileKey
|
||||||
|
);
|
||||||
|
let fileMagicMetadata: FileMagicMetadata;
|
||||||
|
let filePubMagicMetadata: FilePublicMagicMetadata;
|
||||||
|
if (magicMetadata?.data) {
|
||||||
|
fileMagicMetadata = {
|
||||||
|
...file.magicMetadata,
|
||||||
|
data: await worker.decryptMetadata(
|
||||||
|
magicMetadata.data,
|
||||||
|
magicMetadata.header,
|
||||||
|
fileKey
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pubMagicMetadata?.data) {
|
||||||
|
filePubMagicMetadata = {
|
||||||
|
...pubMagicMetadata,
|
||||||
|
data: await worker.decryptMetadata(
|
||||||
|
pubMagicMetadata.data,
|
||||||
|
pubMagicMetadata.header,
|
||||||
|
fileKey
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...restFileProps,
|
||||||
|
key: fileKey,
|
||||||
|
metadata: fileMetadata,
|
||||||
|
magicMetadata: fileMagicMetadata,
|
||||||
|
pubMagicMetadata: filePubMagicMetadata,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'file decryption failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileNameWithoutExtension(filename: string) {
|
||||||
|
const lastDotPosition = filename.lastIndexOf('.');
|
||||||
|
if (lastDotPosition === -1) return filename;
|
||||||
|
else return filename.slice(0, lastDotPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileExtensionWithDot(filename: string) {
|
||||||
|
const lastDotPosition = filename.lastIndexOf('.');
|
||||||
|
if (lastDotPosition === -1) return '';
|
||||||
|
else return filename.slice(lastDotPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitFilenameAndExtension(filename: string): [string, string] {
|
||||||
|
const lastDotPosition = filename.lastIndexOf('.');
|
||||||
|
if (lastDotPosition === -1) return [filename, null];
|
||||||
|
else
|
||||||
|
return [
|
||||||
|
filename.slice(0, lastDotPosition),
|
||||||
|
filename.slice(lastDotPosition + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileExtension(filename: string) {
|
||||||
|
return splitFilenameAndExtension(filename)[1]?.toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStreamFromArrayBuffer(data: Uint8Array) {
|
||||||
|
return new ReadableStream({
|
||||||
|
async start(controller: ReadableStreamDefaultController) {
|
||||||
|
controller.enqueue(data);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRenderableFileURL(file: EnteFile, fileBlob: Blob) {
|
||||||
|
switch (file.metadata.fileType) {
|
||||||
|
case FILE_TYPE.IMAGE: {
|
||||||
|
const convertedBlob = await getRenderableImage(
|
||||||
|
file.metadata.title,
|
||||||
|
fileBlob
|
||||||
|
);
|
||||||
|
const { originalURL, convertedURL } = getFileObjectURLs(
|
||||||
|
fileBlob,
|
||||||
|
convertedBlob
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
converted: [convertedURL],
|
||||||
|
original: [originalURL],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case FILE_TYPE.LIVE_PHOTO: {
|
||||||
|
return await getRenderableLivePhotoURL(file, fileBlob);
|
||||||
|
}
|
||||||
|
case FILE_TYPE.VIDEO: {
|
||||||
|
const convertedBlob = await getPlayableVideo(
|
||||||
|
file.metadata.title,
|
||||||
|
fileBlob
|
||||||
|
);
|
||||||
|
const { originalURL, convertedURL } = getFileObjectURLs(
|
||||||
|
fileBlob,
|
||||||
|
convertedBlob
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
converted: [convertedURL],
|
||||||
|
original: [originalURL],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const previewURL = await createTypedObjectURL(
|
||||||
|
fileBlob,
|
||||||
|
file.metadata.title
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
converted: [previewURL],
|
||||||
|
original: [previewURL],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRenderableLivePhotoURL(
|
||||||
|
file: EnteFile,
|
||||||
|
fileBlob: Blob
|
||||||
|
): Promise<{ original: string[]; converted: string[] }> {
|
||||||
|
const livePhoto = await decodeLivePhoto(file, fileBlob);
|
||||||
|
const imageBlob = new Blob([livePhoto.image]);
|
||||||
|
const videoBlob = new Blob([livePhoto.video]);
|
||||||
|
const convertedImageBlob = await getRenderableImage(
|
||||||
|
livePhoto.imageNameTitle,
|
||||||
|
imageBlob
|
||||||
|
);
|
||||||
|
const convertedVideoBlob = await getPlayableVideo(
|
||||||
|
livePhoto.videoNameTitle,
|
||||||
|
videoBlob,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const { originalURL: originalImageURL, convertedURL: convertedImageURL } =
|
||||||
|
getFileObjectURLs(imageBlob, convertedImageBlob);
|
||||||
|
|
||||||
|
const { originalURL: originalVideoURL, convertedURL: convertedVideoURL } =
|
||||||
|
getFileObjectURLs(videoBlob, convertedVideoBlob);
|
||||||
|
return {
|
||||||
|
converted: [convertedImageURL, convertedVideoURL],
|
||||||
|
original: [originalImageURL, originalVideoURL],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlayableVideo(
|
||||||
|
videoNameTitle: string,
|
||||||
|
videoBlob: Blob,
|
||||||
|
forceConvert = false
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const isPlayable = await isPlaybackPossible(
|
||||||
|
URL.createObjectURL(videoBlob)
|
||||||
|
);
|
||||||
|
if (isPlayable && !forceConvert) {
|
||||||
|
return videoBlob;
|
||||||
|
} else {
|
||||||
|
if (!forceConvert && !isElectron()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
addLogLine(
|
||||||
|
'video format not supported, converting it name:',
|
||||||
|
videoNameTitle
|
||||||
|
);
|
||||||
|
const mp4ConvertedVideo = await ffmpegService.convertToMP4(
|
||||||
|
new File([videoBlob], videoNameTitle)
|
||||||
|
);
|
||||||
|
addLogLine('video successfully converted', videoNameTitle);
|
||||||
|
return new Blob([await mp4ConvertedVideo.arrayBuffer()]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addLogLine('video conversion failed', videoNameTitle);
|
||||||
|
logError(e, 'video conversion failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRenderableImage(fileName: string, imageBlob: Blob) {
|
||||||
|
let fileTypeInfo: FileTypeInfo;
|
||||||
|
try {
|
||||||
|
const tempFile = new File([imageBlob], fileName);
|
||||||
|
fileTypeInfo = await getFileType(tempFile);
|
||||||
|
addLocalLog(() => `file type info: ${JSON.stringify(fileTypeInfo)}`);
|
||||||
|
const { exactType } = fileTypeInfo;
|
||||||
|
let convertedImageBlob: Blob;
|
||||||
|
if (isRawFile(exactType)) {
|
||||||
|
try {
|
||||||
|
if (!isSupportedRawFormat(exactType)) {
|
||||||
|
throw Error(CustomError.UNSUPPORTED_RAW_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isElectron()) {
|
||||||
|
throw Error(CustomError.NOT_AVAILABLE_ON_WEB);
|
||||||
|
}
|
||||||
|
addLogLine(
|
||||||
|
`RawConverter called for ${fileName}-${convertBytesToHumanReadable(
|
||||||
|
imageBlob.size
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
convertedImageBlob = await imageProcessor.convertToJPEG(
|
||||||
|
imageBlob,
|
||||||
|
fileName
|
||||||
|
);
|
||||||
|
addLogLine(`${fileName} successfully converted`);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
if (!isFileHEIC(exactType)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
addLogLine(
|
||||||
|
`HEICConverter called for ${fileName}-${convertBytesToHumanReadable(
|
||||||
|
imageBlob.size
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
convertedImageBlob = await heicConversionService.convert(
|
||||||
|
imageBlob
|
||||||
|
);
|
||||||
|
addLogLine(`${fileName} successfully converted`);
|
||||||
|
} catch (e) {
|
||||||
|
throw Error(CustomError.NON_PREVIEWABLE_FILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return convertedImageBlob;
|
||||||
|
} else {
|
||||||
|
return imageBlob;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'get Renderable Image failed', { fileTypeInfo });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFileHEIC(exactType: string) {
|
||||||
|
return (
|
||||||
|
exactType.toLowerCase().endsWith(TYPE_HEIC) ||
|
||||||
|
exactType.toLowerCase().endsWith(TYPE_HEIF)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRawFile(exactType: string) {
|
||||||
|
return RAW_FORMATS.includes(exactType.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRawFileFromFileName(fileName: string) {
|
||||||
|
for (const rawFormat of RAW_FORMATS) {
|
||||||
|
if (fileName.toLowerCase().endsWith(rawFormat)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedRawFormat(exactType: string) {
|
||||||
|
return SUPPORTED_RAW_FORMATS.includes(exactType.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeFilesVisibility(
|
||||||
|
files: EnteFile[],
|
||||||
|
visibility: VISIBILITY_STATE
|
||||||
|
): Promise<EnteFile[]> {
|
||||||
|
const fileWithUpdatedMagicMetadataList: FileWithUpdatedMagicMetadata[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const updatedMagicMetadataProps: FileMagicMetadataProps = {
|
||||||
|
visibility,
|
||||||
|
};
|
||||||
|
|
||||||
|
fileWithUpdatedMagicMetadataList.push({
|
||||||
|
file,
|
||||||
|
updatedMagicMetadata: await updateMagicMetadata(
|
||||||
|
updatedMagicMetadataProps,
|
||||||
|
file.magicMetadata,
|
||||||
|
file.key
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await updateFileMagicMetadata(fileWithUpdatedMagicMetadataList);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeFileCreationTime(
|
||||||
|
file: EnteFile,
|
||||||
|
editedTime: number
|
||||||
|
): Promise<EnteFile> {
|
||||||
|
const updatedPublicMagicMetadataProps: FilePublicMagicMetadataProps = {
|
||||||
|
editedTime,
|
||||||
|
};
|
||||||
|
const updatedPublicMagicMetadata: FilePublicMagicMetadata =
|
||||||
|
await updateMagicMetadata(
|
||||||
|
updatedPublicMagicMetadataProps,
|
||||||
|
file.pubMagicMetadata,
|
||||||
|
file.key
|
||||||
|
);
|
||||||
|
const updateResult = await updateFilePublicMagicMetadata([
|
||||||
|
{ file, updatedPublicMagicMetadata },
|
||||||
|
]);
|
||||||
|
return updateResult[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeFileName(
|
||||||
|
file: EnteFile,
|
||||||
|
editedName: string
|
||||||
|
): Promise<EnteFile> {
|
||||||
|
const updatedPublicMagicMetadataProps: FilePublicMagicMetadataProps = {
|
||||||
|
editedName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPublicMagicMetadata: FilePublicMagicMetadata =
|
||||||
|
await updateMagicMetadata(
|
||||||
|
updatedPublicMagicMetadataProps,
|
||||||
|
file.pubMagicMetadata,
|
||||||
|
file.key
|
||||||
|
);
|
||||||
|
const updateResult = await updateFilePublicMagicMetadata([
|
||||||
|
{ file, updatedPublicMagicMetadata },
|
||||||
|
]);
|
||||||
|
return updateResult[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeCaption(
|
||||||
|
file: EnteFile,
|
||||||
|
caption: string
|
||||||
|
): Promise<EnteFile> {
|
||||||
|
const updatedPublicMagicMetadataProps: FilePublicMagicMetadataProps = {
|
||||||
|
caption,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPublicMagicMetadata: FilePublicMagicMetadata =
|
||||||
|
await updateMagicMetadata(
|
||||||
|
updatedPublicMagicMetadataProps,
|
||||||
|
file.pubMagicMetadata,
|
||||||
|
file.key
|
||||||
|
);
|
||||||
|
const updateResult = await updateFilePublicMagicMetadata([
|
||||||
|
{ file, updatedPublicMagicMetadata },
|
||||||
|
]);
|
||||||
|
return updateResult[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSharedFile(user: User, file: EnteFile) {
|
||||||
|
if (!user?.id || !file?.ownerID) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return file.ownerID !== user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 function updateExistingFilePubMetadata(
|
||||||
|
existingFile: EnteFile,
|
||||||
|
updatedFile: EnteFile
|
||||||
|
) {
|
||||||
|
existingFile.pubMagicMetadata = updatedFile.pubMagicMetadata;
|
||||||
|
existingFile.metadata = mergeMetadata([existingFile])[0].metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileFromURL(fileURL: string) {
|
||||||
|
const fileBlob = await (await fetch(fileURL)).blob();
|
||||||
|
const fileFile = new File([fileBlob], 'temp');
|
||||||
|
return fileFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUniqueFiles(files: EnteFile[]) {
|
||||||
|
const idSet = new Set<number>();
|
||||||
|
const uniqueFiles = files.filter((file) => {
|
||||||
|
if (!idSet.has(file.id)) {
|
||||||
|
idSet.add(file.id);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqueFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFiles(
|
||||||
|
files: EnteFile[],
|
||||||
|
progressBarUpdater?: {
|
||||||
|
increaseSuccess: () => void;
|
||||||
|
increaseFailed: () => void;
|
||||||
|
isCancelled: () => boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
if (progressBarUpdater?.isCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await downloadFile(file, false);
|
||||||
|
progressBarUpdater?.increaseSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'download fail for file');
|
||||||
|
progressBarUpdater?.increaseFailed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFilesDesktop(
|
||||||
|
files: EnteFile[],
|
||||||
|
progressBarUpdater: {
|
||||||
|
increaseSuccess: () => void;
|
||||||
|
increaseFailed: () => void;
|
||||||
|
isCancelled: () => boolean;
|
||||||
|
},
|
||||||
|
downloadPath: string
|
||||||
|
) {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
if (progressBarUpdater?.isCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await downloadFileDesktop(fileReader, file, downloadPath);
|
||||||
|
progressBarUpdater?.increaseSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'download fail for file');
|
||||||
|
progressBarUpdater?.increaseFailed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFileDesktop(
|
||||||
|
fileReader: FileReader,
|
||||||
|
file: EnteFile,
|
||||||
|
downloadPath: string
|
||||||
|
) {
|
||||||
|
let fileStream: ReadableStream<Uint8Array>;
|
||||||
|
const fileURL = await DownloadManager.getCachedOriginalFile(file)[0];
|
||||||
|
if (!fileURL) {
|
||||||
|
fileStream = await DownloadManager.downloadFile(file);
|
||||||
|
} else {
|
||||||
|
fileStream = await fetch(fileURL).then((res) => res.body);
|
||||||
|
}
|
||||||
|
const updatedFileStream = await getUpdatedEXIFFileForDownload(
|
||||||
|
fileReader,
|
||||||
|
file,
|
||||||
|
fileStream
|
||||||
|
);
|
||||||
|
|
||||||
|
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
||||||
|
const fileBlob = await new Response(updatedFileStream).blob();
|
||||||
|
const livePhoto = await decodeLivePhoto(file, fileBlob);
|
||||||
|
const imageExportName = getUniqueFileExportName(
|
||||||
|
downloadPath,
|
||||||
|
livePhoto.imageNameTitle
|
||||||
|
);
|
||||||
|
const imageStream = generateStreamFromArrayBuffer(livePhoto.image);
|
||||||
|
await ElectronFSService.saveMediaFile(
|
||||||
|
getFileExportPath(downloadPath, imageExportName),
|
||||||
|
imageStream
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const videoExportName = getUniqueFileExportName(
|
||||||
|
downloadPath,
|
||||||
|
livePhoto.videoNameTitle
|
||||||
|
);
|
||||||
|
const videoStream = generateStreamFromArrayBuffer(livePhoto.video);
|
||||||
|
await ElectronFSService.saveMediaFile(
|
||||||
|
getFileExportPath(downloadPath, videoExportName),
|
||||||
|
videoStream
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
ElectronFSService.deleteFile(
|
||||||
|
getFileExportPath(downloadPath, imageExportName)
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileExportName = getUniqueFileExportName(
|
||||||
|
downloadPath,
|
||||||
|
file.metadata.title
|
||||||
|
);
|
||||||
|
await ElectronFSService.saveMediaFile(
|
||||||
|
getFileExportPath(downloadPath, fileExportName),
|
||||||
|
updatedFileStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isImageOrVideo = (fileType: FILE_TYPE) =>
|
||||||
|
[FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType);
|
||||||
|
|
||||||
|
export const getArchivedFiles = (files: EnteFile[]) => {
|
||||||
|
return files.filter(isArchivedFile).map((file) => file.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTypedObjectURL = async (blob: Blob, fileName: string) => {
|
||||||
|
const type = await getFileType(new File([blob], fileName));
|
||||||
|
return URL.createObjectURL(new Blob([blob], { type: type.mimeType }));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserOwnedFiles = (files: EnteFile[]) => {
|
||||||
|
const user: User = getData(LS_KEYS.USER);
|
||||||
|
if (!user?.id) {
|
||||||
|
throw Error('user missing');
|
||||||
|
}
|
||||||
|
return files.filter((file) => file.ownerID === user.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// doesn't work on firefox
|
||||||
|
export const copyFileToClipboard = async (fileUrl: string) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const canvasCTX = canvas.getContext('2d');
|
||||||
|
const image = new Image();
|
||||||
|
|
||||||
|
const blobPromise = new Promise<Blob>((resolve, reject) => {
|
||||||
|
let timeout: NodeJS.Timeout = null;
|
||||||
|
try {
|
||||||
|
image.setAttribute('src', fileUrl);
|
||||||
|
image.onload = () => {
|
||||||
|
canvas.width = image.width;
|
||||||
|
canvas.height = image.height;
|
||||||
|
canvasCTX.drawImage(image, 0, 0, image.width, image.height);
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
resolve(blob);
|
||||||
|
},
|
||||||
|
'image/png',
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
void logError(e, 'failed to copy to clipboard');
|
||||||
|
reject(e);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(
|
||||||
|
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
|
||||||
|
WAIT_TIME_IMAGE_CONVERSION
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ClipboardItem } = window;
|
||||||
|
|
||||||
|
await navigator.clipboard
|
||||||
|
.write([new ClipboardItem({ 'image/png': blobPromise })])
|
||||||
|
.catch((e) => logError(e, 'failed to copy to clipboard'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLatestVersionFiles(files: EnteFile[]) {
|
||||||
|
const latestVersionFiles = new Map<string, EnteFile>();
|
||||||
|
files.forEach((file) => {
|
||||||
|
const uid = `${file.collectionID}-${file.id}`;
|
||||||
|
if (
|
||||||
|
!latestVersionFiles.has(uid) ||
|
||||||
|
latestVersionFiles.get(uid).updationTime < file.updationTime
|
||||||
|
) {
|
||||||
|
latestVersionFiles.set(uid, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(latestVersionFiles.values()).filter(
|
||||||
|
(file) => !file.isDeleted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersonalFiles(files: EnteFile[], user: User) {
|
||||||
|
if (!user?.id) {
|
||||||
|
throw Error('user missing');
|
||||||
|
}
|
||||||
|
return files.filter((file) => file.ownerID === user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIDBasedSortedFiles(files: EnteFile[]) {
|
||||||
|
return files.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constructFileToCollectionMap(files: EnteFile[]) {
|
||||||
|
const fileToCollectionsMap = new Map<number, number[]>();
|
||||||
|
(files ?? []).forEach((file) => {
|
||||||
|
if (!fileToCollectionsMap.get(file.id)) {
|
||||||
|
fileToCollectionsMap.set(file.id, []);
|
||||||
|
}
|
||||||
|
fileToCollectionsMap.get(file.id).push(file.collectionID);
|
||||||
|
});
|
||||||
|
return fileToCollectionsMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shouldShowAvatar = (file: EnteFile, user: User) => {
|
||||||
|
if (!file || !user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// is Shared file
|
||||||
|
else if (file.ownerID !== user.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// is public collected file
|
||||||
|
else if (
|
||||||
|
file.ownerID === user.id &&
|
||||||
|
file.pubMagicMetadata?.data?.uploaderName
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleFileOps = async (
|
||||||
|
ops: FILE_OPS_TYPE,
|
||||||
|
files: EnteFile[],
|
||||||
|
setDeletedFileIds: (
|
||||||
|
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||||
|
) => void,
|
||||||
|
setHiddenFileIds: (
|
||||||
|
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||||
|
) => void,
|
||||||
|
setFixCreationTimeAttributes: (
|
||||||
|
fixCreationTimeAttributes:
|
||||||
|
| {
|
||||||
|
files: EnteFile[];
|
||||||
|
}
|
||||||
|
| ((prev: { files: EnteFile[] }) => { files: EnteFile[] })
|
||||||
|
) => void
|
||||||
|
) => {
|
||||||
|
switch (ops) {
|
||||||
|
case FILE_OPS_TYPE.TRASH:
|
||||||
|
await deleteFileHelper(files, false, setDeletedFileIds);
|
||||||
|
break;
|
||||||
|
case FILE_OPS_TYPE.DELETE_PERMANENTLY:
|
||||||
|
await deleteFileHelper(files, true, setDeletedFileIds);
|
||||||
|
break;
|
||||||
|
case FILE_OPS_TYPE.HIDE:
|
||||||
|
await hideFilesHelper(files, setHiddenFileIds);
|
||||||
|
break;
|
||||||
|
case FILE_OPS_TYPE.DOWNLOAD:
|
||||||
|
await downloadFiles(files);
|
||||||
|
break;
|
||||||
|
case FILE_OPS_TYPE.FIX_TIME:
|
||||||
|
fixTimeHelper(files, setFixCreationTimeAttributes);
|
||||||
|
break;
|
||||||
|
case FILE_OPS_TYPE.ARCHIVE:
|
||||||
|
await changeFilesVisibility(files, VISIBILITY_STATE.ARCHIVED);
|
||||||
|
break;
|
||||||
|
case FILE_OPS_TYPE.UNARCHIVE:
|
||||||
|
await changeFilesVisibility(files, VISIBILITY_STATE.VISIBLE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFileHelper = async (
|
||||||
|
selectedFiles: EnteFile[],
|
||||||
|
permanent: boolean,
|
||||||
|
setDeletedFileIds: (
|
||||||
|
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||||
|
) => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
setDeletedFileIds((deletedFileIds) => {
|
||||||
|
selectedFiles.forEach((file) => deletedFileIds.add(file.id));
|
||||||
|
return new Set(deletedFileIds);
|
||||||
|
});
|
||||||
|
if (permanent) {
|
||||||
|
await deleteFromTrash(selectedFiles.map((file) => file.id));
|
||||||
|
} else {
|
||||||
|
await trashFiles(selectedFiles);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setDeletedFileIds(new Set());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideFilesHelper = async (
|
||||||
|
selectedFiles: EnteFile[],
|
||||||
|
setHiddenFileIds: (
|
||||||
|
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
|
||||||
|
) => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
setHiddenFileIds((hiddenFileIds) => {
|
||||||
|
selectedFiles.forEach((file) => hiddenFileIds.add(file.id));
|
||||||
|
return new Set(hiddenFileIds);
|
||||||
|
});
|
||||||
|
await moveToHiddenCollection(selectedFiles);
|
||||||
|
} catch (e) {
|
||||||
|
setHiddenFileIds(new Set());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixTimeHelper = async (
|
||||||
|
selectedFiles: EnteFile[],
|
||||||
|
setFixCreationTimeAttributes: (fixCreationTimeAttributes: {
|
||||||
|
files: EnteFile[];
|
||||||
|
}) => void
|
||||||
|
) => {
|
||||||
|
setFixCreationTimeAttributes({ files: selectedFiles });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileObjectURLs = (originalBlob: Blob, convertedBlob: Blob) => {
|
||||||
|
const originalURL = URL.createObjectURL(originalBlob);
|
||||||
|
const convertedURL = convertedBlob
|
||||||
|
? convertedBlob === originalBlob
|
||||||
|
? originalURL
|
||||||
|
: URL.createObjectURL(convertedBlob)
|
||||||
|
: null;
|
||||||
|
return { originalURL, convertedURL };
|
||||||
|
};
|
42
apps/cast/src/utils/file/livePhoto.ts
Normal file
42
apps/cast/src/utils/file/livePhoto.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { FILE_TYPE } from 'constants/file';
|
||||||
|
import { getFileExtension } from 'utils/file';
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = [
|
||||||
|
'heic',
|
||||||
|
'heif',
|
||||||
|
'jpeg',
|
||||||
|
'jpg',
|
||||||
|
'png',
|
||||||
|
'gif',
|
||||||
|
'bmp',
|
||||||
|
'tiff',
|
||||||
|
'webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = [
|
||||||
|
'mov',
|
||||||
|
'mp4',
|
||||||
|
'm4v',
|
||||||
|
'avi',
|
||||||
|
'wmv',
|
||||||
|
'flv',
|
||||||
|
'mkv',
|
||||||
|
'webm',
|
||||||
|
'3gp',
|
||||||
|
'3g2',
|
||||||
|
'avi',
|
||||||
|
'ogv',
|
||||||
|
'mpg',
|
||||||
|
'mp',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getFileTypeFromExtensionForLivePhotoClustering(
|
||||||
|
filename: string
|
||||||
|
) {
|
||||||
|
const extension = getFileExtension(filename)?.toLowerCase();
|
||||||
|
if (IMAGE_EXTENSIONS.includes(extension)) {
|
||||||
|
return FILE_TYPE.IMAGE;
|
||||||
|
} else if (VIDEO_EXTENSIONS.includes(extension)) {
|
||||||
|
return FILE_TYPE.VIDEO;
|
||||||
|
}
|
||||||
|
}
|
12
apps/cast/src/utils/file/size.ts
Normal file
12
apps/cast/src/utils/file/size.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export function convertBytesToHumanReadable(
|
||||||
|
bytes: number,
|
||||||
|
precision = 2
|
||||||
|
): string {
|
||||||
|
if (bytes === 0 || isNaN(bytes)) {
|
||||||
|
return '0 MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(precision) + ' ' + sizes[i];
|
||||||
|
}
|
116
apps/cast/src/utils/logging/index.ts
Normal file
116
apps/cast/src/utils/logging/index.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import { ElectronFile } from 'types/upload';
|
||||||
|
import { convertBytesToHumanReadable } from 'utils/file/size';
|
||||||
|
import { formatDateTimeShort } from 'utils/time/format';
|
||||||
|
import { isDEVSentryENV } from 'constants/sentry';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
// import ElectronService from 'services/electron/common';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import {
|
||||||
|
getData,
|
||||||
|
LS_KEYS,
|
||||||
|
removeData,
|
||||||
|
setData,
|
||||||
|
} from 'utils/storage/localStorage';
|
||||||
|
|
||||||
|
export const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
export const MAX_LOG_LINES = 1000;
|
||||||
|
|
||||||
|
export interface Log {
|
||||||
|
timestamp: number;
|
||||||
|
logLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLogLine(
|
||||||
|
log: string | number | boolean,
|
||||||
|
...optionalParams: (string | number | boolean)[]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const completeLog = [log, ...optionalParams].join(' ');
|
||||||
|
if (isDEVSentryENV()) {
|
||||||
|
console.log(completeLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElectron()) {
|
||||||
|
// ElectronService.logToDisk(completeLog);
|
||||||
|
} else {
|
||||||
|
saveLogLine({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
logLine: completeLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'QuotaExceededError') {
|
||||||
|
deleteLogs();
|
||||||
|
addLogLine('logs cleared');
|
||||||
|
}
|
||||||
|
logError(e, 'failed to addLogLine', undefined, true);
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addLocalLog = (getLog: () => string) => {
|
||||||
|
if (isDEVSentryENV()) {
|
||||||
|
console.log(getLog());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDebugLogs() {
|
||||||
|
return combineLogLines(getLogs());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileNameSize(file: File | ElectronFile) {
|
||||||
|
return `${file.name}_${convertBytesToHumanReadable(file.size)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearLogsIfLocalStorageLimitExceeded = () => {
|
||||||
|
try {
|
||||||
|
const logs = getDebugLogs();
|
||||||
|
const logSize = getStringSize(logs);
|
||||||
|
if (logSize > MAX_LOG_SIZE) {
|
||||||
|
deleteLogs();
|
||||||
|
addLogLine('Logs cleared due to size limit exceeded');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
addLogLine(`app started`);
|
||||||
|
} catch (e) {
|
||||||
|
deleteLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addLogLine(`logs size: ${convertBytesToHumanReadable(logSize)}`);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'failed to clearLogsIfLocalStorageLimitExceeded');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function saveLogLine(log: Log) {
|
||||||
|
const logs = getLogs();
|
||||||
|
if (logs.length > MAX_LOG_LINES) {
|
||||||
|
logs.slice(logs.length - MAX_LOG_LINES);
|
||||||
|
}
|
||||||
|
logs.push(log);
|
||||||
|
setLogs(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogs(): Log[] {
|
||||||
|
return getData(LS_KEYS.LOGS)?.logs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLogs(logs: Log[]) {
|
||||||
|
setData(LS_KEYS.LOGS, { logs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLogs() {
|
||||||
|
removeData(LS_KEYS.LOGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringSize(str: string) {
|
||||||
|
return new Blob([str]).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLog(log: Log) {
|
||||||
|
return `[${formatDateTimeShort(log.timestamp)}] ${log.logLine}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineLogLines(logs: Log[]) {
|
||||||
|
return logs.map(formatLog).join('\n');
|
||||||
|
}
|
167
apps/cast/src/utils/logging/upload/index.ts
Normal file
167
apps/cast/src/utils/logging/upload/index.ts
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import { FILE_TYPE } from 'constants/file';
|
||||||
|
import { Collection } from 'types/collection';
|
||||||
|
import { B64EncryptionResult, LocalFileAttributes } from 'types/crypto';
|
||||||
|
import {
|
||||||
|
MetadataFileAttributes,
|
||||||
|
S3FileAttributes,
|
||||||
|
FilePublicMagicMetadata,
|
||||||
|
FilePublicMagicMetadataProps,
|
||||||
|
} from 'types/file';
|
||||||
|
import { EncryptedMagicMetadata } from 'types/magicMetadata';
|
||||||
|
|
||||||
|
export interface DataStream {
|
||||||
|
stream: ReadableStream<Uint8Array>;
|
||||||
|
chunkCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDataStream(object: any): object is DataStream {
|
||||||
|
return 'stream' in object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Logger = (message: string) => void;
|
||||||
|
|
||||||
|
export interface Metadata {
|
||||||
|
title: string;
|
||||||
|
creationTime: number;
|
||||||
|
modificationTime: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
fileType: FILE_TYPE;
|
||||||
|
hasStaticThumbnail?: boolean;
|
||||||
|
hash?: string;
|
||||||
|
imageHash?: string;
|
||||||
|
videoHash?: string;
|
||||||
|
localID?: number;
|
||||||
|
version?: number;
|
||||||
|
deviceFolder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedMetadataJSON {
|
||||||
|
creationTime: number;
|
||||||
|
modificationTime: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultipartUploadURLs {
|
||||||
|
objectKey: string;
|
||||||
|
partURLs: string[];
|
||||||
|
completeURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileTypeInfo {
|
||||||
|
fileType: FILE_TYPE;
|
||||||
|
exactType: string;
|
||||||
|
mimeType?: string;
|
||||||
|
imageType?: string;
|
||||||
|
videoType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ElectronFile is a custom interface that is used to represent
|
||||||
|
* any file on disk as a File-like object in the Electron desktop app.
|
||||||
|
*
|
||||||
|
* This was added to support the auto-resuming of failed uploads
|
||||||
|
* which needed absolute paths to the files which the
|
||||||
|
* normal File interface does not provide.
|
||||||
|
*/
|
||||||
|
export interface ElectronFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: number;
|
||||||
|
stream: () => Promise<ReadableStream<Uint8Array>>;
|
||||||
|
blob: () => Promise<Blob>;
|
||||||
|
arrayBuffer: () => Promise<Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadAsset {
|
||||||
|
isLivePhoto?: boolean;
|
||||||
|
file?: File | ElectronFile;
|
||||||
|
livePhotoAssets?: LivePhotoAssets;
|
||||||
|
isElectron?: boolean;
|
||||||
|
}
|
||||||
|
export interface LivePhotoAssets {
|
||||||
|
image: globalThis.File | ElectronFile;
|
||||||
|
video: globalThis.File | ElectronFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWithCollection extends UploadAsset {
|
||||||
|
localID: number;
|
||||||
|
collection?: Collection;
|
||||||
|
collectionID?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedMetadataJSONMap = Map<string, ParsedMetadataJSON>;
|
||||||
|
|
||||||
|
export interface UploadURL {
|
||||||
|
url: string;
|
||||||
|
objectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInMemory {
|
||||||
|
filedata: Uint8Array | DataStream;
|
||||||
|
thumbnail: Uint8Array;
|
||||||
|
hasStaticThumbnail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWithMetadata
|
||||||
|
extends Omit<FileInMemory, 'hasStaticThumbnail'> {
|
||||||
|
metadata: Metadata;
|
||||||
|
localID: number;
|
||||||
|
pubMagicMetadata: FilePublicMagicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedFile {
|
||||||
|
file: ProcessedFile;
|
||||||
|
fileKey: B64EncryptionResult;
|
||||||
|
}
|
||||||
|
export interface ProcessedFile {
|
||||||
|
file: LocalFileAttributes<Uint8Array | DataStream>;
|
||||||
|
thumbnail: LocalFileAttributes<Uint8Array>;
|
||||||
|
metadata: LocalFileAttributes<string>;
|
||||||
|
pubMagicMetadata: EncryptedMagicMetadata;
|
||||||
|
localID: number;
|
||||||
|
}
|
||||||
|
export interface BackupedFile {
|
||||||
|
file: S3FileAttributes;
|
||||||
|
thumbnail: S3FileAttributes;
|
||||||
|
metadata: MetadataFileAttributes;
|
||||||
|
pubMagicMetadata: EncryptedMagicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFile extends BackupedFile {
|
||||||
|
collectionID: number;
|
||||||
|
encryptedKey: string;
|
||||||
|
keyDecryptionNonce: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedExtractedMetadata {
|
||||||
|
location: Location;
|
||||||
|
creationTime: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is used to prompt the user the make upload strategy choice
|
||||||
|
export interface ImportSuggestion {
|
||||||
|
rootFolderName: string;
|
||||||
|
hasNestedFolders: boolean;
|
||||||
|
hasRootLevelFileWithFolder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicUploadProps {
|
||||||
|
token: string;
|
||||||
|
passwordToken: string;
|
||||||
|
accessedThroughSharedURL: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractMetadataResult {
|
||||||
|
metadata: Metadata;
|
||||||
|
publicMagicMetadata: FilePublicMagicMetadataProps;
|
||||||
|
}
|
43
apps/cast/src/utils/logging/upload/ui.ts
Normal file
43
apps/cast/src/utils/logging/upload/ui.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { UPLOAD_RESULT, UPLOAD_STAGES } from 'constants/upload';
|
||||||
|
|
||||||
|
export type FileID = number;
|
||||||
|
export type FileName = string;
|
||||||
|
|
||||||
|
export type PercentageUploaded = number;
|
||||||
|
export type UploadFileNames = Map<FileID, FileName>;
|
||||||
|
|
||||||
|
export interface UploadCounter {
|
||||||
|
finished: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InProgressUpload {
|
||||||
|
localFileID: FileID;
|
||||||
|
progress: PercentageUploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinishedUpload {
|
||||||
|
localFileID: FileID;
|
||||||
|
result: UPLOAD_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InProgressUploads = Map<FileID, PercentageUploaded>;
|
||||||
|
|
||||||
|
export type FinishedUploads = Map<FileID, UPLOAD_RESULT>;
|
||||||
|
|
||||||
|
export type SegregatedFinishedUploads = Map<UPLOAD_RESULT, FileID[]>;
|
||||||
|
|
||||||
|
export interface ProgressUpdater {
|
||||||
|
setPercentComplete: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
setUploadCounter: React.Dispatch<React.SetStateAction<UploadCounter>>;
|
||||||
|
setUploadStage: React.Dispatch<React.SetStateAction<UPLOAD_STAGES>>;
|
||||||
|
setInProgressUploads: React.Dispatch<
|
||||||
|
React.SetStateAction<InProgressUpload[]>
|
||||||
|
>;
|
||||||
|
setFinishedUploads: React.Dispatch<
|
||||||
|
React.SetStateAction<SegregatedFinishedUploads>
|
||||||
|
>;
|
||||||
|
setUploadFilenames: React.Dispatch<React.SetStateAction<UploadFileNames>>;
|
||||||
|
setHasLivePhotos: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setUploadProgressView: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
97
apps/cast/src/utils/magicMetadata/index.ts
Normal file
97
apps/cast/src/utils/magicMetadata/index.ts
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import { Collection } from 'types/collection';
|
||||||
|
import { EnteFile } from 'types/file';
|
||||||
|
import { MagicMetadataCore, VISIBILITY_STATE } from 'types/magicMetadata';
|
||||||
|
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||||
|
|
||||||
|
export function isArchivedFile(item: EnteFile): boolean {
|
||||||
|
if (!item || !item.magicMetadata || !item.magicMetadata.data) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArchivedCollection(item: Collection): boolean {
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.magicMetadata && item.magicMetadata.data) {
|
||||||
|
return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.sharedMagicMetadata && item.sharedMagicMetadata.data) {
|
||||||
|
return (
|
||||||
|
item.sharedMagicMetadata.data.visibility ===
|
||||||
|
VISIBILITY_STATE.ARCHIVED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPinnedCollection(item: Collection) {
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
!item.magicMetadata ||
|
||||||
|
!item.magicMetadata.data ||
|
||||||
|
typeof item.magicMetadata.data === 'string' ||
|
||||||
|
typeof item.magicMetadata.data.order === 'undefined'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return item.magicMetadata.data.order !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMagicMetadata<T>(
|
||||||
|
magicMetadataUpdates: T,
|
||||||
|
originalMagicMetadata?: MagicMetadataCore<T>,
|
||||||
|
decryptionKey?: string
|
||||||
|
): Promise<MagicMetadataCore<T>> {
|
||||||
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||||
|
|
||||||
|
if (!originalMagicMetadata) {
|
||||||
|
originalMagicMetadata = getNewMagicMetadata<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof originalMagicMetadata?.data === 'string') {
|
||||||
|
originalMagicMetadata.data = await cryptoWorker.decryptMetadata(
|
||||||
|
originalMagicMetadata.data,
|
||||||
|
originalMagicMetadata.header,
|
||||||
|
decryptionKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// copies the existing magic metadata properties of the files and updates the visibility value
|
||||||
|
// The expected behavior while updating magic metadata is to let the existing property as it is and update/add the property you want
|
||||||
|
const magicMetadataProps: T = {
|
||||||
|
...originalMagicMetadata.data,
|
||||||
|
...magicMetadataUpdates,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nonEmptyMagicMetadataProps =
|
||||||
|
getNonEmptyMagicMetadataProps(magicMetadataProps);
|
||||||
|
|
||||||
|
const magicMetadata = {
|
||||||
|
...originalMagicMetadata,
|
||||||
|
data: nonEmptyMagicMetadataProps,
|
||||||
|
count: Object.keys(nonEmptyMagicMetadataProps).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return magicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getNewMagicMetadata = <T>(): MagicMetadataCore<T> => {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
data: null,
|
||||||
|
header: null,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNonEmptyMagicMetadataProps = <T>(magicMetadataProps: T): T => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(magicMetadataProps).filter(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
([_, v]) => v !== null && v !== undefined
|
||||||
|
)
|
||||||
|
) as T;
|
||||||
|
};
|
75
apps/cast/src/utils/sentry/index.ts
Normal file
75
apps/cast/src/utils/sentry/index.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import { addLocalLog, addLogLine } from 'utils/logging';
|
||||||
|
import { getSentryUserID } from 'utils/user';
|
||||||
|
import InMemoryStore, { MS_KEYS } from 'services/InMemoryStore';
|
||||||
|
import { getHasOptedOutOfCrashReports } from 'utils/storage';
|
||||||
|
import { ApiError } from 'utils/error';
|
||||||
|
|
||||||
|
export const logError = async (
|
||||||
|
error: any,
|
||||||
|
msg: string,
|
||||||
|
info?: Record<string, unknown>,
|
||||||
|
skipAddLogLine = false
|
||||||
|
) => {
|
||||||
|
const err = errorWithContext(error, msg);
|
||||||
|
if (!skipAddLogLine) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
addLogLine(`error: ${error?.name} ${error?.message}
|
||||||
|
msg: ${msg} errorCode: ${JSON.stringify(error?.errCode)}
|
||||||
|
httpStatusCode: ${JSON.stringify(error?.httpStatusCode)} ${
|
||||||
|
info ? `info: ${JSON.stringify(info)}` : ''
|
||||||
|
}
|
||||||
|
${error?.stack}`);
|
||||||
|
} else {
|
||||||
|
addLogLine(
|
||||||
|
`error: ${error?.name} ${error?.message}
|
||||||
|
msg: ${msg} ${info ? `info: ${JSON.stringify(info)}` : ''}
|
||||||
|
${error?.stack}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!InMemoryStore.has(MS_KEYS.OPT_OUT_OF_CRASH_REPORTS)) {
|
||||||
|
const optedOutOfCrashReports = getHasOptedOutOfCrashReports();
|
||||||
|
InMemoryStore.set(
|
||||||
|
MS_KEYS.OPT_OUT_OF_CRASH_REPORTS,
|
||||||
|
optedOutOfCrashReports
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (InMemoryStore.get(MS_KEYS.OPT_OUT_OF_CRASH_REPORTS)) {
|
||||||
|
addLocalLog(() => `skipping sentry error: ${error?.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isErrorUnnecessaryForSentry(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.captureException(err, {
|
||||||
|
level: 'info',
|
||||||
|
user: { id: await getSentryUserID() },
|
||||||
|
contexts: {
|
||||||
|
...(info && {
|
||||||
|
info: info,
|
||||||
|
}),
|
||||||
|
rootCause: { message: error?.message, completeError: error },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// copy of errorWithContext to prevent importing error util
|
||||||
|
function errorWithContext(originalError: Error, context: string) {
|
||||||
|
const errorWithContext = new Error(context);
|
||||||
|
errorWithContext.stack =
|
||||||
|
errorWithContext.stack.split('\n').slice(2, 4).join('\n') +
|
||||||
|
'\n' +
|
||||||
|
originalError.stack;
|
||||||
|
return errorWithContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrorUnnecessaryForSentry(error: any) {
|
||||||
|
if (error?.message?.includes('Network Error')) {
|
||||||
|
return true;
|
||||||
|
} else if (error?.status === 401) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
40
apps/cast/src/utils/storage/index.ts
Normal file
40
apps/cast/src/utils/storage/index.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { Language } from 'constants/locale';
|
||||||
|
import { getData, LS_KEYS, setData } from './localStorage';
|
||||||
|
|
||||||
|
export const isFirstLogin = () =>
|
||||||
|
getData(LS_KEYS.IS_FIRST_LOGIN)?.status ?? false;
|
||||||
|
|
||||||
|
export function setIsFirstLogin(status) {
|
||||||
|
setData(LS_KEYS.IS_FIRST_LOGIN, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const justSignedUp = () =>
|
||||||
|
getData(LS_KEYS.JUST_SIGNED_UP)?.status ?? false;
|
||||||
|
|
||||||
|
export function setJustSignedUp(status) {
|
||||||
|
setData(LS_KEYS.JUST_SIGNED_UP, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLivePhotoInfoShownCount() {
|
||||||
|
return getData(LS_KEYS.LIVE_PHOTO_INFO_SHOWN_COUNT)?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLivePhotoInfoShownCount(count) {
|
||||||
|
setData(LS_KEYS.LIVE_PHOTO_INFO_SHOWN_COUNT, { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserLocale(): Language {
|
||||||
|
return getData(LS_KEYS.LOCALE)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalMapEnabled(): boolean {
|
||||||
|
return getData(LS_KEYS.MAP_ENABLED)?.value ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocalMapEnabled(value: boolean) {
|
||||||
|
setData(LS_KEYS.MAP_ENABLED, { value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHasOptedOutOfCrashReports(): boolean {
|
||||||
|
return getData(LS_KEYS.OPT_OUT_OF_CRASH_REPORTS)?.value ?? false;
|
||||||
|
}
|
12
apps/cast/src/utils/storage/localForage.ts
Normal file
12
apps/cast/src/utils/storage/localForage.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { runningInBrowser } from 'utils/common';
|
||||||
|
|
||||||
|
import localForage from 'localforage';
|
||||||
|
|
||||||
|
if (runningInBrowser()) {
|
||||||
|
localForage.config({
|
||||||
|
name: 'ente-files',
|
||||||
|
version: 1.0,
|
||||||
|
storeName: 'files',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export default localForage;
|
68
apps/cast/src/utils/storage/localStorage.ts
Normal file
68
apps/cast/src/utils/storage/localStorage.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
|
||||||
|
export enum LS_KEYS {
|
||||||
|
USER = 'user',
|
||||||
|
SESSION = 'session',
|
||||||
|
KEY_ATTRIBUTES = 'keyAttributes',
|
||||||
|
ORIGINAL_KEY_ATTRIBUTES = 'originalKeyAttributes',
|
||||||
|
SUBSCRIPTION = 'subscription',
|
||||||
|
FAMILY_DATA = 'familyData',
|
||||||
|
PLANS = 'plans',
|
||||||
|
IS_FIRST_LOGIN = 'isFirstLogin',
|
||||||
|
JUST_SIGNED_UP = 'justSignedUp',
|
||||||
|
SHOW_BACK_BUTTON = 'showBackButton',
|
||||||
|
EXPORT = 'export',
|
||||||
|
AnonymizedUserID = 'anonymizedUserID',
|
||||||
|
THUMBNAIL_FIX_STATE = 'thumbnailFixState',
|
||||||
|
LIVE_PHOTO_INFO_SHOWN_COUNT = 'livePhotoInfoShownCount',
|
||||||
|
LOGS = 'logs',
|
||||||
|
USER_DETAILS = 'userDetails',
|
||||||
|
COLLECTION_SORT_BY = 'collectionSortBy',
|
||||||
|
THEME = 'theme',
|
||||||
|
WAIT_TIME = 'waitTime',
|
||||||
|
API_ENDPOINT = 'apiEndpoint',
|
||||||
|
LOCALE = 'locale',
|
||||||
|
MAP_ENABLED = 'mapEnabled',
|
||||||
|
SRP_SETUP_ATTRIBUTES = 'srpSetupAttributes',
|
||||||
|
SRP_ATTRIBUTES = 'srpAttributes',
|
||||||
|
OPT_OUT_OF_CRASH_REPORTS = 'optOutOfCrashReports',
|
||||||
|
CF_PROXY_DISABLED = 'cfProxyDisabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setData = (key: LS_KEYS, value: object) => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeData = (key: LS_KEYS) => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getData = (key: LS_KEYS) => {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof localStorage === 'undefined' ||
|
||||||
|
typeof key === 'undefined' ||
|
||||||
|
typeof localStorage.getItem(key) === 'undefined' ||
|
||||||
|
localStorage.getItem(key) === 'undefined'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = localStorage.getItem(key);
|
||||||
|
return data && JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e, 'Failed to Parse JSON for key ' + key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearData = () => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
localStorage.clear();
|
||||||
|
};
|
32
apps/cast/src/utils/storage/sessionStorage.ts
Normal file
32
apps/cast/src/utils/storage/sessionStorage.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
export enum SESSION_KEYS {
|
||||||
|
ENCRYPTION_KEY = 'encryptionKey',
|
||||||
|
KEY_ENCRYPTION_KEY = 'keyEncryptionKey',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setKey = (key: SESSION_KEYS, value: object) => {
|
||||||
|
if (typeof sessionStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getKey = (key: SESSION_KEYS) => {
|
||||||
|
if (typeof sessionStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(sessionStorage.getItem(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeKey = (key: SESSION_KEYS) => {
|
||||||
|
if (typeof sessionStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearKeys = () => {
|
||||||
|
if (typeof sessionStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sessionStorage.clear();
|
||||||
|
};
|
78
apps/cast/src/utils/time/format.ts
Normal file
78
apps/cast/src/utils/time/format.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import i18n, { t } from 'i18next';
|
||||||
|
|
||||||
|
const dateTimeFullFormatter1 = new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateTimeFullFormatter2 = new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
const dateTimeShortFormatter = new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDateFull(date: number | Date) {
|
||||||
|
return [dateTimeFullFormatter1, dateTimeFullFormatter2]
|
||||||
|
.map((f) => f.format(date))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: number | Date) {
|
||||||
|
const withinYear =
|
||||||
|
new Date().getFullYear() === new Date(date).getFullYear();
|
||||||
|
const dateTimeFormat2 = !withinYear ? dateTimeFullFormatter2 : null;
|
||||||
|
return [dateTimeFullFormatter1, dateTimeFormat2]
|
||||||
|
.filter((f) => !!f)
|
||||||
|
.map((f) => f.format(date))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeShort(date: number | Date) {
|
||||||
|
return dateTimeShortFormatter.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(date: number | Date) {
|
||||||
|
return timeFormatter.format(date).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeFull(dateTime: number | Date): string {
|
||||||
|
return [formatDateFull(dateTime), t('at'), formatTime(dateTime)].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(dateTime: number | Date): string {
|
||||||
|
return [formatDate(dateTime), t('at'), formatTime(dateTime)].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateRelative(date: number) {
|
||||||
|
const units = {
|
||||||
|
year: 24 * 60 * 60 * 1000 * 365,
|
||||||
|
month: (24 * 60 * 60 * 1000 * 365) / 12,
|
||||||
|
day: 24 * 60 * 60 * 1000,
|
||||||
|
hour: 60 * 60 * 1000,
|
||||||
|
minute: 60 * 1000,
|
||||||
|
second: 1000,
|
||||||
|
};
|
||||||
|
const relativeDateFormat = new Intl.RelativeTimeFormat(i18n.language, {
|
||||||
|
localeMatcher: 'best fit',
|
||||||
|
numeric: 'always',
|
||||||
|
style: 'long',
|
||||||
|
});
|
||||||
|
const elapsed = date - Date.now(); // "Math.abs" accounts for both "past" & "future" scenarios
|
||||||
|
|
||||||
|
for (const u in units)
|
||||||
|
if (Math.abs(elapsed) > units[u] || u === 'second')
|
||||||
|
return relativeDateFormat.format(
|
||||||
|
Math.round(elapsed / units[u]),
|
||||||
|
u as Intl.RelativeTimeFormatUnit
|
||||||
|
);
|
||||||
|
}
|
177
apps/cast/src/utils/time/index.ts
Normal file
177
apps/cast/src/utils/time/index.ts
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
export interface TimeDelta {
|
||||||
|
hours?: number;
|
||||||
|
days?: number;
|
||||||
|
months?: number;
|
||||||
|
years?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DateComponent<T = number> {
|
||||||
|
year: T;
|
||||||
|
month: T;
|
||||||
|
day: T;
|
||||||
|
hour: T;
|
||||||
|
minute: T;
|
||||||
|
second: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnixTimeInMicroSecondsWithDelta(delta: TimeDelta): number {
|
||||||
|
let currentDate = new Date();
|
||||||
|
if (delta?.hours) {
|
||||||
|
currentDate = _addHours(currentDate, delta.hours);
|
||||||
|
}
|
||||||
|
if (delta?.days) {
|
||||||
|
currentDate = _addDays(currentDate, delta.days);
|
||||||
|
}
|
||||||
|
if (delta?.months) {
|
||||||
|
currentDate = _addMonth(currentDate, delta.months);
|
||||||
|
}
|
||||||
|
if (delta?.years) {
|
||||||
|
currentDate = _addYears(currentDate, delta.years);
|
||||||
|
}
|
||||||
|
return currentDate.getTime() * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addDays(date: Date, days: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(date.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addHours(date: Date, hours: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setHours(date.getHours() + hours);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addMonth(date: Date, months: number) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setMonth(date.getMonth() + months);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addYears(date: Date, years: number) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setFullYear(date.getFullYear() + years);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
generates data component for date in format YYYYMMDD-HHMMSS
|
||||||
|
*/
|
||||||
|
export function parseDateFromFusedDateString(dateTime: string) {
|
||||||
|
const dateComponent: DateComponent<number> = convertDateComponentToNumber({
|
||||||
|
year: dateTime.slice(0, 4),
|
||||||
|
month: dateTime.slice(4, 6),
|
||||||
|
day: dateTime.slice(6, 8),
|
||||||
|
hour: dateTime.slice(9, 11),
|
||||||
|
minute: dateTime.slice(11, 13),
|
||||||
|
second: dateTime.slice(13, 15),
|
||||||
|
});
|
||||||
|
return validateAndGetDateFromComponents(dateComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sample date format = 2018-08-19 12:34:45
|
||||||
|
the date has six symbol separated number values
|
||||||
|
which we would extract and use to form the date
|
||||||
|
*/
|
||||||
|
export function tryToParseDateTime(dateTime: string): Date {
|
||||||
|
const dateComponent = getDateComponentsFromSymbolJoinedString(dateTime);
|
||||||
|
if (dateComponent.year?.length === 8 && dateComponent.month?.length === 6) {
|
||||||
|
// the filename has size 8 consecutive and then 6 consecutive digits
|
||||||
|
// high possibility that the it is a date in format YYYYMMDD-HHMMSS
|
||||||
|
const possibleDateTime = dateComponent.year + '-' + dateComponent.month;
|
||||||
|
return parseDateFromFusedDateString(possibleDateTime);
|
||||||
|
}
|
||||||
|
return validateAndGetDateFromComponents(
|
||||||
|
convertDateComponentToNumber(dateComponent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateComponentsFromSymbolJoinedString(
|
||||||
|
dateTime: string
|
||||||
|
): DateComponent<string> {
|
||||||
|
const [year, month, day, hour, minute, second] =
|
||||||
|
dateTime.match(/\d+/g) ?? [];
|
||||||
|
|
||||||
|
return { year, month, day, hour, minute, second };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndGetDateFromComponents(
|
||||||
|
dateComponent: DateComponent<number>
|
||||||
|
) {
|
||||||
|
let date = getDateFromComponents(dateComponent);
|
||||||
|
if (hasTimeValues(dateComponent) && !isTimePartValid(date, dateComponent)) {
|
||||||
|
// if the date has time values but they are not valid
|
||||||
|
// then we remove the time values and try to validate the date
|
||||||
|
date = getDateFromComponents(removeTimeValues(dateComponent));
|
||||||
|
}
|
||||||
|
if (!isDatePartValid(date, dateComponent)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimePartValid(date: Date, dateComponent: DateComponent<number>) {
|
||||||
|
return (
|
||||||
|
date.getHours() === dateComponent.hour &&
|
||||||
|
date.getMinutes() === dateComponent.minute &&
|
||||||
|
date.getSeconds() === dateComponent.second
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDatePartValid(date: Date, dateComponent: DateComponent<number>) {
|
||||||
|
return (
|
||||||
|
date.getFullYear() === dateComponent.year &&
|
||||||
|
date.getMonth() === dateComponent.month &&
|
||||||
|
date.getDate() === dateComponent.day
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDateComponentToNumber(
|
||||||
|
dateComponent: DateComponent<string>
|
||||||
|
): DateComponent<number> {
|
||||||
|
return {
|
||||||
|
year: Number(dateComponent.year),
|
||||||
|
// https://stackoverflow.com/questions/2552483/why-does-the-month-argument-range-from-0-to-11-in-javascripts-date-constructor
|
||||||
|
month: Number(dateComponent.month) - 1,
|
||||||
|
day: Number(dateComponent.day),
|
||||||
|
hour: Number(dateComponent.hour),
|
||||||
|
minute: Number(dateComponent.minute),
|
||||||
|
second: Number(dateComponent.second),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateFromComponents(dateComponent: DateComponent<number>) {
|
||||||
|
const { year, month, day, hour, minute, second } = dateComponent;
|
||||||
|
if (hasTimeValues(dateComponent)) {
|
||||||
|
return new Date(year, month, day, hour, minute, second);
|
||||||
|
} else {
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTimeValues(dateComponent: DateComponent<number>) {
|
||||||
|
const { hour, minute, second } = dateComponent;
|
||||||
|
return !isNaN(hour) && !isNaN(minute) && !isNaN(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTimeValues(
|
||||||
|
dateComponent: DateComponent<number>
|
||||||
|
): DateComponent<number> {
|
||||||
|
return { ...dateComponent, hour: 0, minute: 0, second: 0 };
|
||||||
|
}
|
45
apps/cast/src/utils/user/family.ts
Normal file
45
apps/cast/src/utils/user/family.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { FamilyData, FamilyMember, User } from 'types/user';
|
||||||
|
import { logError } from 'utils/sentry';
|
||||||
|
import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
||||||
|
|
||||||
|
export function getLocalFamilyData(): FamilyData {
|
||||||
|
return getData(LS_KEYS.FAMILY_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPartOfFamily return true if the current user is part of some family plan
|
||||||
|
export function isPartOfFamily(familyData: FamilyData): boolean {
|
||||||
|
return Boolean(
|
||||||
|
familyData && familyData.members && familyData.members.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasNonAdminFamilyMembers return true if the admin user has members in his family
|
||||||
|
export function hasNonAdminFamilyMembers(familyData: FamilyData): boolean {
|
||||||
|
return Boolean(isPartOfFamily(familyData) && familyData.members.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFamilyAdmin(familyData: FamilyData): boolean {
|
||||||
|
const familyAdmin: FamilyMember = getFamilyPlanAdmin(familyData);
|
||||||
|
const user: User = getData(LS_KEYS.USER);
|
||||||
|
return familyAdmin.email === user.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFamilyPlanAdmin(familyData: FamilyData): FamilyMember {
|
||||||
|
if (isPartOfFamily(familyData)) {
|
||||||
|
return familyData.members.find((x) => x.isAdmin);
|
||||||
|
} else {
|
||||||
|
logError(
|
||||||
|
Error(
|
||||||
|
'verify user is part of family plan before calling this method'
|
||||||
|
),
|
||||||
|
'invalid getFamilyPlanAdmin call'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTotalFamilyUsage(familyData: FamilyData): number {
|
||||||
|
return familyData.members.reduce(
|
||||||
|
(sum, currentMember) => sum + currentMember.usage,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
52
apps/cast/src/utils/user/index.ts
Normal file
52
apps/cast/src/utils/user/index.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
import { UserDetails } from 'types/user';
|
||||||
|
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
|
||||||
|
// import ElectronService from 'services/electron/common';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
|
export function makeID(length) {
|
||||||
|
let result = '';
|
||||||
|
const characters =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const charactersLength = characters.length;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += characters.charAt(
|
||||||
|
Math.floor(Math.random() * charactersLength)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSentryUserID() {
|
||||||
|
if (isElectron()) {
|
||||||
|
// return await ElectronService.getSentryUserID();
|
||||||
|
} else {
|
||||||
|
let anonymizeUserID = getData(LS_KEYS.AnonymizedUserID)?.id;
|
||||||
|
if (!anonymizeUserID) {
|
||||||
|
anonymizeUserID = makeID(6);
|
||||||
|
setData(LS_KEYS.AnonymizedUserID, { id: anonymizeUserID });
|
||||||
|
}
|
||||||
|
return anonymizeUserID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalUserDetails(): UserDetails {
|
||||||
|
return getData(LS_KEYS.USER_DETAILS)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isInternalUser = () => {
|
||||||
|
const userEmail = getData(LS_KEYS.USER)?.email;
|
||||||
|
if (!userEmail) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
userEmail.endsWith('@ente.io') || userEmail === 'kr.anand619@gmail.com'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertBufferToBase64 = (buffer: Buffer) => {
|
||||||
|
return buffer.toString('base64');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertBase64ToBuffer = (base64: string) => {
|
||||||
|
return Buffer.from(base64, 'base64');
|
||||||
|
};
|
215
apps/cast/src/worker/crypto.worker.ts
Normal file
215
apps/cast/src/worker/crypto.worker.ts
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
import * as Comlink from 'comlink';
|
||||||
|
import { StateAddress } from 'libsodium-wrappers';
|
||||||
|
import * as libsodium from 'utils/crypto/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);
|
18
apps/cast/tsconfig.json
Normal file
18
apps/cast/tsconfig.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"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"],
|
||||||
|
"exclude": ["node_modules", "out", ".next", "thirdparty"]
|
||||||
|
}
|
Loading…
Reference in a new issue