Merge branch 'main' into discovery-3

This commit is contained in:
ashilkn
2024-06-12 16:41:54 +05:30
18 changed files with 313 additions and 173 deletions

View File

@@ -267,31 +267,41 @@ class UserService {
}
Future<void> onPassKeyVerified(BuildContext context, Map response) async {
final userPassword = Configuration.instance.getVolatilePassword();
if (userPassword == null) throw Exception("volatile password is null");
final ProgressDialog dialog =
createProgressDialog(context, context.l10n.pleaseWait);
await dialog.show();
try {
final userPassword = _config.getVolatilePassword();
if (userPassword == null) throw Exception("volatile password is null");
await _saveConfiguration(response);
await _saveConfiguration(response);
Widget page;
if (Configuration.instance.getEncryptedToken() != null) {
await Configuration.instance.decryptSecretsAndGetKeyEncKey(
userPassword,
Configuration.instance.getKeyAttributes()!,
Widget page;
if (_config.getEncryptedToken() != null) {
await _config.decryptSecretsAndGetKeyEncKey(
userPassword,
_config.getKeyAttributes()!,
);
page = const HomePage();
} else {
throw Exception("unexpected response during passkey verification");
}
await dialog.hide();
// ignore: unawaited_futures
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return page;
},
),
(route) => route.isFirst,
);
page = const HomePage();
} else {
throw Exception("unexpected response during passkey verification");
} catch (e) {
_logger.severe(e);
await dialog.hide();
rethrow;
}
// ignore: unawaited_futures
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return page;
},
),
(route) => route.isFirst,
);
}
Future<void> verifyEmail(

View File

@@ -8,6 +8,7 @@ import 'package:ente_auth/services/user_service.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/models/button_type.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/toast_util.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -61,6 +62,11 @@ class _PasskeyPageState extends State<PasskeyPage> {
}
try {
if (mounted && link.toLowerCase().startsWith("enteauth://passkey")) {
if (Configuration.instance.isLoggedIn()) {
_logger.info('ignored deeplink: already configured');
showToast(context, 'Account is already configured.');
return;
}
final String? uri = Uri.parse(link).queryParameters['response'];
String base64String = uri!.toString();
while (base64String.length % 4 != 0) {

View File

@@ -38,6 +38,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>otpauth</string>
<string>enteauth</string>
</array>
</dict>
</array>

View File

@@ -61,6 +61,103 @@ export const allowWindowClose = (): void => {
shouldAllowWindowClose = true;
};
/**
* The app's entry point.
*
* We call this at the end of this file.
*/
const main = () => {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
}
let mainWindow: BrowserWindow | undefined;
initLogging();
logStartupBanner();
registerForEnteLinks();
// The order of the next two calls is important
setupRendererServer();
registerPrivilegedSchemes();
migrateLegacyWatchStoreIfNeeded();
/**
* Handle an open URL request, but ensuring that we have a mainWindow.
*/
const handleOpenURLEnsuringWindow = (url: string) => {
log.info(`Attempting to handle request to open URL: ${url}`);
if (mainWindow) handleEnteLinks(mainWindow, url);
else setTimeout(() => handleOpenURLEnsuringWindow(url), 1000);
};
app.on("second-instance", (_, argv: string[]) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
mainWindow.show();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// On Windows and Linux, this is how we get deeplinks.
// See: registerForEnteLinks
const url = argv.pop();
if (url) handleOpenURLEnsuringWindow(url);
});
// Emitted once, when Electron has finished initializing.
//
// Note that some Electron APIs can only be used after this event occurs.
void app.whenReady().then(() => {
void (async () => {
// Create window and prepare for the renderer.
mainWindow = createMainWindow();
// Setup IPC and streams.
const watcher = createWatcher(mainWindow);
attachIPCHandlers();
attachFSWatchIPCHandlers(watcher);
attachLogoutIPCHandler(watcher);
registerStreamProtocol();
// Configure the renderer's environment.
const webContents = mainWindow.webContents;
setDownloadPath(webContents);
allowExternalLinks(webContents);
allowAllCORSOrigins(webContents);
// Start loading the renderer.
void mainWindow.loadURL(rendererURL);
// Continue on with the rest of the startup sequence.
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
setupTrayItem(mainWindow);
setupAutoUpdater(mainWindow);
try {
await deleteLegacyDiskCacheDirIfExists();
await deleteLegacyKeysStoreIfExists();
} catch (e) {
// Log but otherwise ignore errors during non-critical startup
// actions.
log.error("Ignoring startup error", e);
}
})();
});
// This is a macOS only event. Show our window when the user activates the
// app, e.g. by clicking on its dock icon.
app.on("activate", () => mainWindow?.show());
app.on("before-quit", () => {
if (mainWindow) saveWindowBounds(mainWindow);
allowWindowClose();
});
// On macOS, this is how we get deeplinks. See: registerForEnteLinks
app.on("open-url", (_, url) => handleOpenURLEnsuringWindow(url));
};
/**
* Log a standard startup banner.
*
@@ -145,6 +242,8 @@ const registerPrivilegedSchemes = () => {
* Implementation notes:
* - https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app
* - This works only when the app is packaged.
* - On Windows and Linux, we get the deeplink in the "second-instance" event.
* - On macOS, we get the deeplink in the "open-url" event.
*/
const registerForEnteLinks = () => app.setAsDefaultProtocolClient("ente");
@@ -464,87 +563,5 @@ const deleteLegacyKeysStoreIfExists = async () => {
}
};
const main = () => {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
}
let mainWindow: BrowserWindow | undefined;
initLogging();
logStartupBanner();
registerForEnteLinks();
// The order of the next two calls is important
setupRendererServer();
registerPrivilegedSchemes();
migrateLegacyWatchStoreIfNeeded();
app.on("second-instance", () => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
mainWindow.show();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
// Emitted once, when Electron has finished initializing.
//
// Note that some Electron APIs can only be used after this event occurs.
void app.whenReady().then(() => {
void (async () => {
// Create window and prepare for the renderer.
mainWindow = createMainWindow();
// Setup IPC and streams.
const watcher = createWatcher(mainWindow);
attachIPCHandlers();
attachFSWatchIPCHandlers(watcher);
attachLogoutIPCHandler(watcher);
registerStreamProtocol();
// Configure the renderer's environment.
const webContents = mainWindow.webContents;
setDownloadPath(webContents);
allowExternalLinks(webContents);
allowAllCORSOrigins(webContents);
// Start loading the renderer.
void mainWindow.loadURL(rendererURL);
// Continue on with the rest of the startup sequence.
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
setupTrayItem(mainWindow);
setupAutoUpdater(mainWindow);
try {
await deleteLegacyDiskCacheDirIfExists();
await deleteLegacyKeysStoreIfExists();
} catch (e) {
// Log but otherwise ignore errors during non-critical startup
// actions.
log.error("Ignoring startup error", e);
}
})();
});
// This is a macOS only event. Show our window when the user activates the
// app, e.g. by clicking on its dock icon.
app.on("activate", () => mainWindow?.show());
app.on("before-quit", () => {
if (mainWindow) saveWindowBounds(mainWindow);
allowWindowClose();
});
const handleOpenURLWithWindow = (url: string) => {
log.info(`Attempting to handle request to open URL: ${url}`);
if (mainWindow) handleEnteLinks(mainWindow, url);
else setTimeout(() => handleOpenURLWithWindow(url), 1000);
};
app.on("open-url", (_, url) => handleOpenURLWithWindow(url));
};
// Go for it.
main();

View File

@@ -44,7 +44,8 @@ yarn dev
For an editor, VSCode is a good choice. Also install the Prettier extension for
VSCode, and set VSCode to format on save. This way the editor will automatically
format and wrap the text using the project's standard, so you can just focus on
the content.
the content. You can also format without VSCode by using the `yarn pretty`
command.
## Have fun!

View File

@@ -44,6 +44,10 @@ export const sidebar = [
link: "/photos/features/location-tags",
},
{ text: "Map", link: "/photos/features/map" },
{
text: "Passkeys",
link: "/photos/features/passkeys",
},
{
text: "Public link",
link: "/photos/features/public-link",

View File

@@ -97,3 +97,6 @@ your own instead of contacting support to ask them to delete your account.
Note that both Ente photos and Ente auth data will be deleted when you delete
your account (irrespective of which app you delete it from) since both photos
and auth use the same underlying account.
To know details of how your data is deleted, including when you delete your
account, please see https://ente.io/blog/how-ente-deletes-data/.

View File

@@ -0,0 +1,62 @@
---
title: Passkeys
description: Using passkeys as a second factor for your Ente account
---
# Passkeys
> [!CAUTION]
>
> This is preview documentation for an upcoming feature. This feature has not
> yet been released yet, so the steps below will not work currently.
Passkeys are a new authentication mechanism that uses strong cryptography built
into devices, like Windows Hello or Apple's Touch ID. **You can use passkeys as
a second factor to secure your Ente account.**
> [!TIP]
>
> Passkeys are the colloquial term for a WebAuthn (Web Authentication)
> credentials. To know more technical details about how our passkey verification
> works, you can see this
> [technical note in our source code](https://github.com/ente-io/ente/blob/main/web/docs/webauthn-passkeys.md).
## Passkeys and TOTP
Ente already supports TOTP codes (in fact, we built an
[entire app](https://ente.io/auth/) to store them...). Passkeys serve as an
alternative 2FA (second factor) mechanism.
If you add a passkey to your Ente account, it will be used instead of any
existing 2FA codes that you have configured (if any).
## Enabling and disabling passkeys
Passkeys get enabled if you add one (or more) passkeys to your account.
Conversely, passkeys get disabled if you remove all your existing passkeys.
To add and remove passkeys, use the _Passkey_ option in the settings menu. This
will open up _accounts.ente.io_, where you can manage your passkeys.
## Login with passkeys
If passkeys are enabled, then _accounts.ente.io_ will automatically open when
you log into your Ente account on a new device. Here you can follow the
instructions given by the browser to verify your passkey.
> These instructions different for each browser and device, but generally they
> will ask you to use the same mechanism that you used when you created the
> passkey to verify it (scanning a QR code, using your fingerprint, pressing the
> key on your Yubikey or other security key hardware etc).
## Recovery
If you are unable to login with your passkey (e.g. if you have misplaced the
hardware key that you used to store your passkey), then you can **recover your
account by using your Ente recovery key**.
During login, press cancel on the browser dialog to verify your passkey, and
then select the "Recover two-factor" option in the error message that gets
shown. This will take you to a place where you can enter your Ente recovery key
and login into your account. Now you can go to the _Passkey_ page to delete the
lost passkey and/or add a new one.

View File

@@ -9,6 +9,9 @@ The latest version of the Ente Photos desktop app can be downloaded from
[ente.io/download](https://ente.io/download). If you're having trouble, please
see if any of the following cases apply.
- [Windows](#windows)
- [Linux](#linux)
## Windows
If the app stops with an "A JavaScript error occurred in the main process - The
@@ -22,7 +25,26 @@ This is what the error looks like:
You can install the Microsoft VC++ redistributable runtime from here:<br/>
https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170#latest-microsoft-visual-c-redistributable-version
## AppImages on ARM64 Linux
## Linux
### AppImage desktop integration
AppImages are not fully standalone, and they require additional steps to enable
full "desktop integration":
- Showing the app icon,
- Surfacing the app in the list of installed apps,
- Handling redirection after passkey verification.
All the ways of enabling AppImage desktop integration are mentioned in
[AppImage documentation](https://docs.appimage.org/user-guide/run-appimages.html#integrating-appimages-into-the-desktop).
For example, you can download the
[appimaged](https://github.com/probonopd/go-appimage/releases) AppImage, run it,
and then download the Ente Photos AppImage into your `~/Downloads` folder.
_appimaged_ will then pick it up automatically.
### AppImages on ARM64
If you're on an ARM64 machine running Linux, and the AppImages doesn't do
anything when you run it, you will need to run the following command on your
@@ -42,7 +64,7 @@ details, see the following upstream issues:
- libz.so: cannot open shared object file with Ubuntu arm64 -
[electron-userland/electron-builder/issues/7835](https://github.com/electron-userland/electron-builder/issues/7835)
## AppImage says it requires FUSE
### AppImage says it requires FUSE
See
[docs.appimage.org](https://docs.appimage.org/user-guide/troubleshooting/fuse.html#the-appimage-tells-me-it-needs-fuse-to-run).
@@ -53,7 +75,7 @@ tl;dr; for example, on Ubuntu,
sudo apt install libfuse2
```
## Linux SUID error
### Linux SUID error
On some Linux distributions, if you run the AppImage from the CLI, it might fail
with the following error:

View File

@@ -1,19 +1,4 @@
module.exports = {
extends: ["@/build-config/eslintrc-next"],
ignorePatterns: ["next.config.js", "next-env.d.ts"],
/* TODO: Temporary overrides */
rules: {
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"react-refresh/only-export-components": "off",
},
};

View File

@@ -0,0 +1,15 @@
import type { BaseAppContextT } from "@/next/types/app";
import { ensure } from "@/utils/ensure";
import { createContext, useContext } from "react";
/** The accounts app has no extra properties on top of the base context. */
type AppContextT = BaseAppContextT;
/** The React {@link Context} available to all pages. */
export const AppContext = createContext<AppContextT | undefined>(undefined);
/**
* Utility hook to get the {@link AppContextT}, throwing an exception if it is
* not defined.
*/
export const useAppContext = (): AppContextT => ensure(useContext(AppContext));

View File

@@ -1,8 +1,7 @@
import { CustomHead } from "@/next/components/Head";
import { setupI18n } from "@/next/i18n";
import { logUnhandledErrorsAndRejections } from "@/next/log-web";
import { appTitle, type AppName, type BaseAppContextT } from "@/next/types/app";
import { ensure } from "@/utils/ensure";
import { appTitle, type AppName } from "@/next/types/app";
import { PAGES } from "@ente/accounts/constants/pages";
import { accountLogout } from "@ente/accounts/services/logout";
import { Overlay } from "@ente/shared/components/Container";
@@ -16,27 +15,20 @@ import { getTheme } from "@ente/shared/themes";
import { THEME_COLOR } from "@ente/shared/themes/constants";
import { CssBaseline, useMediaQuery } from "@mui/material";
import { ThemeProvider } from "@mui/material/styles";
import { AppContext } from "components/context";
import { t } from "i18next";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { createContext, useContext, useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import "styles/global.css";
/** The accounts app has no extra properties on top of the base context. */
type AppContextT = BaseAppContextT;
/** The React {@link Context} available to all pages. */
export const AppContext = createContext<AppContextT | undefined>(undefined);
/** Utility hook to reduce amount of boilerplate in account related pages. */
export const useAppContext = () => ensure(useContext(AppContext));
export default function App({ Component, pageProps }: AppProps) {
const App: React.FC<AppProps> = ({ Component, pageProps }) => {
const appName: AppName = "accounts";
const [isI18nReady, setIsI18nReady] = useState<boolean>(false);
const [showNavbar, setShowNavBar] = useState(false);
const [showNavbar, setShowNavbar] = useState(false);
const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState<
DialogBoxAttributesV2 | undefined
@@ -48,8 +40,6 @@ export default function App({ Component, pageProps }: AppProps) {
setDialogBoxV2View(true);
}, [dialogBoxAttributeV2]);
const showNavBar = (show: boolean) => setShowNavBar(show);
const isMobile = useMediaQuery("(max-width: 428px)");
const router = useRouter();
@@ -57,7 +47,7 @@ export default function App({ Component, pageProps }: AppProps) {
const [themeColor] = useLocalState(LS_KEYS.THEME, THEME_COLOR.DARK);
useEffect(() => {
setupI18n().finally(() => setIsI18nReady(true));
void setupI18n().finally(() => setIsI18nReady(true));
logUnhandledErrorsAndRejections(true);
return () => logUnhandledErrorsAndRejections(false);
}, []);
@@ -66,14 +56,14 @@ export default function App({ Component, pageProps }: AppProps) {
const theme = getTheme(themeColor, "photos");
const logout = () => {
const logout = useCallback(() => {
void accountLogout().then(() => router.push(PAGES.ROOT));
};
}, [router]);
const appContext = {
appName,
logout,
showNavBar,
showNavBar: setShowNavbar,
isMobile,
setDialogBoxAttributesV2,
};
@@ -92,7 +82,7 @@ export default function App({ Component, pageProps }: AppProps) {
sx={{ zIndex: 1600 }}
open={dialogBoxV2View}
onClose={closeDialogBoxV2}
attributes={dialogBoxAttributeV2 as any}
attributes={dialogBoxAttributeV2}
/>
<AppContext.Provider value={appContext}>
@@ -103,8 +93,7 @@ export default function App({ Component, pageProps }: AppProps) {
justifyContent: "center",
alignItems: "center",
zIndex: 2000,
backgroundColor: (theme as any).colors
.background.base,
backgroundColor: theme.colors.background.base,
})}
>
<EnteSpinner />
@@ -116,4 +105,6 @@ export default function App({ Component, pageProps }: AppProps) {
</ThemeProvider>
</>
);
}
};
export default App;

View File

@@ -18,9 +18,9 @@ import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import KeyIcon from "@mui/icons-material/Key";
import { Box, Button, Stack, Typography, useMediaQuery } from "@mui/material";
import { useAppContext } from "components/context";
import { t } from "i18next";
import { useAppContext } from "pages/_app";
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
deletePasskey,
getPasskeys,
@@ -39,6 +39,14 @@ const Page: React.FC = () => {
Passkey | undefined
>();
const showPasskeyFetchFailedErrorDialog = useCallback(() => {
setDialogBoxAttributesV2({
title: t("ERROR"),
content: t("passkey_fetch_failed"),
close: {},
});
}, [setDialogBoxAttributesV2]);
useEffect(() => {
showNavBar(true);
@@ -51,30 +59,22 @@ const Page: React.FC = () => {
log.error("Missing accounts token");
showPasskeyFetchFailedErrorDialog();
}
}, []);
}, [showNavBar, showPasskeyFetchFailedErrorDialog]);
useEffect(() => {
if (token) {
void refreshPasskeys();
}
}, [token]);
const refreshPasskeys = async () => {
const refreshPasskeys = useCallback(async () => {
try {
setPasskeys(await getPasskeys(ensure(token)));
} catch (e) {
log.error("Failed to fetch passkeys", e);
showPasskeyFetchFailedErrorDialog();
}
};
}, [token, showPasskeyFetchFailedErrorDialog]);
const showPasskeyFetchFailedErrorDialog = () => {
setDialogBoxAttributesV2({
title: t("ERROR"),
content: t("passkey_fetch_failed"),
close: {},
});
};
useEffect(() => {
if (token) {
void refreshPasskeys();
}
}, [token, refreshPasskeys]);
const handleSelectPasskey = (passkey: Passkey) => {
setSelectedPasskey(passkey);

View File

@@ -7,7 +7,7 @@ import EnteSpinner from "@ente/shared/components/EnteSpinner";
import InfoIcon from "@mui/icons-material/Info";
import { Paper, Typography, styled } from "@mui/material";
import { t } from "i18next";
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import {
beginPasskeyAuthentication,
finishPasskeyAuthentication,
@@ -50,7 +50,7 @@ const Page = () => {
// Ensure that redirectURL is whitelisted, otherwise show an invalid
// "login" URL error to the user.
if (!redirectURL || !isWhitelistedRedirect(redirectURL)) {
log.error(`Redirect URL '${redirectURL}' is not whitelisted`);
log.error(`Redirect '${redirect}' is not whitelisted`);
setStatus("unknownRedirect");
return;
}

View File

@@ -31,7 +31,13 @@ import { ThemeProvider } from "@mui/material/styles";
import { t } from "i18next";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar";
import "../../public/css/global.css";
@@ -52,7 +58,7 @@ export const AppContext = createContext<AppContextT | undefined>(undefined);
/** Utility hook to reduce amount of boilerplate in account related pages. */
export const useAppContext = () => ensure(useContext(AppContext));
export default function App({ Component, pageProps }: AppProps) {
const App: React.FC<AppProps> = ({ Component, pageProps }) => {
const appName: AppName = "auth";
const router = useRouter();
@@ -75,7 +81,7 @@ export default function App({ Component, pageProps }: AppProps) {
);
useEffect(() => {
setupI18n().finally(() => setIsI18nReady(true));
void setupI18n().finally(() => setIsI18nReady(true));
const userId = (getData(LS_KEYS.USER) as User)?.id;
logStartupBanner(appName, userId);
logUnhandledErrorsAndRejections(true);
@@ -198,4 +204,6 @@ export default function App({ Component, pageProps }: AppProps) {
</ThemeProvider>
</>
);
}
};
export default App;

View File

@@ -152,7 +152,7 @@ export default function App({ Component, pageProps }: AppProps) {
);
useEffect(() => {
setupI18n().finally(() => setIsI18nReady(true));
void setupI18n().finally(() => setIsI18nReady(true));
const userId = (getData(LS_KEYS.USER) as User)?.id;
logStartupBanner(appName, userId);
logUnhandledErrorsAndRejections(true);

View File

@@ -25,5 +25,20 @@ module.exports = {
ignoreArrowShorthand: true,
},
],
/*
Allow async functions to be passed as JSX attributes expected to be
functions that return void (typically onFoo event handlers).
This should be safe since we have registered global unhandled Promise
handlers.
*/
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: {
attributes: false,
},
},
],
},
};

View File

@@ -13,7 +13,7 @@ export interface DialogBoxAttributesV2 {
title?: React.ReactNode;
staticBackdrop?: boolean;
nonClosable?: boolean;
content?: any;
content?: React.ReactNode;
close?: {
text?: string;
variant?: ButtonProps["color"];