Merge branch 'main' into face_fix

This commit is contained in:
Laurens Priem 2024-05-23 14:40:26 +05:30 committed by GitHub
commit 1f9e222d6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 680 additions and 715 deletions

View file

@ -9,7 +9,7 @@ on:
- ".github/workflows/mobile-lint.yml"
env:
FLUTTER_VERSION: "3.19.5"
FLUTTER_VERSION: "3.22.0"
jobs:
lint:

View file

@ -106,7 +106,7 @@ const handleRead = async (path: string) => {
res.headers.set("Content-Length", `${fileSize}`);
// Add the file's last modified time (as epoch milliseconds).
const mtimeMs = stat.mtimeMs;
const mtimeMs = stat.mtime.getTime();
res.headers.set("X-Last-Modified-Ms", `${mtimeMs}`);
}
return res;
@ -132,6 +132,13 @@ const handleReadZip = async (zipPath: string, entryName: string) => {
// Close the zip handle when the underlying stream closes.
stream.on("end", () => void zip.close());
// While it is documented that entry.time is the modification time,
// the units are not mentioned. By seeing the source code, we can
// verify that it is indeed epoch milliseconds. See `parseZipTime`
// in the node-stream-zip source,
// https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js
const modifiedMs = entry.time;
return new Response(webReadableStream, {
headers: {
// We don't know the exact type, but it doesn't really matter, just
@ -139,12 +146,7 @@ const handleReadZip = async (zipPath: string, entryName: string) => {
// doesn't tinker with it thinking of it as text.
"Content-Type": "application/octet-stream",
"Content-Length": `${entry.size}`,
// While it is documented that entry.time is the modification time,
// the units are not mentioned. By seeing the source code, we can
// verify that it is indeed epoch milliseconds. See `parseZipTime`
// in the node-stream-zip source,
// https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js
"X-Last-Modified-Ms": `${entry.time}`,
"X-Last-Modified-Ms": `${modifiedMs}`,
},
});
};

View file

@ -46,7 +46,7 @@ You can alternatively install the build from PlayStore or F-Droid.
## 🧑‍💻 Building from source
1. [Install Flutter v3.19.3](https://flutter.dev/docs/get-started/install).
1. [Install Flutter v3.22.0](https://flutter.dev/docs/get-started/install).
2. Pull in all submodules with `git submodule update --init --recursive`

View file

@ -427,7 +427,7 @@ SPEC CHECKSUMS:
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892
integration_test: 13825b8a9334a850581300559b8839134b124670
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9

View file

@ -987,7 +987,7 @@
"fileTypesAndNames": "Tipos de arquivo e nomes",
"location": "Local",
"moments": "Momentos",
"searchFaceEmptySection": "Encontre todas as fotos de uma pessoa",
"searchFaceEmptySection": "Pessoas serão exibidas aqui uma vez que a indexação é feita",
"searchDatesEmptySection": "Pesquisar por data, mês ou ano",
"searchLocationEmptySection": "Fotos de grupo que estão sendo tiradas em algum raio da foto",
"searchPeopleEmptySection": "Convide pessoas e você verá todas as fotos compartilhadas por elas aqui",
@ -1042,7 +1042,7 @@
"@storageUsageInfo": {
"description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used"
},
"freeStorageSpace": "{freeAmount} {storageUnit} grátis",
"freeStorageSpace": "{freeAmount} {storageUnit} livre",
"appVersion": "Versão: {versionValue}",
"verifyIDLabel": "Verificar",
"fileInfoAddDescHint": "Adicionar descrição...",
@ -1171,6 +1171,7 @@
}
},
"faces": "Rostos",
"people": "Pessoas",
"contents": "Conteúdos",
"addNew": "Adicionar novo",
"@addNew": {
@ -1196,14 +1197,14 @@
"verifyPasskey": "Verificar chave de acesso",
"playOnTv": "Reproduzir álbum na TV",
"pair": "Parear",
"autoPair": "Pareamento automático",
"pairWithPin": "Parear com PIN",
"deviceNotFound": "Dispositivo não encontrado",
"castInstruction": "Visite cast.ente.io no dispositivo que você deseja parear.\n\ndigite o código abaixo para reproduzir o álbum em sua TV.",
"deviceCodeHint": "Insira o código",
"joinDiscord": "Junte-se ao Discord",
"locations": "Locais",
"descriptions": "Descrições",
"addAName": "Adicione um nome",
"findPeopleByName": "Encontre pessoas rapidamente por nome",
"addViewers": "{count, plural, zero {Adicionar visualizador} one {Adicionar visualizador} other {Adicionar Visualizadores}}",
"addCollaborators": "{count, plural, zero {Adicionar colaborador} one {Adicionar coloborador} other {Adicionar colaboradores}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Pressione e segure um e-mail para verificar a criptografia de ponta a ponta.",
@ -1216,6 +1217,8 @@
"customEndpoint": "Conectado a {endpoint}",
"createCollaborativeLink": "Criar link colaborativo",
"search": "Pesquisar",
"enterPersonName": "Inserir nome da pessoa",
"removePersonLabel": "Remover etiqueta da pessoa",
"autoPairDesc": "O pareamento automático funciona apenas com dispositivos que suportam o Chromecast.",
"manualPairDesc": "Parear com o PIN funciona com qualquer tela que você deseja ver o seu álbum ativado.",
"connectToDevice": "Conectar ao dispositivo",
@ -1227,8 +1230,10 @@
"castIPMismatchTitle": "Falha ao transmitir álbum",
"castIPMismatchBody": "Certifique-se de estar na mesma rede que a TV.",
"pairingComplete": "Pareamento concluído",
"faceRecognition": "Face recognition",
"faceRecognitionIndexingDescription": "Please note that this will result in a higher bandwidth and battery usage until all items are indexed.",
"foundFaces": "Found faces",
"clusteringProgress": "Clustering progress"
"autoPair": "Pareamento automático",
"pairWithPin": "Parear com PIN",
"faceRecognition": "Reconhecimento facial",
"faceRecognitionIndexingDescription": "Por favor, note que isso resultará em uma largura de banda maior e uso de bateria até que todos os itens sejam indexados.",
"foundFaces": "Rostos encontrados",
"clusteringProgress": "Progresso de agrupamento"
}

View file

@ -45,10 +45,10 @@ packages:
dependency: "direct main"
description:
name: animated_list_plus
sha256: fe66f9c300d715254727fbdf050487844d17b013fec344fa28081d29bddbdf1a
sha256: fb3d7f1fbaf5af84907f3c739236bacda8bf32cbe1f118dd51510752883ff50c
url: "https://pub.dev"
source: hosted
version: "0.4.5"
version: "0.5.2"
animated_stack_widget:
dependency: transitive
description:
@ -971,10 +971,10 @@ packages:
dependency: "direct main"
description:
name: home_widget
sha256: "29565bfee4b32eaf9e7e8b998d504618b779a74b2b1ac62dd4dac7468e66f1a3"
sha256: "2a0fdd6267ff975bd07bedf74686bd5577200f504f5de36527ac1b56bdbe68e3"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
version: "0.6.0"
html:
dependency: transitive
description:
@ -1152,26 +1152,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
version: "10.0.4"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.3"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.1"
like_button:
dependency: "direct main"
description:
@ -1368,10 +1368,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
version: "1.12.0"
mgrs_dart:
dependency: transitive
description:
@ -2144,10 +2144,10 @@ packages:
dependency: "direct main"
description:
name: styled_text
sha256: f72928d1ebe8cb149e3b34a689cb1ddca696b808187cf40ac3a0bd183dff379c
sha256: fd624172cf629751b4f171dd0ecf9acf02a06df3f8a81bb56c0caa4f1df706c3
url: "https://pub.dev"
source: hosted
version: "7.0.0"
version: "8.1.0"
sync_http:
dependency: transitive
description:
@ -2160,18 +2160,18 @@ packages:
dependency: "direct main"
description:
name: syncfusion_flutter_core
sha256: "9be1bb9bbdb42823439a18da71484f1964c14dbe1c255ab1b931932b12fa96e8"
sha256: "63108a33f9b0d89f7b6b56cce908b8e519fe433dbbe0efcf41ad3e8bb2081bd9"
url: "https://pub.dev"
source: hosted
version: "19.4.56"
version: "25.2.5"
syncfusion_flutter_sliders:
dependency: "direct main"
description:
name: syncfusion_flutter_sliders
sha256: "1f6a63ccab4180b544074b9264a20f01ee80b553de154192fe1d7b434089d3c2"
sha256: f27310bedc0e96e84054f0a70ac593d1a3c38397c158c5226ba86027ad77b2c1
url: "https://pub.dev"
source: hosted
version: "19.4.56"
version: "25.2.5"
synchronized:
dependency: "direct main"
description:
@ -2192,26 +2192,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f
sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073"
url: "https://pub.dev"
source: hosted
version: "1.24.9"
version: "1.25.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
url: "https://pub.dev"
source: hosted
version: "0.6.1"
version: "0.7.0"
test_core:
dependency: transitive
description:
name: test_core
sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a
sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4"
url: "https://pub.dev"
source: hosted
version: "0.5.9"
version: "0.6.0"
timezone:
dependency: transitive
description:
@ -2441,10 +2441,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
version: "13.0.0"
version: "14.2.1"
volume_controller:
dependency: transitive
description:
@ -2591,4 +2591,4 @@ packages:
version: "3.1.2"
sdks:
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.19.0"
flutter: ">=3.20.0-1.2.pre"

View file

@ -21,7 +21,7 @@ environment:
dependencies:
adaptive_theme: ^3.1.0
animate_do: ^2.0.0
animated_list_plus: ^0.4.5
animated_list_plus: ^0.5.2
archive: ^3.1.2
background_fetch: ^1.2.1
battery_info: ^1.1.1
@ -93,13 +93,13 @@ dependencies:
fluttertoast: ^8.0.6
freezed_annotation: ^2.4.1
google_nav_bar: ^5.0.5
home_widget: ^0.5.0
home_widget: ^0.6.0
html_unescape: ^2.0.0
http: ^1.1.0
image: ^4.0.17
image_editor: ^1.3.0
in_app_purchase: ^3.0.7
intl: ^0.18.0
intl: ^0.19.0
json_annotation: ^4.8.0
latlong2: ^0.9.0
like_button: ^2.0.5
@ -152,9 +152,9 @@ dependencies:
sqlite3_flutter_libs: ^0.5.20
sqlite_async: ^0.6.1
step_progress_indicator: ^1.0.2
styled_text: ^7.0.0
syncfusion_flutter_core: ^19.2.49
syncfusion_flutter_sliders: ^19.2.49
styled_text: ^8.1.0
syncfusion_flutter_core: ^25.2.5
syncfusion_flutter_sliders: ^25.2.5
synchronized: ^3.1.0
tuple: ^2.0.0
uni_links: ^0.5.1
@ -177,6 +177,7 @@ dependency_overrides:
# Remove this after removing dependency from flutter_sodium.
# Newer flutter packages depends on ffi > 2.0.0 while flutter_sodium depends on ffi < 2.0.0
ffi: 2.1.0
intl: 0.18.1
video_player:
git:
url: https://github.com/ente-io/packages.git

View file

@ -1,23 +0,0 @@
import { Button } from "@mui/material";
import { t } from "i18next";
export const AuthFooter = () => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
<a
href="https://github.com/ente-io/ente/tree/main/auth#-download"
download
>
<Button color="accent">{t("DOWNLOAD")}</Button>
</a>
</div>
);
};

View file

@ -1,35 +0,0 @@
import { HorizontalFlex } from "@ente/shared/components/Container";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
import NavbarBase from "@ente/shared/components/Navbar/base";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
import MoreHoriz from "@mui/icons-material/MoreHoriz";
import { t } from "i18next";
import { AppContext } from "pages/_app";
import React from "react";
export default function AuthNavbar() {
const { isMobile, logout } = React.useContext(AppContext);
return (
<NavbarBase isMobile={isMobile}>
<HorizontalFlex flex={1} justifyContent={"center"}>
<EnteLogo />
</HorizontalFlex>
<HorizontalFlex position={"absolute"} right="24px">
<OverflowMenu
ariaControls={"auth-options"}
triggerButtonIcon={<MoreHoriz />}
>
<OverflowMenuOption
color="critical"
startIcon={<LogoutOutlined />}
onClick={logout}
>
{t("LOGOUT")}
</OverflowMenuOption>
</OverflowMenu>
</HorizontalFlex>
</NavbarBase>
);
}

View file

@ -1,237 +0,0 @@
import { ButtonBase, Snackbar } from "@mui/material";
import { t } from "i18next";
import { HOTP, TOTP } from "otpauth";
import { useEffect, useState } from "react";
import { Code } from "types/code";
import TimerProgress from "./TimerProgress";
const TOTPDisplay = ({ issuer, account, code, nextCode, period }) => {
return (
<div
style={{
backgroundColor: "rgba(40, 40, 40, 0.6)",
borderRadius: "4px",
overflow: "hidden",
}}
>
<TimerProgress period={period ?? Code.defaultPeriod} />
<div
style={{
padding: "12px 20px 0px 20px",
display: "flex",
alignItems: "flex-start",
minWidth: "320px",
minHeight: "120px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
minWidth: "200px",
}}
>
<p
style={{
fontWeight: "bold",
margin: "0px",
fontSize: "14px",
textAlign: "left",
}}
>
{issuer}
</p>
<p
style={{
marginTop: "0px",
marginBottom: "8px",
textAlign: "left",
fontSize: "12px",
maxWidth: "200px",
minHeight: "16px",
color: "grey",
}}
>
{account}
</p>
<p
style={{
margin: "0px",
marginBottom: "1rem",
fontSize: "24px",
fontWeight: "bold",
textAlign: "left",
}}
>
{code}
</p>
</div>
<div style={{ flex: 1 }} />
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
minWidth: "120px",
textAlign: "right",
marginTop: "auto",
marginBottom: "1rem",
}}
>
<p
style={{
fontWeight: "bold",
marginBottom: "0px",
fontSize: "10px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{t("AUTH_NEXT")}
</p>
<p
style={{
fontSize: "14px",
fontWeight: "bold",
marginBottom: "0px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{nextCode}
</p>
</div>
</div>
</div>
);
};
function BadCodeInfo({ codeInfo, codeErr }) {
const [showRawData, setShowRawData] = useState(false);
return (
<div className="code-info">
<div>{codeInfo.title}</div>
<div>{codeErr}</div>
<div>
{showRawData ? (
<div onClick={() => setShowRawData(false)}>
{codeInfo.rawData ?? "no raw data"}
</div>
) : (
<div onClick={() => setShowRawData(true)}>Show rawData</div>
)}
</div>
</div>
);
}
interface OTPDisplayProps {
codeInfo: Code;
}
const OTPDisplay = (props: OTPDisplayProps) => {
const { codeInfo } = props;
const [code, setCode] = useState("");
const [nextCode, setNextCode] = useState("");
const [codeErr, setCodeErr] = useState("");
const [hasCopied, setHasCopied] = useState(false);
const generateCodes = () => {
try {
const currentTime = new Date().getTime();
if (codeInfo.type.toLowerCase() === "totp") {
const totp = new TOTP({
secret: codeInfo.secret,
algorithm: codeInfo.algorithm ?? Code.defaultAlgo,
period: codeInfo.period ?? Code.defaultPeriod,
digits: codeInfo.digits ?? Code.defaultDigits,
});
setCode(totp.generate());
setNextCode(
totp.generate({
timestamp: currentTime + codeInfo.period * 1000,
}),
);
} else if (codeInfo.type.toLowerCase() === "hotp") {
const hotp = new HOTP({
secret: codeInfo.secret,
counter: 0,
algorithm: codeInfo.algorithm,
});
setCode(hotp.generate());
setNextCode(hotp.generate({ counter: 1 }));
}
} catch (err) {
setCodeErr(err.message);
}
};
const copyCode = () => {
navigator.clipboard.writeText(code);
setHasCopied(true);
setTimeout(() => {
setHasCopied(false);
}, 2000);
};
useEffect(() => {
// this is to set the initial code and nextCode on component mount
generateCodes();
const codeType = codeInfo.type;
const codePeriodInMs = codeInfo.period * 1000;
const timeToNextCode =
codePeriodInMs - (new Date().getTime() % codePeriodInMs);
const intervalId = null;
// wait until we are at the start of the next code period,
// and then start the interval loop
setTimeout(() => {
// we need to call generateCodes() once before the interval loop
// to set the initial code and nextCode
generateCodes();
codeType.toLowerCase() === "totp" ||
codeType.toLowerCase() === "hotp"
? setInterval(() => {
generateCodes();
}, codePeriodInMs)
: null;
}, timeToNextCode);
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [codeInfo]);
return (
<div style={{ padding: "8px" }}>
{codeErr === "" ? (
<ButtonBase
component="div"
onClick={() => {
copyCode();
}}
>
<TOTPDisplay
period={codeInfo.period}
issuer={codeInfo.issuer}
account={codeInfo.account}
code={code}
nextCode={nextCode}
/>
<Snackbar
open={hasCopied}
message="Code copied to clipboard"
/>
</ButtonBase>
) : (
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
)}
</div>
);
};
export default OTPDisplay;

View file

@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
const TimerProgress = ({ period }) => {
const [progress, setProgress] = useState(0);
const [ticker, setTicker] = useState(null);
const microSecondsInPeriod = period * 1000000;
const startTicker = () => {
const ticker = setInterval(() => {
updateTimeRemaining();
}, 10);
setTicker(ticker);
};
const updateTimeRemaining = () => {
const timeRemaining =
microSecondsInPeriod -
((new Date().getTime() * 1000) % microSecondsInPeriod);
setProgress(timeRemaining / microSecondsInPeriod);
};
useEffect(() => {
startTicker();
return () => clearInterval(ticker);
}, []);
const color = progress > 0.4 ? "green" : "orange";
return (
<div
style={{
borderTopLeftRadius: "3px",
width: `${progress * 100}%`,
height: "3px",
backgroundColor: color,
}}
/>
);
};
export default TimerProgress;

View file

@ -0,0 +1,449 @@
import {
HorizontalFlex,
VerticallyCentered,
} from "@ente/shared/components/Container";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import NavbarBase from "@ente/shared/components/Navbar/base";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages";
import { CustomError } from "@ente/shared/error";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
import MoreHoriz from "@mui/icons-material/MoreHoriz";
import { Button, ButtonBase, Snackbar, TextField } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import { HOTP, TOTP } from "otpauth";
import { AppContext } from "pages/_app";
import React, { useContext, useEffect, useState } from "react";
import { Code } from "services/code";
import { getAuthCodes } from "services/remote";
const AuthenticatorCodesPage = () => {
const appContext = useContext(AppContext);
const router = useRouter();
const [codes, setCodes] = useState([]);
const [hasFetched, setHasFetched] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
const fetchCodes = async () => {
try {
const res = await getAuthCodes();
setCodes(res);
} catch (err) {
if (err.message === CustomError.KEY_MISSING) {
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH);
router.push(PAGES.ROOT);
} else {
// do not log errors
}
}
setHasFetched(true);
};
fetchCodes();
appContext.showNavBar(false);
}, []);
const filteredCodes = codes.filter(
(secret) =>
(secret.issuer ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
(secret.account ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
if (!hasFetched) {
return (
<>
<VerticallyCentered>
<EnteSpinner></EnteSpinner>
</VerticallyCentered>
</>
);
}
return (
<>
<AuthNavbar />
<div
style={{
maxWidth: "800px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
}}
>
<div style={{ marginBottom: "1rem" }} />
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
<></>
) : (
<TextField
id="search"
name="search"
label={t("SEARCH")}
onChange={(e) => setSearchTerm(e.target.value)}
variant="filled"
style={{ width: "350px" }}
value={searchTerm}
autoFocus
/>
)}
<div style={{ marginBottom: "1rem" }} />
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{filteredCodes.length === 0 ? (
<div
style={{
alignItems: "center",
display: "flex",
textAlign: "center",
marginTop: "32px",
}}
>
{searchTerm.length !== 0 ? (
<p>{t("NO_RESULTS")}</p>
) : (
<div />
)}
</div>
) : (
filteredCodes.map((code) => (
<CodeDisplay codeInfo={code} key={code.id} />
))
)}
</div>
<div style={{ marginBottom: "2rem" }} />
<AuthFooter />
<div style={{ marginBottom: "4rem" }} />
</div>
</>
);
};
export default AuthenticatorCodesPage;
const AuthNavbar: React.FC = () => {
const { isMobile, logout } = useContext(AppContext);
return (
<NavbarBase isMobile={isMobile}>
<HorizontalFlex flex={1} justifyContent={"center"}>
<EnteLogo />
</HorizontalFlex>
<HorizontalFlex position={"absolute"} right="24px">
<OverflowMenu
ariaControls={"auth-options"}
triggerButtonIcon={<MoreHoriz />}
>
<OverflowMenuOption
color="critical"
startIcon={<LogoutOutlined />}
onClick={logout}
>
{t("LOGOUT")}
</OverflowMenuOption>
</OverflowMenu>
</HorizontalFlex>
</NavbarBase>
);
};
interface CodeDisplay {
codeInfo: Code;
}
const CodeDisplay: React.FC<CodeDisplay> = ({ codeInfo }) => {
const [otp, setOTP] = useState("");
const [nextOTP, setNextOTP] = useState("");
const [codeErr, setCodeErr] = useState("");
const [hasCopied, setHasCopied] = useState(false);
const generateCodes = () => {
try {
const currentTime = new Date().getTime();
if (codeInfo.type === "totp") {
const totp = new TOTP({
secret: codeInfo.secret,
algorithm: codeInfo.algorithm,
period: codeInfo.period,
digits: codeInfo.digits,
});
setOTP(totp.generate());
setNextOTP(
totp.generate({
timestamp: currentTime + codeInfo.period * 1000,
}),
);
} else if (codeInfo.type === "hotp") {
const hotp = new HOTP({
secret: codeInfo.secret,
counter: 0,
algorithm: codeInfo.algorithm,
});
setOTP(hotp.generate());
setNextOTP(hotp.generate({ counter: 1 }));
}
} catch (err) {
setCodeErr(err.message);
}
};
const copyCode = () => {
navigator.clipboard.writeText(otp);
setHasCopied(true);
setTimeout(() => {
setHasCopied(false);
}, 2000);
};
useEffect(() => {
// this is to set the initial code and nextCode on component mount
generateCodes();
const codeType = codeInfo.type;
const codePeriodInMs = codeInfo.period * 1000;
const timeToNextCode =
codePeriodInMs - (new Date().getTime() % codePeriodInMs);
const intervalId = null;
// wait until we are at the start of the next code period,
// and then start the interval loop
setTimeout(() => {
// we need to call generateCodes() once before the interval loop
// to set the initial code and nextCode
generateCodes();
codeType.toLowerCase() === "totp" ||
codeType.toLowerCase() === "hotp"
? setInterval(() => {
generateCodes();
}, codePeriodInMs)
: null;
}, timeToNextCode);
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [codeInfo]);
return (
<div style={{ padding: "8px" }}>
{codeErr === "" ? (
<ButtonBase
component="div"
onClick={() => {
copyCode();
}}
>
<OTPDisplay code={codeInfo} otp={otp} nextOTP={nextOTP} />
<Snackbar
open={hasCopied}
message="Code copied to clipboard"
/>
</ButtonBase>
) : (
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
)}
</div>
);
};
interface OTPDisplayProps {
code: Code;
otp: string;
nextOTP: string;
}
const OTPDisplay: React.FC<OTPDisplayProps> = ({ code, otp, nextOTP }) => {
return (
<div
style={{
backgroundColor: "rgba(40, 40, 40, 0.6)",
borderRadius: "4px",
overflow: "hidden",
}}
>
<TimerProgress period={code.period} />
<div
style={{
padding: "12px 20px 0px 20px",
display: "flex",
alignItems: "flex-start",
minWidth: "320px",
minHeight: "120px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
minWidth: "200px",
}}
>
<p
style={{
fontWeight: "bold",
margin: "0px",
fontSize: "14px",
textAlign: "left",
}}
>
{code.issuer}
</p>
<p
style={{
marginTop: "0px",
marginBottom: "8px",
textAlign: "left",
fontSize: "12px",
maxWidth: "200px",
minHeight: "16px",
color: "grey",
}}
>
{code.account}
</p>
<p
style={{
margin: "0px",
marginBottom: "1rem",
fontSize: "24px",
fontWeight: "bold",
textAlign: "left",
}}
>
{otp}
</p>
</div>
<div style={{ flex: 1 }} />
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
minWidth: "120px",
textAlign: "right",
marginTop: "auto",
marginBottom: "1rem",
}}
>
<p
style={{
fontWeight: "bold",
marginBottom: "0px",
fontSize: "10px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{t("AUTH_NEXT")}
</p>
<p
style={{
fontSize: "14px",
fontWeight: "bold",
marginBottom: "0px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{nextOTP}
</p>
</div>
</div>
</div>
);
};
interface TimerProgressProps {
period: number;
}
const TimerProgress: React.FC<TimerProgressProps> = ({ period }) => {
const [progress, setProgress] = useState(0);
const microSecondsInPeriod = period * 1000000;
useEffect(() => {
const updateTimeRemaining = () => {
const timeRemaining =
microSecondsInPeriod -
((new Date().getTime() * 1000) % microSecondsInPeriod);
setProgress(timeRemaining / microSecondsInPeriod);
};
const ticker = setInterval(() => {
updateTimeRemaining();
}, 10);
return () => clearInterval(ticker);
}, []);
const color = progress > 0.4 ? "green" : "orange";
return (
<div
style={{
borderTopLeftRadius: "3px",
width: `${progress * 100}%`,
height: "3px",
backgroundColor: color,
}}
/>
);
};
function BadCodeInfo({ codeInfo, codeErr }) {
const [showRawData, setShowRawData] = useState(false);
return (
<div className="code-info">
<div>{codeInfo.title}</div>
<div>{codeErr}</div>
<div>
{showRawData ? (
<div onClick={() => setShowRawData(false)}>
{codeInfo.uriString ?? "(no raw data)"}
</div>
) : (
<div onClick={() => setShowRawData(true)}>Show rawData</div>
)}
</div>
</div>
);
}
const AuthFooter: React.FC = () => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
<a
href="https://github.com/ente-io/ente/tree/main/auth#-download"
download
>
<Button color="accent">{t("DOWNLOAD")}</Button>
</a>
</div>
);
};

View file

@ -1,129 +0,0 @@
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages";
import { CustomError } from "@ente/shared/error";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import { TextField } from "@mui/material";
import { AuthFooter } from "components/AuthFooter";
import AuthNavbar from "components/Navbar";
import OTPDisplay from "components/OTPDisplay";
import { t } from "i18next";
import { useRouter } from "next/router";
import { AppContext } from "pages/_app";
import { useContext, useEffect, useState } from "react";
import { getAuthCodes } from "services";
const AuthenticatorCodesPage = () => {
const appContext = useContext(AppContext);
const router = useRouter();
const [codes, setCodes] = useState([]);
const [hasFetched, setHasFetched] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
const fetchCodes = async () => {
try {
const res = await getAuthCodes();
setCodes(res);
} catch (err) {
if (err.message === CustomError.KEY_MISSING) {
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH);
router.push(PAGES.ROOT);
} else {
// do not log errors
}
}
setHasFetched(true);
};
fetchCodes();
appContext.showNavBar(false);
}, []);
const filteredCodes = codes.filter(
(secret) =>
(secret.issuer ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
(secret.account ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
if (!hasFetched) {
return (
<>
<VerticallyCentered>
<EnteSpinner></EnteSpinner>
</VerticallyCentered>
</>
);
}
return (
<>
<AuthNavbar />
<div
style={{
maxWidth: "800px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
}}
>
<div style={{ marginBottom: "1rem" }} />
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
<></>
) : (
<TextField
id="search"
name="search"
label={t("SEARCH")}
onChange={(e) => setSearchTerm(e.target.value)}
variant="filled"
style={{ width: "350px" }}
value={searchTerm}
autoFocus
/>
)}
<div style={{ marginBottom: "1rem" }} />
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{filteredCodes.length === 0 ? (
<div
style={{
alignItems: "center",
display: "flex",
textAlign: "center",
marginTop: "32px",
}}
>
{searchTerm.length !== 0 ? (
<p>{t("NO_RESULTS")}</p>
) : (
<div />
)}
</div>
) : (
filteredCodes.map((code) => (
<OTPDisplay codeInfo={code} key={code.id} />
))
)}
</div>
<div style={{ marginBottom: "2rem" }} />
<AuthFooter />
<div style={{ marginBottom: "4rem" }} />
</div>
</>
);
};
export default AuthenticatorCodesPage;

View file

@ -1,7 +1,7 @@
import TwoFactorVerifyPage from "@ente/accounts/pages/two-factor/verify";
import { APPS } from "@ente/shared/apps/constants";
import { useContext } from "react";
import { AppContext } from "../../_app";
import { AppContext } from "../_app";
export default function TwoFactorVerify() {
const appContext = useContext(AppContext);

View file

@ -0,0 +1,154 @@
import { URI } from "vscode-uri";
/**
* A parsed representation of an xOTP code URI.
*
* This is all the data we need to drive a OTP generator.
*/
export interface Code {
/** The uniquue id for the corresponding auth entity. */
id?: String;
/** The type of the code. */
type: "totp" | "hotp";
/** The user's account or email for which this code is used. */
account: string;
/** The name of the entity that issued this code. */
issuer: string;
/** Number of digits in the code. */
digits: number;
/**
* The time period (in seconds) for which a single OTP generated from this
* code remains valid.
*/
period: number;
/** The secret that is used to drive the OTP generator. */
secret: string;
/** The (hashing) algorithim used by the OTP generator. */
algorithm: "sha1" | "sha256" | "sha512";
/** The original string from which this code was generated. */
uriString?: string;
}
/**
* Convert a OTP code URI into its parse representation, a {@link Code}.
*
* @param id A unique ID of this code within the auth app.
*
* @param uriString A string specifying how to generate a TOTP/HOTP/Steam OTP
* code. These strings are of the form:
*
* - (TOTP)
* otpauth://totp/account:user@example.org?algorithm=SHA1&digits=6&issuer=issuer&period=30&secret=ALPHANUM
*/
export const codeFromURIString = (id: string, uriString: string): Code => {
let santizedRawData = uriString
.replace(/\+/g, "%2B")
.replace(/:/g, "%3A")
.replaceAll("\r", "");
if (santizedRawData.startsWith('"')) {
santizedRawData = santizedRawData.substring(1);
}
if (santizedRawData.endsWith('"')) {
santizedRawData = santizedRawData.substring(
0,
santizedRawData.length - 1,
);
}
const uriParams = {};
const searchParamsString =
decodeURIComponent(santizedRawData).split("?")[1];
searchParamsString.split("&").forEach((pair) => {
const [key, value] = pair.split("=");
uriParams[key] = value;
});
const uri = URI.parse(santizedRawData);
let uriPath = decodeURIComponent(uri.path);
if (uriPath.startsWith("/otpauth://") || uriPath.startsWith("otpauth://")) {
uriPath = uriPath.split("otpauth://")[1];
} else if (uriPath.startsWith("otpauth%3A//")) {
uriPath = uriPath.split("otpauth%3A//")[1];
}
return {
id,
type: _getType(uriPath),
account: _getAccount(uriPath),
issuer: _getIssuer(uriPath, uriParams),
digits: parseDigits(uriParams),
period: parsePeriod(uriParams),
secret: getSanitizedSecret(uriParams),
algorithm: parseAlgorithm(uriParams),
uriString,
};
};
const _getAccount = (uriPath: string): string => {
try {
const path = decodeURIComponent(uriPath);
if (path.includes(":")) {
return path.split(":")[1];
} else if (path.includes("/")) {
return path.split("/")[1];
}
} catch (e) {
return "";
}
};
const _getIssuer = (uriPath: string, uriParams: { get?: any }): string => {
try {
if (uriParams["issuer"] !== undefined) {
let issuer = uriParams["issuer"];
// This is to handle bug in the ente auth app
if (issuer.endsWith("period")) {
issuer = issuer.substring(0, issuer.length - 6);
}
return issuer;
}
let path = decodeURIComponent(uriPath);
if (path.startsWith("totp/") || path.startsWith("hotp/")) {
path = path.substring(5);
}
if (path.includes(":")) {
return path.split(":")[0];
} else if (path.includes("-")) {
return path.split("-")[0];
}
return path;
} catch (e) {
return "";
}
};
const parseDigits = (uriParams): number =>
parseInt(uriParams["digits"] ?? "", 10) || 6;
const parsePeriod = (uriParams): number =>
parseInt(uriParams["period"] ?? "", 10) || 30;
const parseAlgorithm = (uriParams): Code["algorithm"] => {
switch (uriParams["algorithm"]?.toLowerCase()) {
case "sha256":
return "sha256";
case "sha512":
return "sha512";
default:
return "sha1";
}
};
const _getType = (uriPath: string): Code["type"] => {
const oauthType = uriPath.split("/")[0].substring(0);
if (oauthType.toLowerCase() === "totp") {
return "totp";
} else if (oauthType.toLowerCase() === "hotp") {
return "hotp";
}
throw new Error(`Unsupported format with host ${oauthType}`);
};
const getSanitizedSecret = (uriParams): string => {
return uriParams["secret"].replace(/ /g, "").toUpperCase();
};

View file

@ -6,10 +6,10 @@ import { getEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { getActualKey } from "@ente/shared/user";
import { HttpStatusCode } from "axios";
import { AuthEntity, AuthKey } from "types/api";
import { Code } from "types/code";
import { codeFromURIString, type Code } from "services/code";
const ENDPOINT = getEndpoint();
export const getAuthCodes = async (): Promise<Code[]> => {
const masterKey = await getActualKey();
try {
@ -33,7 +33,7 @@ export const getAuthCodes = async (): Promise<Code[]> => {
entity.header,
authenticatorKey,
);
return Code.fromRawData(entity.id, decryptedCode);
return codeFromURIString(entity.id, decryptedCode);
} catch (e) {
log.error(`failed to parse codeId = ${entity.id}`);
return null;
@ -65,6 +65,20 @@ export const getAuthCodes = async (): Promise<Code[]> => {
}
};
interface AuthEntity {
id: string;
encryptedData: string | null;
header: string | null;
isDeleted: boolean;
createdAt: number;
updatedAt: number;
}
interface AuthKey {
encryptedKey: string;
header: string;
}
export const getAuthKey = async (): Promise<AuthKey> => {
try {
const resp = await HTTPService.get(

View file

@ -1,13 +0,0 @@
export interface AuthEntity {
id: string;
encryptedData: string | null;
header: string | null;
isDeleted: boolean;
createdAt: number;
updatedAt: number;
}
export interface AuthKey {
encryptedKey: string;
header: string;
}

View file

@ -1,182 +0,0 @@
import { URI } from "vscode-uri";
type Type = "totp" | "TOTP" | "hotp" | "HOTP";
type AlgorithmType =
| "sha1"
| "SHA1"
| "sha256"
| "SHA256"
| "sha512"
| "SHA512";
export class Code {
static readonly defaultDigits = 6;
static readonly defaultAlgo = "sha1";
static readonly defaultPeriod = 30;
// id for the corresponding auth entity
id?: String;
account: string;
issuer: string;
digits?: number;
period: number;
secret: string;
algorithm: AlgorithmType;
type: Type;
rawData?: string;
constructor(
account: string,
issuer: string,
digits: number | undefined,
period: number,
secret: string,
algorithm: AlgorithmType,
type: Type,
rawData?: string,
id?: string,
) {
this.account = account;
this.issuer = issuer;
this.digits = digits;
this.period = period;
this.secret = secret;
this.algorithm = algorithm;
this.type = type;
this.rawData = rawData;
this.id = id;
}
static fromRawData(id: string, rawData: string): Code {
let santizedRawData = rawData
.replace(/\+/g, "%2B")
.replace(/:/g, "%3A")
.replaceAll("\r", "");
if (santizedRawData.startsWith('"')) {
santizedRawData = santizedRawData.substring(1);
}
if (santizedRawData.endsWith('"')) {
santizedRawData = santizedRawData.substring(
0,
santizedRawData.length - 1,
);
}
const uriParams = {};
const searchParamsString =
decodeURIComponent(santizedRawData).split("?")[1];
searchParamsString.split("&").forEach((pair) => {
const [key, value] = pair.split("=");
uriParams[key] = value;
});
const uri = URI.parse(santizedRawData);
let uriPath = decodeURIComponent(uri.path);
if (
uriPath.startsWith("/otpauth://") ||
uriPath.startsWith("otpauth://")
) {
uriPath = uriPath.split("otpauth://")[1];
} else if (uriPath.startsWith("otpauth%3A//")) {
uriPath = uriPath.split("otpauth%3A//")[1];
}
return new Code(
Code._getAccount(uriPath),
Code._getIssuer(uriPath, uriParams),
Code._getDigits(uriParams),
Code._getPeriod(uriParams),
Code.getSanitizedSecret(uriParams),
Code._getAlgorithm(uriParams),
Code._getType(uriPath),
rawData,
id,
);
}
private static _getAccount(uriPath: string): string {
try {
const path = decodeURIComponent(uriPath);
if (path.includes(":")) {
return path.split(":")[1];
} else if (path.includes("/")) {
return path.split("/")[1];
}
} catch (e) {
return "";
}
}
private static _getIssuer(
uriPath: string,
uriParams: { get?: any },
): string {
try {
if (uriParams["issuer"] !== undefined) {
let issuer = uriParams["issuer"];
// This is to handle bug in the ente auth app
if (issuer.endsWith("period")) {
issuer = issuer.substring(0, issuer.length - 6);
}
return issuer;
}
let path = decodeURIComponent(uriPath);
if (path.startsWith("totp/") || path.startsWith("hotp/")) {
path = path.substring(5);
}
if (path.includes(":")) {
return path.split(":")[0];
} else if (path.includes("-")) {
return path.split("-")[0];
}
return path;
} catch (e) {
return "";
}
}
private static _getDigits(uriParams): number {
try {
return parseInt(uriParams["digits"], 10) || Code.defaultDigits;
} catch (e) {
return Code.defaultDigits;
}
}
private static _getPeriod(uriParams): number {
try {
return parseInt(uriParams["period"], 10) || Code.defaultPeriod;
} catch (e) {
return Code.defaultPeriod;
}
}
private static _getAlgorithm(uriParams): AlgorithmType {
try {
const algorithm = uriParams["algorithm"].toLowerCase();
if (algorithm === "sha256") {
return algorithm;
} else if (algorithm === "sha512") {
return algorithm;
}
} catch (e) {
// nothing
}
return "sha1";
}
private static _getType(uriPath: string): Type {
const oauthType = uriPath.split("/")[0].substring(0);
if (oauthType.toLowerCase() === "totp") {
return "totp";
} else if (oauthType.toLowerCase() === "hotp") {
return "hotp";
}
throw new Error(`Unsupported format with host ${oauthType}`);
}
static getSanitizedSecret(uriParams): string {
return uriParams["secret"].replace(/ /g, "").toUpperCase();
}
}