Merge branch 'main' into remove_awaits

This commit is contained in:
Vishnu Mohandas 2024-03-09 08:42:04 +05:30 committed by GitHub
commit b52cf1605d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1137 additions and 283 deletions

View file

@ -3,15 +3,16 @@ name: "Sync Crowdin translations (auth)"
on:
push:
paths:
# Run action when auth's intl_en.arb is changed
# Run workflow when auth's intl_en.arb is changed
- "mobile/lib/l10n/arb/app_en.arb"
# Or the workflow itself is changed
- ".github/workflows/auth-crowdin.yml"
branches: [main]
schedule:
# Run every 24 hours - https://crontab.guru/#0_*/24_*_*_*
- cron: "0 */24 * * *"
workflow_dispatch: # Allow manually running the action
# See: [Note: Run every 24 hours]
- cron: "50 1 * * *"
# Also allow manually running the workflow
workflow_dispatch:
jobs:
synchronize-with-crowdin:

47
.github/workflows/docs-deploy.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: "Deploy (docs)"
on:
# Run on every push to main that changes docs/
push:
branches: [main]
paths:
- "docs/**"
- ".github/workflows/docs-deploy.yml"
# Also allow manually running the workflow
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: "docs/yarn.lock"
- name: Install dependencies
run: yarn install
- name: Build production site
# Will create docs/.vitepress/dist
run: yarn build
- name: Publish
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: help
directory: docs/docs/.vitepress/dist
wranglerVersion: "3"

View file

@ -3,15 +3,16 @@ name: "Sync Crowdin translations (mobile)"
on:
push:
paths:
# Run action when mobiles's intl_en.arb is changed
# Run workflow when mobiles's intl_en.arb is changed
- "mobile/lib/l10n/intl_en.arb"
# Or the workflow itself is changed
- ".github/workflows/mobile-crowdin.yml"
branches: [main]
schedule:
# Run every 24 hours - https://crontab.guru/#0_*/24_*_*_*
- cron: "0 */24 * * *"
workflow_dispatch: # Allow manually running the action
# See: [Note: Run every 24 hours]
- cron: "40 1 * * *"
# Also allow manually running the workflow
workflow_dispatch:
jobs:
synchronize-with-crowdin:

View file

@ -3,15 +3,16 @@ name: "Sync Crowdin translations (web)"
on:
push:
paths:
# Run action when web's en-US/translation.json is changed
# Run workflow when web's en-US/translation.json is changed
- "web/apps/photos/public/locales/en-US/translation.json"
# Or the workflow itself is changed
- ".github/workflows/web-crowdin.yml"
branches: [main]
schedule:
# Run every 24 hours - https://crontab.guru/#0_*/24_*_*_*
- cron: "0 */24 * * *"
workflow_dispatch: # Allow manually running the action
# See: [Note: Run every 24 hours]
- cron: "20 1 * * *"
# Also allow manually running the workflow
workflow_dispatch:
jobs:
synchronize-with-crowdin:

View file

@ -0,0 +1,43 @@
name: "Deploy (accounts)"
on:
push:
# Run workflow on pushes to the deploy/accounts
branches: [deploy/accounts]
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: "docs/yarn.lock"
- name: Install dependencies
run: yarn install
- name: Build accounts
run: yarn build:accounts
- name: Publish accounts
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: deploy/accounts
directory: web/apps/accounts/out
wranglerVersion: "3"

43
.github/workflows/web-deploy-auth.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: "Deploy (auth)"
on:
push:
# Run workflow on pushes to the deploy/auth
branches: [deploy/auth]
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: "docs/yarn.lock"
- name: Install dependencies
run: yarn install
- name: Build auth
run: yarn build:auth
- name: Publish auth
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: deploy/auth
directory: web/apps/auth/out
wranglerVersion: "3"

43
.github/workflows/web-deploy-cast.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: "Deploy (cast)"
on:
push:
# Run workflow on pushes to the deploy/cast
branches: [deploy/cast]
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: "docs/yarn.lock"
- name: Install dependencies
run: yarn install
- name: Build cast
run: yarn build:cast
- name: Publish cast
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: deploy/cast
directory: web/apps/cast/out
wranglerVersion: "3"

43
.github/workflows/web-deploy-photos.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: "Deploy (photos)"
on:
push:
# Run workflow on pushes to the deploy/photos
branches: [deploy/photos]
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: "docs/yarn.lock"
- name: Install dependencies
run: yarn install
- name: Build photos
run: yarn build:photos
- name: Publish photos
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: deploy/photos
directory: web/apps/photos/out
wranglerVersion: "3"

92
.github/workflows/web-nightly.yml vendored Normal file
View file

@ -0,0 +1,92 @@
name: "Nightly (web)"
on:
schedule:
# [Note: Run every 24 hours]
#
# Run every 24 hours - First field is minute, second is hour of the day
# This runs 23:15 UTC everyday - 1 and 15 are just arbitrary offset to
# avoid scheduling it on the exact hour, as suggested by GitHub.
#
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
# https://crontab.guru/
#
- cron: "15 23 * * *"
# Also allow manually running the workflow
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: "docs/yarn.lock"
- name: Install dependencies
run: yarn install
- name: Build accounts
run: yarn build:accounts
- name: Publish accounts
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: n-accounts
directory: web/apps/accounts/out
wranglerVersion: "3"
- name: Build auth
run: yarn build:auth
- name: Publish auth
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: n-auth
directory: web/apps/auth/out
wranglerVersion: "3"
- name: Build cast
run: yarn build:cast
- name: Publish cast
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: n-cast
directory: web/apps/cast/out
wranglerVersion: "3"
- name: Build photos
run: yarn build:photos
- name: Publish photos
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: n-photos
directory: web/apps/photos/out
wranglerVersion: "3"

View file

@ -50,13 +50,13 @@ Thank you for your support.
## Document
_Coming soon!_
The help guides and FAQs for users of Ente products are also open source, and
can be edited in a wiki-esque manner by our community members. More than the
quantity, we feel this helps improve the quality and approachability of the
documentation by bringing in more diverse viewpoints and familiarity levels.
See [docs/](docs/README.md) for how to edit these documents.
## Code contributions
If you'd like to contribute code, it is best to start small.

View file

@ -70,7 +70,7 @@ existing users will be grandfathered in.
[<img height="42" src=".github/assets/app-store-badge.svg">](https://apps.apple.com/app/id6444121398)
[<img height="42" src=".github/assets/play-store-badge.png">](https://play.google.com/store/apps/details?id=io.ente.auth)
[<img height="42" src=".github/assets/f-droid-badge.png">](https://f-droid.org/packages/io.ente.auth/)
[<img height="42" src=".github/assets/github-badge.png">](https://github.com/ente-io/ente/releases?q=tag%3Av2.0.34&expanded=true)
[<img height="42" src=".github/assets/github-badge.png">](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v2)
[<img height="42" src=".github/assets/web-badge.svg">](https://auth.ente.io)
</div>

View file

@ -7,7 +7,7 @@ details as possible about whatever it is that you need help with, and we will
get back to you as soon as possible.
In some cases, your query might already have been answered in our help
documentation (_Coming soon!_).
documentation at [help.ente.io](https://help.ente.io).
Other ways to get in touch are:

View file

@ -12,7 +12,7 @@ multi-device sync.
### Android
This repository's [GitHub
releases](https://github.com/ente-io/ente/releases/latest/download/ente-auth.apk)
releases](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v2)
contains APKs, built straight from source. These builds keep themselves updated,
without relying on third party stores.

View file

@ -1,14 +1,34 @@
# Docs
Help and documentation for Ente's products
Help and documentation for Ente's products.
> [!CAUTION]
>
> **Currently not published**. There are bits we need to clean up before
> publishing these docs. They'll likely be available at help.ente.io once we
> wrap those loose ends up.
You can find the live version of these at
**[help.ente.io](https://help.ente.io)**.
## Running
## Quick edits
You can edit these files directly on GitHub and open a pull request.
[help.ente.io](https://help.ente.io) will automatically get updated with your
changes in a few minutes after your pull request is merged.
## Running locally
The above workflow is great since it doesn't require you to setup anything on
your local machine. But if you plan on contributing frequently, you might find
it easier to run things locally.
Clone this repository
```sh
git clone https://github.com/ente-io/ente
```
Change to this directory
```sh
cd ente/docs
```
Install dependencies
@ -22,27 +42,12 @@ Then start a local server
yarn dev
```
## Workflow
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.
You can edit these files directly on GitHub and open a pull request. That is the
easiest workflow to get started without needing to install anything on your
local machine.
If you plan on contributing frequently, we recommend using 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.
Note that we currently don't enforce these formatting standards to make it easy
for people unfamiliar with programming to also be able to make edits from GitHub
directly.
This is a common theme - unlike the rest of the codebase where we expect some
baseline understanding of the tools involved, the docs are meant to be a place
for non-technical people to also provide their input. The reason for this is not
to increase the number of docs, but to bring more diversity to them. Such
diversity of viewpoints is essential for evolving documents that can be of help
to people of varying level of familiarity with tech.
## Have fun!
If you're unsure about how to do something, just look around in the other files
and copy paste whatever seems to match the look of what you're trying to do. And

View file

@ -7,6 +7,7 @@ export default defineConfig({
description: "Documentation and help for Ente's products",
head: [["link", { rel: "icon", type: "image/png", href: "/favicon.png" }]],
cleanUrls: true,
ignoreDeadLinks: 'localhostLinks',
themeConfig: {
// We use the default theme (with some CSS color overrides). This
// themeConfig block can be used to further customize the default theme.

View file

@ -74,6 +74,41 @@ export const sidebar = [
},
],
},
{
text: "Self hosting",
collapsed: true,
items: [
{ text: "Getting started", link: "/self-hosting/" },
{
text: "Guides",
items: [
{ text: "Introduction", link: "/self-hosting/guides/" },
{
text: "System requirements",
link: "/self-hosting/guides/system-requirements",
},
],
},
{
text: "FAQ",
items: [
{
text: "Verification code",
link: "/self-hosting/faq/otp",
},
],
},
{
text: "Troubleshooting",
items: [
{
text: "Yarn",
link: "/self-hosting/troubleshooting/yarn",
},
],
},
],
},
{
text: "About",
link: "/about/",

View file

@ -11,5 +11,4 @@ can use it to safely store your 2FA codes (second-factor authentication codes).
> [!CAUTION]
>
> These docs are still incomplete. If you feel like documenting an issue you ran
> into and then found a solution to, help us [fill them
> in](/about/contributing).
> into and then found a solution to, help us [fill them in](/about/contribute).

View file

@ -10,4 +10,4 @@ Ende-zu-Ende-verschlüsselte Authenticator-App für jedermann. Wir sind froh, da
du hier bist!
**Please note that this German translation is currently just a placeholder.**
Know German? [Help us fill this in!](/about/contributing).
Know German? [Help us fill this in!](/about/contribute).

View file

@ -15,5 +15,5 @@ If you are using VPN, please try disabling the VPN or switching provider.
The desktop/web app tries to detect if a particular file is video or image. If
the detection fails, then the app skips the upload. Please contact our
[support](support.ente.io) if you find that a valid file did not get detected
and uploaded.
[support](mailto:support@ente.io) if you find that a valid file did not get
detected and uploaded.

View file

@ -15,7 +15,7 @@ the logs just make the process a bit faster and easier.
### Mobile
Placeholder
Steps for mobile. Still a placeholder.
### Desktop

View file

@ -0,0 +1,19 @@
---
title: Verification code
description: Getting the OTP for a self host Ente
---
# Verification code
The self-hosted Ente by default does not send out emails, so you can pick the
verification code by:
* Getting it from the server logs, or
* Reading it from the DB (otts table)
You can also set pre-defined hardcoded OTTs for certain users when running
locally by creating a `museum.yaml` and adding the `internal.hardcoded-ott`
configuration setting to it. See
[local.yaml](https://github.com/ente-io/ente/blob/main/server/configurations/local.yaml)
in the server source code for details about how to define this.

View file

@ -0,0 +1,9 @@
---
title: Self Hosting
description: Guides for self hosting Ente Photos and/or Ente Auth
---
# Guides
If you've figured out how to do something, help others out by adding
walkthroughs, tutorials and other FAQ pages in this directory.

View file

@ -0,0 +1,14 @@
---
title: System requirements
description: System requirements for running Ente's server
---
# System requirements
There aren't any "minimum" system requirements as such, the server process is
very light weight - it's just a single go binary, and it doesn't do any server
side ML, so I feel it should be able to run on anything reasonable.
We've used the server quite easily on small cloud instances, old laptops etc. A
community member also reported being able to run the server on [very low-end
embedded devices](https://github.com/ente-io/ente/discussions/594).

View file

@ -0,0 +1,64 @@
---
title: Self Hosting
description: Getting started self hosting Ente Photos and/or Ente Auth
---
# Self Hosting
The entire source code for Ente is open source, including the servers. This is
the same code we use for our own cloud service.
> [!TIP]
>
> To get some context, you might find our [blog
> post](https://ente.io/blog/open-sourcing-our-server/) announcing the open
> sourcing of our server useful.
## Getting started
Start the server
```sh
git clone https://github.com/ente-io/ente
cd ente/server
docker compose up --build
```
Then in a separate terminal, you can run (e.g) the web client
```sh
cd ente/web
git submodule update --init --recursive
yarn install
NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 yarn dev
```
That's about it. If you open http://localhost:3000, you will be able to create
an account on a Ente Photos web app running on your machine, and this web app
will be connecting to the server running on your local machine at
localhost:8080.
## Next steps
* More details about the server are in its
[README](https://github.com/ente-io/ente/tree/main/server#readme)
* More details about running the server (with or without Docker) are in
[RUNNING](https://github.com/ente-io/ente/blob/main/server/RUNNING.md)
* If you have questions around self-hosting that are not answered in any of the
existing documentation, you can ask in our [GitHub
Discussions](https://github.com/ente-io/ente/discussions). **Please remember
to search first if the query has been already asked and answered.**
## Contributing!
While we would love to provide a completely seamless self-hosting experience,
right now we do not have the engineering bandwidth to answer all queries,
document everything exactly etc. We will try (that's why we're writing this!),
but we also hope that community members will step up to fill any gaps.
One particular way in which you can help is by adding new [guides](guides/) on
this help site. The documentation is written in Markdown and adding new pages is
[easy](https://github.com/ente-io/ente/tree/main/docs#readme). Editing existing
pages is even easier: at the bottom of each page is an _Edit this page_ link.

View file

@ -0,0 +1,10 @@
---
title: Yarn errors
description: Fixing yarn install errors when trying to self host Ente
---
# Yarn
If your `yarn install` is failing, make sure you are using Yarn Classic
* https://classic.yarnpkg.com/lang/en/docs/install

View file

@ -958,7 +958,7 @@ class MessageLookup extends MessageLookupByLibrary {
"pair": MessageLookupByLibrary.simpleMessage("Pair"),
"passkey": MessageLookupByLibrary.simpleMessage("Passkey"),
"passkeyAuthTitle":
MessageLookupByLibrary.simpleMessage("Passkey authentication"),
MessageLookupByLibrary.simpleMessage("Passkey verification"),
"password": MessageLookupByLibrary.simpleMessage("Password"),
"passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage(
"Password changed successfully"),
@ -1446,6 +1446,7 @@ class MessageLookup extends MessageLookupByLibrary {
"verifyEmail": MessageLookupByLibrary.simpleMessage("Verify email"),
"verifyEmailID": m64,
"verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verify"),
"verifyPasskey": MessageLookupByLibrary.simpleMessage("Verify passkey"),
"verifyPassword":
MessageLookupByLibrary.simpleMessage("Verify password"),
"verifying": MessageLookupByLibrary.simpleMessage("Verifying..."),
@ -1465,8 +1466,8 @@ class MessageLookup extends MessageLookupByLibrary {
"viewer": MessageLookupByLibrary.simpleMessage("Viewer"),
"visitWebToManage": MessageLookupByLibrary.simpleMessage(
"Please visit web.ente.io to manage your subscription"),
"waitingForBrowserRequest": MessageLookupByLibrary.simpleMessage(
"Waiting for browser request..."),
"waitingForVerification":
MessageLookupByLibrary.simpleMessage("Waiting for verification..."),
"waitingForWifi":
MessageLookupByLibrary.simpleMessage("Waiting for WiFi..."),
"weAreOpenSource":

View file

@ -820,6 +820,8 @@ class MessageLookup extends MessageLookupByLibrary {
"language": MessageLookupByLibrary.simpleMessage("Idioma"),
"lastUpdated":
MessageLookupByLibrary.simpleMessage("Última atualização"),
"launchPasskeyUrlAgain": MessageLookupByLibrary.simpleMessage(
"Iniciar a URL de chave de acesso novamente"),
"leave": MessageLookupByLibrary.simpleMessage("Sair"),
"leaveAlbum": MessageLookupByLibrary.simpleMessage("Sair do álbum"),
"leaveFamily": MessageLookupByLibrary.simpleMessage("Sair da família"),
@ -984,6 +986,9 @@ class MessageLookup extends MessageLookupByLibrary {
"orPickAnExistingOne":
MessageLookupByLibrary.simpleMessage("Ou escolha um existente"),
"pair": MessageLookupByLibrary.simpleMessage("Parear"),
"passkey": MessageLookupByLibrary.simpleMessage("Chave de acesso"),
"passkeyAuthTitle": MessageLookupByLibrary.simpleMessage(
"Autenticação via Chave de acesso"),
"password": MessageLookupByLibrary.simpleMessage("Senha"),
"passwordChangedSuccessfully":
MessageLookupByLibrary.simpleMessage("Senha alterada com sucesso"),

View file

@ -62,7 +62,7 @@ class MessageLookup extends MessageLookupByLibrary {
static String m14(albumName) => "这将删除用于访问\"${albumName}\"的公共链接。";
static String m15(supportEmail) => "请从您注册的电子邮件地址拖放一封邮件到 ${supportEmail}";
static String m15(supportEmail) => "请从您注册的邮箱发送一封邮件到 ${supportEmail}";
static String m16(count, storageSaved) =>
"您已经清理了 ${Intl.plural(count, other: '${count} 个重复文件')}, 释放了 (${storageSaved}!)";
@ -81,7 +81,7 @@ class MessageLookup extends MessageLookupByLibrary {
"此相册中的 ${Intl.plural(count, one: '1 个文件', other: '${formattedNumber} 个文件')} 已安全备份";
static String m22(storageAmountInGB) =>
"每当有人注册付费计划时${storageAmountInGB} GB 并应用了您的代码";
"每当有人使用您的代码注册付费计划时您将获得${storageAmountInGB} GB";
static String m23(freeAmount, storageUnit) =>
"${freeAmount} ${storageUnit} 空闲";
@ -126,7 +126,7 @@ class MessageLookup extends MessageLookupByLibrary {
static String m40(storeName) => "${storeName} 上给我们评分";
static String m41(storageInGB) => "3. 你都可以免费获得 ${storageInGB} GB*";
static String m41(storageInGB) => "3. 你和朋友都将免费获得 ${storageInGB} GB*";
static String m42(userEmail) =>
"${userEmail} 将从这个共享相册中删除\n\nTA们添加的任何照片也将从相册中删除";
@ -143,10 +143,10 @@ class MessageLookup extends MessageLookupByLibrary {
static String m47(verificationID) => "这是我的ente.io 的验证 ID ${verificationID}";
static String m48(verificationID) =>
"嘿,你能确认这是你的 ente.io 验证 ID${verificationID}";
"嘿,你能确认这是你的 ente.io 验证 ID${verificationID}";
static String m49(referralCode, referralStorageInGB) =>
"ente转发码: ${referralCode} \n\n在设置 → 常规 → 推荐中应用它以在注册付费计划后可以免费获得 ${referralStorageInGB} GB\n\nhttps://ente.io";
"ente推荐码: ${referralCode} \n\n注册付费计划后在设置 → 常规 → 推荐中应用它以免费获得 ${referralStorageInGB} GB空间\n\nhttps://ente.io";
static String m50(numberOfPeople) =>
"${Intl.plural(numberOfPeople, zero: '与特定人员共享', one: '与 1 人共享', other: '${numberOfPeople} 人共享')}";
@ -247,7 +247,7 @@ class MessageLookup extends MessageLookupByLibrary {
"androidBiometricNotRecognized":
MessageLookupByLibrary.simpleMessage("无法识别。请重试。"),
"androidBiometricRequiredTitle":
MessageLookupByLibrary.simpleMessage("需要生物"),
MessageLookupByLibrary.simpleMessage("需要生物识别认证"),
"androidBiometricSuccess": MessageLookupByLibrary.simpleMessage("成功"),
"androidCancelButton": MessageLookupByLibrary.simpleMessage("取消"),
"androidDeviceCredentialsRequiredTitle":
@ -255,7 +255,7 @@ class MessageLookup extends MessageLookupByLibrary {
"androidDeviceCredentialsSetupDescription":
MessageLookupByLibrary.simpleMessage("需要设备凭据"),
"androidGoToSettingsDescription": MessageLookupByLibrary.simpleMessage(
"未在您的设备上设置生物鉴别身份验证。前往“设置>安全”添加生物鉴别身份验证。"),
"您未在该设备上设置生物识别身份验证。前往“设置>安全”添加生物识别身份验证。"),
"androidIosWebDesktop":
MessageLookupByLibrary.simpleMessage("安卓, iOS, 网页端, 桌面端"),
"androidSignInTitle": MessageLookupByLibrary.simpleMessage("需要身份验证"),
@ -286,7 +286,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("您删除账户的主要原因是什么?"),
"askYourLovedOnesToShare":
MessageLookupByLibrary.simpleMessage("请您的亲人分享"),
"atAFalloutShelter": MessageLookupByLibrary.simpleMessage("在一个护所中"),
"atAFalloutShelter": MessageLookupByLibrary.simpleMessage("在一个护所中"),
"authToChangeEmailVerificationSetting":
MessageLookupByLibrary.simpleMessage("请进行身份验证以更改电子邮件验证"),
"authToChangeLockscreenSetting":
@ -360,14 +360,14 @@ class MessageLookup extends MessageLookupByLibrary {
"clickOnTheOverflowMenu":
MessageLookupByLibrary.simpleMessage("• 点击溢出菜单"),
"close": MessageLookupByLibrary.simpleMessage("关闭"),
"clubByCaptureTime": MessageLookupByLibrary.simpleMessage("抓取时间断开"),
"clubByCaptureTime": MessageLookupByLibrary.simpleMessage("拍摄时间分组"),
"clubByFileName": MessageLookupByLibrary.simpleMessage("按文件名排序"),
"codeAppliedPageTitle": MessageLookupByLibrary.simpleMessage("代码已应用"),
"codeCopiedToClipboard":
MessageLookupByLibrary.simpleMessage("代码已复制到剪贴板"),
"codeUsedByYou": MessageLookupByLibrary.simpleMessage("您所使用的代码"),
"collabLinkSectionDescription": MessageLookupByLibrary.simpleMessage(
"创建一个链接以允许在您的共享相册中添加和查看照片,而无需应用程序或账户。 非常适合收集活动照片。"),
"创建一个链接以允许其他人在您的共享相册中添加和查看照片,而无需应用程序或ente账户。 非常适合收集活动照片。"),
"collaborativeLink": MessageLookupByLibrary.simpleMessage("协作链接"),
"collaborativeLinkCreatedFor": m9,
"collaborator": MessageLookupByLibrary.simpleMessage("协作者"),
@ -408,7 +408,7 @@ class MessageLookup extends MessageLookupByLibrary {
"couldNotUpdateSubscription":
MessageLookupByLibrary.simpleMessage("无法升级订阅"),
"count": MessageLookupByLibrary.simpleMessage("计数"),
"crashReporting": MessageLookupByLibrary.simpleMessage("崩溃报告"),
"crashReporting": MessageLookupByLibrary.simpleMessage("上报崩溃"),
"create": MessageLookupByLibrary.simpleMessage("创建"),
"createAccount": MessageLookupByLibrary.simpleMessage("创建账户"),
"createAlbumActionHint":
@ -427,7 +427,7 @@ class MessageLookup extends MessageLookupByLibrary {
"dayYesterday": MessageLookupByLibrary.simpleMessage("昨天"),
"decrypting": MessageLookupByLibrary.simpleMessage("解密中..."),
"decryptingVideo": MessageLookupByLibrary.simpleMessage("正在解密视频..."),
"deduplicateFiles": MessageLookupByLibrary.simpleMessage("重复文件"),
"deduplicateFiles": MessageLookupByLibrary.simpleMessage("文件去重"),
"delete": MessageLookupByLibrary.simpleMessage("删除"),
"deleteAccount": MessageLookupByLibrary.simpleMessage("删除账户"),
"deleteAccountFeedbackPrompt":
@ -438,7 +438,7 @@ class MessageLookup extends MessageLookupByLibrary {
"deleteAlbumDialog": MessageLookupByLibrary.simpleMessage(
"也删除此相册中存在的照片(和视频),从 <bold>他们所加入的所有</bold> 其他相册?"),
"deleteAlbumsDialogBody": MessageLookupByLibrary.simpleMessage(
"这将删除所有空相册。 当您想减少相册列表的混乱时,这很有用。"),
"这将删除所有空相册。 当您想减少相册列表的混乱时,这很有用。"),
"deleteAll": MessageLookupByLibrary.simpleMessage("全部删除"),
"deleteConfirmDialogBody": MessageLookupByLibrary.simpleMessage(
"此账户已链接到其他 ente 旗下的应用程序(如果您使用任何 ente 旗下的应用程序)。\\n\\n您在所有 ente 旗下的应用程序中上传的数据将被安排删除,并且您的账户将被永久删除。"),
@ -456,7 +456,7 @@ class MessageLookup extends MessageLookupByLibrary {
"deleteProgress": m13,
"deleteReason1": MessageLookupByLibrary.simpleMessage("找不到我想要的功能"),
"deleteReason2":
MessageLookupByLibrary.simpleMessage("应用或某个功能不会有 行为。我认为它应该有的"),
MessageLookupByLibrary.simpleMessage("应用或某个功能没有按我的预期运行"),
"deleteReason3":
MessageLookupByLibrary.simpleMessage("我找到了另一个我喜欢更好的服务"),
"deleteReason4": MessageLookupByLibrary.simpleMessage("我的原因未被列出"),
@ -489,8 +489,8 @@ class MessageLookup extends MessageLookupByLibrary {
"discord": MessageLookupByLibrary.simpleMessage("Discord"),
"dismiss": MessageLookupByLibrary.simpleMessage("忽略"),
"distanceInKMUnit": MessageLookupByLibrary.simpleMessage("公里"),
"doNotSignOut": MessageLookupByLibrary.simpleMessage("不要退"),
"doThisLater": MessageLookupByLibrary.simpleMessage("稍后再"),
"doNotSignOut": MessageLookupByLibrary.simpleMessage("不要"),
"doThisLater": MessageLookupByLibrary.simpleMessage("稍后再"),
"doYouWantToDiscardTheEditsYouHaveMade":
MessageLookupByLibrary.simpleMessage("您想要放弃您所做的编辑吗?"),
"done": MessageLookupByLibrary.simpleMessage("已完成"),
@ -559,11 +559,11 @@ class MessageLookup extends MessageLookupByLibrary {
"exif": MessageLookupByLibrary.simpleMessage("EXIF"),
"existingUser": MessageLookupByLibrary.simpleMessage("现有用户"),
"expiredLinkInfo":
MessageLookupByLibrary.simpleMessage("此链接已过期。请选择新的过期时间或禁用链接期。"),
MessageLookupByLibrary.simpleMessage("此链接已过期。请选择新的过期时间或禁用链接有效期。"),
"exportLogs": MessageLookupByLibrary.simpleMessage("导出日志"),
"exportYourData": MessageLookupByLibrary.simpleMessage("导出您的数据"),
"faces": MessageLookupByLibrary.simpleMessage("人脸"),
"failedToApplyCode": MessageLookupByLibrary.simpleMessage("无法应用代码"),
"failedToApplyCode": MessageLookupByLibrary.simpleMessage("无法使用此代码"),
"failedToCancel": MessageLookupByLibrary.simpleMessage("取消失败"),
"failedToDownloadVideo": MessageLookupByLibrary.simpleMessage("视频下载失败"),
"failedToFetchOriginalForEdit":
@ -575,7 +575,7 @@ class MessageLookup extends MessageLookupByLibrary {
"failedToVerifyPaymentStatus":
MessageLookupByLibrary.simpleMessage("验证支付状态失败"),
"familyPlanOverview": MessageLookupByLibrary.simpleMessage(
"在您现有的计划中添加 5 名家庭成员无需支付额外费用。\n\n每个成员都有自己的私人空间,除非共享,否则无法看到彼此的文件。\n\n家庭计划适用于付费订阅的客户。\n\n立即订阅以开始使用!"),
"在您现有的计划中添加 5 名家庭成员无需支付额外费用。\n\n每个成员都有自己的私人空间,除非共享,否则无法看到彼此的文件。\n\n家庭计划适用于已有付费订阅的客户。\n\n立即订阅以开始使用!"),
"familyPlanPortalTitle": MessageLookupByLibrary.simpleMessage("家庭"),
"familyPlans": MessageLookupByLibrary.simpleMessage("家庭计划"),
"faq": MessageLookupByLibrary.simpleMessage("常见问题"),
@ -614,7 +614,7 @@ class MessageLookup extends MessageLookupByLibrary {
"goToSettings": MessageLookupByLibrary.simpleMessage("前往设置"),
"googlePlayId": MessageLookupByLibrary.simpleMessage("Google Play ID"),
"grantFullAccessPrompt":
MessageLookupByLibrary.simpleMessage("请在“设置”应用中将权限更改为允许访问所有所有照片"),
MessageLookupByLibrary.simpleMessage("请在手机“设置”中授权软件访问所有照片"),
"grantPermission": MessageLookupByLibrary.simpleMessage("授予权限"),
"groupNearbyPhotos": MessageLookupByLibrary.simpleMessage("将附近的照片分组"),
"hearUsExplanation": MessageLookupByLibrary.simpleMessage(
@ -629,9 +629,9 @@ class MessageLookup extends MessageLookupByLibrary {
"howToViewShareeVerificationID": MessageLookupByLibrary.simpleMessage(
"请让他们在设置屏幕上长按他们的电子邮件地址,并验证两台设备上的 ID 是否匹配。"),
"iOSGoToSettingsDescription": MessageLookupByLibrary.simpleMessage(
"未在您的设备上设置生物鉴别身份验证。请在您的手机上启用 Touch ID或Face ID。"),
"您未在该设备上设置生物识别身份验证。请在您的手机上启用 Touch ID或Face ID。"),
"iOSLockOut":
MessageLookupByLibrary.simpleMessage("生物别认证已禁用。请锁定并解锁您的屏幕以启用它。"),
MessageLookupByLibrary.simpleMessage("生物别认证已禁用。请锁定并解锁您的屏幕以启用它。"),
"iOSOkButton": MessageLookupByLibrary.simpleMessage("好的"),
"ignoreUpdate": MessageLookupByLibrary.simpleMessage("忽略"),
"ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage(
@ -673,6 +673,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("请帮助我们了解这个信息"),
"language": MessageLookupByLibrary.simpleMessage("语言"),
"lastUpdated": MessageLookupByLibrary.simpleMessage("最后更新"),
"launchPasskeyUrlAgain":
MessageLookupByLibrary.simpleMessage("再次启动 通行密钥 URL"),
"leave": MessageLookupByLibrary.simpleMessage("离开"),
"leaveAlbum": MessageLookupByLibrary.simpleMessage("离开相册"),
"leaveFamily": MessageLookupByLibrary.simpleMessage("离开家庭计划"),
@ -691,9 +693,9 @@ class MessageLookup extends MessageLookupByLibrary {
"livePhotos": MessageLookupByLibrary.simpleMessage("实况照片"),
"loadMessage1": MessageLookupByLibrary.simpleMessage("您可以与家庭分享您的订阅"),
"loadMessage2":
MessageLookupByLibrary.simpleMessage("到目前为止,我们已经保存了1 000多万个回忆"),
MessageLookupByLibrary.simpleMessage("到目前为止,我们已经保存了超过3 000万个回忆"),
"loadMessage3":
MessageLookupByLibrary.simpleMessage("我们保存你的3个数据副本一个在地下安全屋中"),
MessageLookupByLibrary.simpleMessage("我们保存你的3个数据副本其中一个在地下安全屋中"),
"loadMessage4": MessageLookupByLibrary.simpleMessage("我们所有的应用程序都是开源的"),
"loadMessage5":
MessageLookupByLibrary.simpleMessage("我们的源代码和加密技术已经由外部审计"),
@ -722,7 +724,7 @@ class MessageLookup extends MessageLookupByLibrary {
"logInLabel": MessageLookupByLibrary.simpleMessage("登录"),
"loggingOut": MessageLookupByLibrary.simpleMessage("正在退出登录..."),
"loginTerms": MessageLookupByLibrary.simpleMessage(
"点击登录后,我同意 <u-terms>服务条款</u-terms> 和 <u-policy>隐私政策</u-policy>"),
"点击登录时,默认我同意 <u-terms>服务条款</u-terms> 和 <u-policy>隐私政策</u-policy>"),
"logout": MessageLookupByLibrary.simpleMessage("退出登录"),
"logsDialogBody": MessageLookupByLibrary.simpleMessage(
"这将跨日志发送以帮助我们调试您的问题。 请注意,将包含文件名以帮助跟踪特定文件的问题。"),
@ -732,7 +734,7 @@ class MessageLookup extends MessageLookupByLibrary {
"machineLearning": MessageLookupByLibrary.simpleMessage("机器学习"),
"magicSearch": MessageLookupByLibrary.simpleMessage("魔法搜索"),
"magicSearchDescription": MessageLookupByLibrary.simpleMessage(
"使用我们的桌面应用程序来为您库中的待处理项目建立索引"),
"注意,在所有项目完成索引之前,这将使用更高的带宽和电量"),
"manage": MessageLookupByLibrary.simpleMessage("管理"),
"manageDeviceStorage": MessageLookupByLibrary.simpleMessage("管理设备存储"),
"manageFamily": MessageLookupByLibrary.simpleMessage("管理家庭计划"),
@ -811,13 +813,15 @@ class MessageLookup extends MessageLookupByLibrary {
"orPickAnExistingOne":
MessageLookupByLibrary.simpleMessage("或者选择一个现有的"),
"pair": MessageLookupByLibrary.simpleMessage("配对"),
"passkey": MessageLookupByLibrary.simpleMessage("通行密钥"),
"passkeyAuthTitle": MessageLookupByLibrary.simpleMessage("通行密钥认证"),
"password": MessageLookupByLibrary.simpleMessage("密码"),
"passwordChangedSuccessfully":
MessageLookupByLibrary.simpleMessage("密码修改成功"),
"passwordLock": MessageLookupByLibrary.simpleMessage("密码锁"),
"passwordStrength": m34,
"passwordWarning": MessageLookupByLibrary.simpleMessage(
"我们不储存这个密码,所以如果忘记, <underline>我们不能解密您的数据</underline>"),
"我们不储存这个密码,所以如果忘记, <underline>我们将无法解密您的数据</underline>"),
"paymentDetails": MessageLookupByLibrary.simpleMessage("付款明细"),
"paymentFailed": MessageLookupByLibrary.simpleMessage("支付失败"),
"paymentFailedTalkToProvider": m35,
@ -896,11 +900,11 @@ class MessageLookup extends MessageLookupByLibrary {
"如果您忘记了您的密码,您的恢复密钥是恢复您的照片的唯一途径。 您可以在“设置 > 账户”中找到您的恢复密钥。\n\n请在此输入您的恢复密钥以确认您已经正确地保存了它。"),
"recoverySuccessful": MessageLookupByLibrary.simpleMessage("恢复成功!"),
"recreatePasswordBody": MessageLookupByLibrary.simpleMessage(
"当前设备的功能不足以验证您的密码,但我们可以以适用于所有设备的方式重新生成。\n\n请使用您的恢复密钥登录并重新生成您的密码(如果您愿意,可以再次使用相同的密码)。"),
"当前设备的功能不足以验证您的密码,但我们可以以适用于所有设备的方式重新生成。\n\n请使用您的恢复密钥登录并重新生成您的密码(如果您希望,可以再次使用相同的密码)。"),
"recreatePasswordTitle": MessageLookupByLibrary.simpleMessage("重新创建密码"),
"reddit": MessageLookupByLibrary.simpleMessage("Reddit"),
"referFriendsAnd2xYourPlan":
MessageLookupByLibrary.simpleMessage("推荐朋友和 2 倍您的计划"),
MessageLookupByLibrary.simpleMessage("把我们推荐给你的朋友然后获得延长一倍的订阅计划"),
"referralStep1": MessageLookupByLibrary.simpleMessage("1. 将此代码提供给您的朋友"),
"referralStep2": MessageLookupByLibrary.simpleMessage("2. 他们注册一个付费计划"),
"referralStep3": m41,
@ -911,9 +915,9 @@ class MessageLookup extends MessageLookupByLibrary {
"同时从“设置”->“存储”中清空“最近删除”以领取释放的空间"),
"remindToEmptyEnteTrash":
MessageLookupByLibrary.simpleMessage("同时清空您的“回收站”以领取释放的空间"),
"remoteImages": MessageLookupByLibrary.simpleMessage("远程图像"),
"remoteThumbnails": MessageLookupByLibrary.simpleMessage("远程缩略图"),
"remoteVideos": MessageLookupByLibrary.simpleMessage("远程视频"),
"remoteImages": MessageLookupByLibrary.simpleMessage("云端图像"),
"remoteThumbnails": MessageLookupByLibrary.simpleMessage("云端缩略图"),
"remoteVideos": MessageLookupByLibrary.simpleMessage("云端视频"),
"remove": MessageLookupByLibrary.simpleMessage("移除"),
"removeDuplicates": MessageLookupByLibrary.simpleMessage("移除重复内容"),
"removeFromAlbum": MessageLookupByLibrary.simpleMessage("从相册中移除"),
@ -954,9 +958,9 @@ class MessageLookup extends MessageLookupByLibrary {
"saveCopy": MessageLookupByLibrary.simpleMessage("保存副本"),
"saveKey": MessageLookupByLibrary.simpleMessage("保存密钥"),
"saveYourRecoveryKeyIfYouHaventAlready":
MessageLookupByLibrary.simpleMessage("如果你还没有就请保存你的恢复密钥"),
MessageLookupByLibrary.simpleMessage("若您尚未保存,请妥善保存此恢复密钥"),
"saving": MessageLookupByLibrary.simpleMessage("正在保存..."),
"scanCode": MessageLookupByLibrary.simpleMessage("扫描"),
"scanCode": MessageLookupByLibrary.simpleMessage("扫描二维码/条"),
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage("用您的身份验证器应用\n扫描此条码"),
"searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("相册"),
@ -1058,7 +1062,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("它将从所有相册中删除。"),
"singleFileInBothLocalAndRemote": m53,
"singleFileInRemoteOnly": m54,
"skip": MessageLookupByLibrary.simpleMessage(""),
"skip": MessageLookupByLibrary.simpleMessage(""),
"social": MessageLookupByLibrary.simpleMessage("社交"),
"someItemsAreInBothEnteAndYourDevice":
MessageLookupByLibrary.simpleMessage("有些项目既在ente 也在您的设备中。"),
@ -1109,7 +1113,7 @@ class MessageLookup extends MessageLookupByLibrary {
"syncProgress": m59,
"syncStopped": MessageLookupByLibrary.simpleMessage("同步已停止"),
"syncing": MessageLookupByLibrary.simpleMessage("正在同步···"),
"systemTheme": MessageLookupByLibrary.simpleMessage("系统"),
"systemTheme": MessageLookupByLibrary.simpleMessage("适应系统"),
"tapToCopy": MessageLookupByLibrary.simpleMessage("点击以复制"),
"tapToEnterCode": MessageLookupByLibrary.simpleMessage("点击以输入代码"),
"tempErrorContactSupportIfPersists":
@ -1137,7 +1141,7 @@ class MessageLookup extends MessageLookupByLibrary {
"thisAlbumAlreadyHDACollaborativeLink":
MessageLookupByLibrary.simpleMessage("此相册已经有一个协作链接"),
"thisCanBeUsedToRecoverYourAccountIfYou":
MessageLookupByLibrary.simpleMessage("如果您丢失了双因素,这可以用来恢复您的账户"),
MessageLookupByLibrary.simpleMessage("如果您丢失了双因素验证方式,这可以用来恢复您的账户"),
"thisDevice": MessageLookupByLibrary.simpleMessage("此设备"),
"thisEmailIsAlreadyInUse":
MessageLookupByLibrary.simpleMessage("这个邮箱地址已经被使用"),

View file

@ -8308,11 +8308,11 @@ class S {
);
}
/// `Waiting for browser request...`
String get waitingForBrowserRequest {
/// `Waiting for verification...`
String get waitingForVerification {
return Intl.message(
'Waiting for browser request...',
name: 'waitingForBrowserRequest',
'Waiting for verification...',
name: 'waitingForVerification',
desc: '',
args: [],
);
@ -8338,16 +8338,26 @@ class S {
);
}
/// `Passkey authentication`
/// `Passkey verification`
String get passkeyAuthTitle {
return Intl.message(
'Passkey authentication',
'Passkey verification',
name: 'passkeyAuthTitle',
desc: '',
args: [],
);
}
/// `Verify passkey`
String get verifyPasskey {
return Intl.message(
'Verify passkey',
name: 'verifyPasskey',
desc: '',
args: [],
);
}
/// `Play album on TV`
String get playOnTv {
return Intl.message(

View file

@ -1188,10 +1188,11 @@
"changeLocationOfSelectedItems": "Change location of selected items?",
"editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
"cleanUncategorized": "Clean Uncategorized",
"waitingForBrowserRequest": "Waiting for browser request...",
"waitingForVerification": "Waiting for verification...",
"launchPasskeyUrlAgain": "Launch passkey URL again",
"passkey": "Passkey",
"passkeyAuthTitle": "Passkey authentication",
"passkeyAuthTitle": "Passkey verification",
"verifyPasskey": "Verify passkey",
"playOnTv": "Play album on TV",
"pair": "Pair",
"deviceNotFound": "Device not found",

View file

@ -0,0 +1,13 @@
enum TwoFactorType { totp, passkey }
// ToString for TwoFactorType
String twoFactorTypeToString(TwoFactorType type) {
switch (type) {
case TwoFactorType.totp:
return "totp";
case TwoFactorType.passkey:
return "passkey";
default:
return type.name;
}
}

View file

@ -17,6 +17,28 @@ class PasskeyService {
return response.data!["accountsToken"] as String;
}
Future<bool> isPasskeyRecoveryEnabled() async {
final response = await _enteDio.get(
"/users/two-factor/recovery-status",
);
return response.data!["isPasskeyRecoveryEnabled"] as bool;
}
Future<void> configurePasskeyRecovery(
String secret,
String userEncryptedSecret,
String userSecretNonce,
) async {
await _enteDio.post(
"/users/two-factor/passkeys/configure-recovery",
data: {
"secret": secret,
"userSecretCipher": userEncryptedSecret,
"userSecretNonce": userSecretNonce,
},
);
}
Future<void> openPasskeyPage(BuildContext context) async {
try {
final jwtToken = await getJwtToken();

View file

@ -16,6 +16,7 @@ import "package:photos/events/account_configured_event.dart";
import 'package:photos/events/two_factor_status_change_event.dart';
import 'package:photos/events/user_details_changed_event.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/models/account/two_factor.dart";
import "package:photos/models/api/user/srp.dart";
import 'package:photos/models/delete_account.dart';
import 'package:photos/models/key_attributes.dart';
@ -807,7 +808,11 @@ class UserService {
}
}
Future<void> recoverTwoFactor(BuildContext context, String sessionID) async {
Future<void> recoverTwoFactor(
BuildContext context,
String sessionID,
TwoFactorType type,
) async {
final dialog = createProgressDialog(context, S.of(context).pleaseWait);
await dialog.show();
try {
@ -815,6 +820,7 @@ class UserService {
_config.getHttpEndpoint() + "/users/two-factor/recover",
queryParameters: {
"sessionID": sessionID,
"twoFactorType": twoFactorTypeToString(type),
},
);
if (response.statusCode == 200) {
@ -823,6 +829,7 @@ class UserService {
MaterialPageRoute(
builder: (BuildContext context) {
return TwoFactorRecoveryPage(
type,
sessionID,
response.data["encryptedSecret"],
response.data["secretDecryptionNonce"],
@ -833,6 +840,7 @@ class UserService {
);
}
} on DioError catch (e) {
await dialog.hide();
_logger.severe(e);
if (e.response != null && e.response!.statusCode == 404) {
showToast(context, S.of(context).sessionExpired);
@ -854,6 +862,7 @@ class UserService {
);
}
} catch (e) {
await dialog.hide();
_logger.severe(e);
// ignore: unawaited_futures
showErrorDialog(
@ -868,6 +877,7 @@ class UserService {
Future<void> removeTwoFactor(
BuildContext context,
TwoFactorType type,
String sessionID,
String recoveryKey,
String encryptedSecret,
@ -906,7 +916,8 @@ class UserService {
_config.getHttpEndpoint() + "/users/two-factor/remove",
data: {
"sessionID": sessionID,
"secret": secret,
"secret": utf8.decode(base64.decode(secret)),
"twoFactorType": twoFactorTypeToString(type),
},
);
if (response.statusCode == 200) {
@ -926,7 +937,8 @@ class UserService {
);
}
} on DioError catch (e) {
_logger.severe(e);
await dialog.hide();
_logger.severe("error during recovery", e);
if (e.response != null && e.response!.statusCode == 404) {
showToast(context, "Session expired");
// ignore: unawaited_futures
@ -947,7 +959,9 @@ class UserService {
);
}
} catch (e) {
_logger.severe(e);
await dialog.hide();
_logger.severe('unexpcted error during recovery', e);
// ignore: unawaited_futures
showErrorDialog(
context,

View file

@ -3,9 +3,11 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/ente_theme_data.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/models/account/two_factor.dart";
import 'package:photos/services/user_service.dart';
import "package:photos/ui/components/buttons/button_widget.dart";
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/utils/dialog_util.dart";
import 'package:uni_links/uni_links.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -99,27 +101,49 @@ class _PasskeyPageState extends State<PasskeyPage> {
Widget _getBody() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
S.of(context).waitingForBrowserRequest,
style: const TextStyle(
height: 1.4,
fontSize: 16,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
S.of(context).waitingForVerification,
style: const TextStyle(
height: 1.4,
fontSize: 16,
),
),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 32),
child: ElevatedButton(
style: Theme.of(context).colorScheme.optionalActionButtonStyle,
onPressed: launchPasskey,
child: Text(S.of(context).launchPasskeyUrlAgain),
const SizedBox(height: 16),
ButtonWidget(
buttonType: ButtonType.primary,
labelText: S.of(context).verifyPasskey,
onTap: () => launchPasskey(),
),
),
],
const Padding(padding: EdgeInsets.all(30)),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
UserService.instance.recoverTwoFactor(
context,
widget.sessionID,
TwoFactorType.passkey,
);
},
child: Container(
padding: const EdgeInsets.all(10),
child: Center(
child: Text(
S.of(context).recoverAccount,
style: const TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,
),
),
),
),
),
],
),
),
);
}

View file

@ -16,8 +16,11 @@ class RequestPasswordVerificationPage extends StatefulWidget {
final OnPasswordVerifiedFn onPasswordVerified;
final Function? onPasswordError;
const RequestPasswordVerificationPage(
{super.key, required this.onPasswordVerified, this.onPasswordError,});
const RequestPasswordVerificationPage({
super.key,
required this.onPasswordVerified,
this.onPasswordError,
});
@override
State<RequestPasswordVerificationPage> createState() =>

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/models/account/two_factor.dart";
import 'package:photos/services/user_service.dart';
import 'package:photos/ui/lifecycle_event_handler.dart';
import 'package:pinput/pin_put/pin_put.dart';
@ -124,7 +125,11 @@ class _TwoFactorAuthenticationPageState
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
UserService.instance.recoverTwoFactor(context, widget.sessionID);
UserService.instance.recoverTwoFactor(
context,
widget.sessionID,
TwoFactorType.totp,
);
},
child: Container(
padding: const EdgeInsets.all(10),

View file

@ -2,6 +2,7 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/models/account/two_factor.dart";
import 'package:photos/services/user_service.dart';
import 'package:photos/utils/dialog_util.dart';
@ -9,8 +10,10 @@ class TwoFactorRecoveryPage extends StatefulWidget {
final String sessionID;
final String encryptedSecret;
final String secretDecryptionNonce;
final TwoFactorType type;
const TwoFactorRecoveryPage(
this.type,
this.sessionID,
this.encryptedSecret,
this.secretDecryptionNonce, {
@ -71,6 +74,7 @@ class _TwoFactorRecoveryPageState extends State<TwoFactorRecoveryPage> {
? () async {
await UserService.instance.removeTwoFactor(
context,
widget.type,
widget.sessionID,
_recoveryKey.text,
widget.encryptedSecret,

View file

@ -1,7 +1,9 @@
import 'dart:async';
import "dart:convert";
import "dart:typed_data";
import 'package:flutter/material.dart';
import "package:logging/logging.dart";
import 'package:photos/core/configuration.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/ente_theme_data.dart';
@ -22,8 +24,10 @@ import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import 'package:photos/ui/components/toggle_switch_widget.dart';
import 'package:photos/ui/settings/common_settings.dart';
import "package:photos/utils/crypto_util.dart";
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/navigation_util.dart";
import "package:photos/utils/toast_util.dart";
import "package:uuid/uuid.dart";
class SecuritySectionWidget extends StatefulWidget {
const SecuritySectionWidget({Key? key}) : super(key: key);
@ -37,7 +41,7 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
late StreamSubscription<TwoFactorStatusChangeEvent>
_twoFactorStatusChangeEvent;
final Logger _logger = Logger('SecuritySectionWidget');
@override
void initState() {
super.initState();
@ -110,7 +114,7 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () => PasskeyService.instance.openPasskeyPage(context),
onTap: () async => await onPasskeyClick(context),
),
sectionOptionSpacing,
MenuItemWidget(
@ -232,6 +236,33 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
);
}
Future<void> onPasskeyClick(BuildContext buildContext) async {
try {
final isPassKeyResetEnabled =
await PasskeyService.instance.isPasskeyRecoveryEnabled();
if (!isPassKeyResetEnabled) {
final Uint8List recoveryKey =
await UserService.instance.getOrCreateRecoveryKey(context);
final resetSecret = const Uuid().v4().toString();
final bytes = utf8.encode(resetSecret);
final base64Str = base64.encode(bytes);
final encryptionResult = CryptoUtil.encryptSync(
CryptoUtil.base642bin(base64Str),
recoveryKey,
);
await PasskeyService.instance.configurePasskeyRecovery(
resetSecret,
CryptoUtil.bin2base64(encryptionResult.encryptedData!),
CryptoUtil.bin2base64(encryptionResult.nonce!),
);
}
PasskeyService.instance.openPasskeyPage(buildContext).ignore();
} catch (e, s) {
_logger.severe("failed to open passkey page", e, s);
await showGenericErrorDialog(context: context, error: e);
}
}
Future<void> updateEmailMFA(bool isEnabled) async {
try {
final UserDetails details =

View file

@ -41,8 +41,7 @@ class ZoomableImage extends StatefulWidget {
State<ZoomableImage> createState() => _ZoomableImageState();
}
class _ZoomableImageState extends State<ZoomableImage>
with SingleTickerProviderStateMixin {
class _ZoomableImageState extends State<ZoomableImage> {
late Logger _logger;
late EnteFile _photo;
ImageProvider? _imageProvider;
@ -54,6 +53,7 @@ class _ZoomableImageState extends State<ZoomableImage>
ValueChanged<PhotoViewScaleState>? _scaleStateChangedCallback;
bool _isZooming = false;
PhotoViewController _photoViewController = PhotoViewController();
final _scaleStateController = PhotoViewScaleStateController();
@override
void initState() {
@ -74,6 +74,7 @@ class _ZoomableImageState extends State<ZoomableImage>
@override
void dispose() {
_photoViewController.dispose();
_scaleStateController.dispose();
super.dispose();
}
@ -90,8 +91,10 @@ class _ZoomableImageState extends State<ZoomableImage>
content = PhotoViewGestureDetectorScope(
axis: Axis.vertical,
child: PhotoView(
key: ValueKey(_loadedFinalImage),
imageProvider: _imageProvider,
controller: _photoViewController,
scaleStateController: _scaleStateController,
scaleStateChangedCallback: _scaleStateChangedCallback,
minScale: widget.shouldCover
? PhotoViewComputedScale.covered
@ -272,15 +275,13 @@ class _ZoomableImageState extends State<ZoomableImage>
final scale = _photoViewController.scale! /
(finalImageInfo.image.width / prevImageInfo.image.width);
final currentPosition = _photoViewController.value.position;
final positionScaleFactor = 1 / scale;
final newPosition = currentPosition.scale(
positionScaleFactor,
positionScaleFactor,
);
_photoViewController = PhotoViewController(
initialPosition: newPosition,
initialPosition: currentPosition,
initialScale: scale,
);
// Fix for auto-zooming when final image is loaded after double tapping
//twice.
_scaleStateController.scaleState = PhotoViewScaleState.zoomedIn;
}
final bool canUpdateMetadata = _photo.canEditMetaInfo;
// forcefully get finalImageInfo is dimensions are not available in metadata

View file

@ -14,6 +14,8 @@ import (
"syscall"
"time"
"github.com/ente-io/museum/pkg/repo/two_factor_recovery"
"github.com/ente-io/museum/pkg/controller/cast"
"github.com/ente-io/museum/pkg/controller/commonbilling"
@ -137,6 +139,7 @@ func main() {
twoFactorRepo := &repo.TwoFactorRepository{DB: db, SecretEncryptionKey: secretEncryptionKeyBytes}
userAuthRepo := &repo.UserAuthRepository{DB: db}
twoFactorRecoveryRepo := &two_factor_recovery.Repository{Db: db, SecretEncryptionKey: secretEncryptionKeyBytes}
billingRepo := &repo.BillingRepository{DB: db}
userEntityRepo := &userEntityRepo.Repository{DB: db}
locationTagRepository := &locationtagRepo.Repository{DB: db}
@ -304,6 +307,7 @@ func main() {
usageRepo,
userAuthRepo,
twoFactorRepo,
twoFactorRecoveryRepo,
passkeysRepo,
storagBonusRepo,
fileRepo,
@ -429,6 +433,8 @@ func main() {
publicAPI.POST("/users/two-factor/remove", userHandler.RemoveTwoFactor)
publicAPI.POST("/users/two-factor/passkeys/begin", userHandler.BeginPasskeyAuthenticationCeremony)
publicAPI.POST("/users/two-factor/passkeys/finish", userHandler.FinishPasskeyAuthenticationCeremony)
privateAPI.GET("/users/two-factor/recovery-status", userHandler.GetTwoFactorRecoveryStatus)
privateAPI.POST("/users/two-factor/passkeys/configure-recovery", userHandler.ConfigurePasskeyRecovery)
privateAPI.GET("/users/two-factor/status", userHandler.GetTwoFactorStatus)
privateAPI.POST("/users/two-factor/setup", userHandler.SetupTwoFactor)
privateAPI.POST("/users/two-factor/enable", userHandler.EnableTwoFactor)

View file

@ -12,3 +12,19 @@ type Passkey struct {
}
var MaxPasskeys = 10
type SetPasskeyRecoveryRequest struct {
Secret uuid.UUID `json:"secret" binding:"required"`
// The UserSecretCipher has SkipSecret encrypted with the user's recoveryKey
// If the user sends the correct UserSecretCipher, we can be sure that the user has the recoveryKey,
// and we can allow the user to recover their MFA.
UserSecretCipher string `json:"userSecretCipher" binding:"required"`
UserSecretNonce string `json:"userSecretNonce" binding:"required"`
}
type TwoFactorRecoveryStatus struct {
// AllowAdminReset is a boolean that determines if the admin can reset the user's MFA.
// If true, in the event that the user loses their MFA device, the admin can reset the user's MFA.
AllowAdminReset bool `json:"allowAdminReset" binding:"required"`
IsPasskeyRecoveryEnabled bool `json:"isPasskeyRecoveryEnabled" binding:"required"`
}

View file

@ -192,8 +192,9 @@ type TwoFactorRecoveryResponse struct {
// TwoFactorRemovalRequest represents the the body of two factor removal request consist of decrypted two factor secret and sessionID
type TwoFactorRemovalRequest struct {
Secret string `json:"secret"`
SessionID string `json:"sessionID"`
Secret string `json:"secret"`
SessionID string `json:"sessionID"`
TwoFactorType string `json:"twoFactorType"`
}
type ProfileData struct {

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS two_factor_recovery;
DROP TRIGGER IF EXISTS update_two_factor_recovery_updated_at ON two_factor_recovery;

View file

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS two_factor_recovery (
user_id bigint NOT NULL PRIMARY KEY,
-- if false, the support team team will not be able to reset the MFA for the user
enable_admin_mfa_reset boolean NOT NULL DEFAULT true,
server_passkey_secret_data bytea,
server_passkey_secret_nonce bytea,
user_passkey_secret_data text,
user_passkey_secret_nonce text,
created_at bigint NOT NULL DEFAULT now_utc_micro_seconds(),
updated_at bigint NOT NULL DEFAULT now_utc_micro_seconds()
);
CREATE TRIGGER update_two_factor_recovery_updated_at
BEFORE UPDATE
ON two_factor_recovery
FOR EACH ROW
EXECUTE PROCEDURE
trigger_updated_at_microseconds_column();

View file

@ -244,6 +244,31 @@ func (h *UserHandler) GetTwoFactorStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": status})
}
func (h *UserHandler) GetTwoFactorRecoveryStatus(c *gin.Context) {
res, err := h.UserController.GetTwoFactorRecoveryStatus(c)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, res)
}
// ConfigurePasskeyRecovery configures the passkey skip challenge for a user. In case the user does not
// have access to passkey, the user can bypass the passkey by providing the recovery key
func (h *UserHandler) ConfigurePasskeyRecovery(c *gin.Context) {
var request ente.SetPasskeyRecoveryRequest
if err := c.ShouldBindJSON(&request); err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
err := h.UserController.ConfigurePasskeyRecovery(c, &request)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, gin.H{})
}
// SetupTwoFactor generates a two factor secret and sends it to user to setup his authenticator app with
func (h *UserHandler) SetupTwoFactor(c *gin.Context) {
userID := auth.GetUserID(c.Request.Header)
@ -352,6 +377,16 @@ func (h *UserHandler) FinishPasskeyAuthenticationCeremony(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
func (h *UserHandler) IsPasskeyRecoveryEnabled(c *gin.Context) {
userID := auth.GetUserID(c.Request.Header)
response, err := h.UserController.GetKeyAttributeAndToken(c, userID)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, response)
}
// DisableTwoFactor disables the two factor authentication for a user
func (h *UserHandler) DisableTwoFactor(c *gin.Context) {
userID := auth.GetUserID(c.Request.Header)
@ -367,7 +402,14 @@ func (h *UserHandler) DisableTwoFactor(c *gin.Context) {
// recoveryKeyEncryptedTwoFactorSecret for the user to decrypt it and make twoFactor removal api call
func (h *UserHandler) RecoverTwoFactor(c *gin.Context) {
sessionID := c.Query("sessionID")
response, err := h.UserController.RecoverTwoFactor(sessionID)
twoFactorType := c.Query("twoFactorType")
var response *ente.TwoFactorRecoveryResponse
var err error
if twoFactorType == "passkey" {
response, err = h.UserController.GetPasskeyRecoveryResponse(c, sessionID)
} else {
response, err = h.UserController.RecoverTwoFactor(sessionID)
}
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
@ -383,7 +425,13 @@ func (h *UserHandler) RemoveTwoFactor(c *gin.Context) {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
response, err := h.UserController.RemoveTwoFactor(c, request.SessionID, request.Secret)
var response *ente.TwoFactorAuthorizationResponse
var err error
if request.TwoFactorType == "passkey" {
response, err = h.UserController.SkipPasskeyVerification(c, &request)
} else {
response, err = h.UserController.RemoveTOTPTwoFactor(c, request.SessionID, request.Secret)
}
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return

View file

@ -0,0 +1,61 @@
package user
import (
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/ente-io/stacktrace"
"github.com/gin-gonic/gin"
)
// GetTwoFactorRecoveryStatus returns a user's passkey reset status
func (c *UserController) GetTwoFactorRecoveryStatus(ctx *gin.Context) (*ente.TwoFactorRecoveryStatus, error) {
userID := auth.GetUserID(ctx.Request.Header)
return c.TwoFactorRecoveryRepo.GetStatus(userID)
}
func (c *UserController) ConfigurePasskeyRecovery(ctx *gin.Context, req *ente.SetPasskeyRecoveryRequest) error {
userID := auth.GetUserID(ctx.Request.Header)
return c.TwoFactorRecoveryRepo.SetPasskeyRecovery(ctx, userID, req)
}
func (c *UserController) GetPasskeyRecoveryResponse(ctx *gin.Context, passKeySessionID string) (*ente.TwoFactorRecoveryResponse, error) {
userID, err := c.PasskeyRepo.GetUserIDWithPasskeyTwoFactorSession(passKeySessionID)
if err != nil {
return nil, err
}
recoveryStatus, err := c.TwoFactorRecoveryRepo.GetStatus(userID)
if err != nil {
return nil, err
}
if !recoveryStatus.IsPasskeyRecoveryEnabled {
return nil, ente.NewBadRequestWithMessage("Passkey reset is not configured")
}
result, err := c.TwoFactorRecoveryRepo.GetPasskeyRecoveryData(ctx, userID)
if err != nil {
return nil, err
}
if result == nil {
return nil, ente.NewBadRequestWithMessage("Passkey reset is not configured")
}
return result, nil
}
func (c *UserController) SkipPasskeyVerification(context *gin.Context, req *ente.TwoFactorRemovalRequest) (*ente.TwoFactorAuthorizationResponse, error) {
userID, err := c.PasskeyRepo.GetUserIDWithPasskeyTwoFactorSession(req.SessionID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
exists, err := c.TwoFactorRecoveryRepo.ValidatePasskeyRecoverySecret(userID, req.Secret)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if !exists {
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
response, err := c.GetKeyAttributeAndToken(context, userID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return &response, nil
}

View file

@ -131,47 +131,47 @@ func (c *UserController) DisableTwoFactor(userID int64) error {
// RecoverTwoFactor handles the two factor recovery request by sending the
// recoveryKeyEncryptedTwoFactorSecret for the user to decrypt it and make twoFactor removal api call
func (c *UserController) RecoverTwoFactor(sessionID string) (ente.TwoFactorRecoveryResponse, error) {
func (c *UserController) RecoverTwoFactor(sessionID string) (*ente.TwoFactorRecoveryResponse, error) {
userID, err := c.TwoFactorRepo.GetUserIDWithTwoFactorSession(sessionID)
if err != nil {
return ente.TwoFactorRecoveryResponse{}, stacktrace.Propagate(err, "")
return nil, stacktrace.Propagate(err, "")
}
response, err := c.TwoFactorRepo.GetRecoveryKeyEncryptedTwoFactorSecret(userID)
if err != nil {
return ente.TwoFactorRecoveryResponse{}, stacktrace.Propagate(err, "")
return nil, stacktrace.Propagate(err, "")
}
return response, nil
return &response, nil
}
// RemoveTwoFactor handles two factor deactivation request if user lost his device
// RemoveTOTPTwoFactor handles two factor deactivation request if user lost his device
// by authenticating him using his twoFactorsessionToken and twoFactor secret
func (c *UserController) RemoveTwoFactor(context *gin.Context, sessionID string, secret string) (ente.TwoFactorAuthorizationResponse, error) {
func (c *UserController) RemoveTOTPTwoFactor(context *gin.Context, sessionID string, secret string) (*ente.TwoFactorAuthorizationResponse, error) {
userID, err := c.TwoFactorRepo.GetUserIDWithTwoFactorSession(sessionID)
if err != nil {
return ente.TwoFactorAuthorizationResponse{}, stacktrace.Propagate(err, "")
return nil, stacktrace.Propagate(err, "")
}
secretHash, err := crypto.GetHash(secret, c.HashingKey)
if err != nil {
return ente.TwoFactorAuthorizationResponse{}, stacktrace.Propagate(err, "")
return nil, stacktrace.Propagate(err, "")
}
exists, err := c.TwoFactorRepo.VerifyTwoFactorSecret(userID, secretHash)
if err != nil {
return ente.TwoFactorAuthorizationResponse{}, stacktrace.Propagate(err, "")
return nil, stacktrace.Propagate(err, "")
}
if !exists {
return ente.TwoFactorAuthorizationResponse{}, stacktrace.Propagate(ente.ErrPermissionDenied, "")
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
err = c.TwoFactorRepo.UpdateTwoFactorStatus(userID, false)
if err != nil {
return ente.TwoFactorAuthorizationResponse{}, stacktrace.Propagate(err, "")
return nil, stacktrace.Propagate(err, "")
}
response, err := c.GetKeyAttributeAndToken(context, userID)
if err != nil {
return ente.TwoFactorAuthorizationResponse{}, stacktrace.Propagate(err, "")
return nil, stacktrace.Propagate(err, "")
}
return response, nil
return &response, nil
}
func (c *UserController) GetKeyAttributeAndToken(context *gin.Context, userID int64) (ente.TwoFactorAuthorizationResponse, error) {

View file

@ -3,6 +3,7 @@ package user
import (
"errors"
"fmt"
"github.com/ente-io/museum/pkg/repo/two_factor_recovery"
"strings"
cache2 "github.com/ente-io/museum/ente/cache"
@ -30,6 +31,7 @@ import (
// UserController exposes request handlers for all user related requests
type UserController struct {
UserRepo *repo.UserRepository
TwoFactorRecoveryRepo *two_factor_recovery.Repository
UsageRepo *repo.UsageRepository
UserAuthRepo *repo.UserAuthRepository
TwoFactorRepo *repo.TwoFactorRepository
@ -99,6 +101,7 @@ func NewUserController(
usageRepo *repo.UsageRepository,
userAuthRepo *repo.UserAuthRepository,
twoFactorRepo *repo.TwoFactorRepository,
twoFactorRecoveryRepo *two_factor_recovery.Repository,
passkeyRepo *passkey.Repository,
storageBonusRepo *storageBonusRepo.Repository,
fileRepo *repo.FileRepository,
@ -121,6 +124,7 @@ func NewUserController(
return &UserController{
UserRepo: userRepo,
UsageRepo: usageRepo,
TwoFactorRecoveryRepo: twoFactorRecoveryRepo,
UserAuthRepo: userAuthRepo,
StorageBonusRepo: storageBonusRepo,
TwoFactorRepo: twoFactorRepo,

View file

@ -0,0 +1,80 @@
package two_factor_recovery
import (
"context"
"database/sql"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/utils/crypto"
"github.com/ente-io/stacktrace"
"github.com/sirupsen/logrus"
)
type Repository struct {
Db *sql.DB
SecretEncryptionKey []byte
}
// GetStatus returns `ente.TwoFactorRecoveryStatus` for a user
func (r *Repository) GetStatus(userID int64) (*ente.TwoFactorRecoveryStatus, error) {
var isAdminResetEnabled bool
var resetKey []byte
row := r.Db.QueryRow(`SELECT enable_admin_mfa_reset, server_passkey_secret_data FROM two_factor_recovery WHERE user_id = $1`, userID)
err := row.Scan(&isAdminResetEnabled, &resetKey)
if err != nil {
if err == sql.ErrNoRows {
// by default, admin
return &ente.TwoFactorRecoveryStatus{
AllowAdminReset: true,
IsPasskeyRecoveryEnabled: false,
}, nil
}
return nil, err
}
return &ente.TwoFactorRecoveryStatus{AllowAdminReset: isAdminResetEnabled, IsPasskeyRecoveryEnabled: len(resetKey) > 0}, nil
}
func (r *Repository) SetPasskeyRecovery(ctx context.Context, userID int64, req *ente.SetPasskeyRecoveryRequest) error {
serveEncPasskey, encErr := crypto.Encrypt(req.Secret.String(), r.SecretEncryptionKey)
if encErr != nil {
return stacktrace.Propagate(encErr, "failed to encrypt passkey secret")
}
_, err := r.Db.ExecContext(ctx, `INSERT INTO two_factor_recovery
(user_id, server_passkey_secret_data, server_passkey_secret_nonce, user_passkey_secret_data, user_passkey_secret_nonce)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id)
DO UPDATE SET server_passkey_secret_data = $2, server_passkey_secret_nonce = $3, user_passkey_secret_data = $4, user_passkey_secret_nonce = $5
WHERE two_factor_recovery.user_passkey_secret_data IS NULL AND two_factor_recovery.server_passkey_secret_data IS NULL`,
userID, serveEncPasskey.Cipher, serveEncPasskey.Nonce, req.UserSecretCipher, req.UserSecretNonce)
return err
}
func (r *Repository) GetPasskeyRecoveryData(ctx context.Context, userID int64) (*ente.TwoFactorRecoveryResponse, error) {
var result ente.TwoFactorRecoveryResponse
err := r.Db.QueryRowContext(ctx, "SELECT user_passkey_secret_data, user_passkey_secret_nonce FROM two_factor_recovery WHERE user_id= $1", userID).Scan(&result.EncryptedSecret, &result.SecretDecryptionNonce)
if err != nil {
return nil, err
}
return &result, nil
}
// ValidatePasskeyRecoverySecret checks if the passkey skip secret is valid for a user
func (r *Repository) ValidatePasskeyRecoverySecret(userID int64, secret string) (bool, error) {
// get server_passkey_secret_data and server_passkey_secret_nonce for given user id
var severSecreteData, serverSecretNonce []byte
row := r.Db.QueryRow(`SELECT server_passkey_secret_data, server_passkey_secret_nonce FROM two_factor_recovery WHERE user_id = $1`, userID)
err := row.Scan(&severSecreteData, &serverSecretNonce)
if err != nil {
return false, stacktrace.Propagate(err, "")
}
// decrypt server_passkey_secret_data
serverSkipSecretKey, decErr := crypto.Decrypt(severSecreteData, r.SecretEncryptionKey, serverSecretNonce)
// serverSkipSecretKey, decErr := crypto.Decrypt(severSecreteData,serverSecretNonce, r.SecretEncryptionKey )
if decErr != nil {
return false, stacktrace.Propagate(decErr, "failed to decrypt passkey reset key")
}
if secret != serverSkipSecretKey {
logrus.Warn("invalid passkey skip secret")
return false, nil
}
return true, nil
}

View file

@ -32,8 +32,8 @@ yarn dev
That's it. The web app will automatically hot reload when you make changes.
If you're new to web development and unsure about how to get started, see
[docs/new](docs/new.md).
If you're new to web development and unsure about how to get started, or are
facing some problems when running the above steps, see [docs/new](docs/new.md).
## Other apps

View file

@ -1,7 +1,7 @@
{
"HERO_SLIDE_1_TITLE": "",
"HERO_SLIDE_1": "",
"HERO_SLIDE_2_TITLE": "",
"HERO_SLIDE_1_TITLE": "추억을 안전하게 백업하세요",
"HERO_SLIDE_1": "종단간 암호화가 기본지원입니다",
"HERO_SLIDE_2_TITLE": "낙진대피소에 안전하게 보관됩니다",
"HERO_SLIDE_2": "",
"HERO_SLIDE_3_TITLE": "",
"HERO_SLIDE_3": "",

View file

@ -9,9 +9,9 @@
"SIGN_UP": "",
"NEW_USER": "",
"EXISTING_USER": "",
"ENTER_NAME": "",
"ENTER_NAME": "Ange namn",
"PUBLIC_UPLOADER_NAME_MESSAGE": "",
"ENTER_EMAIL": "",
"ENTER_EMAIL": "Ange e-postadress",
"EMAIL_ERROR": "",
"REQUIRED": "",
"EMAIL_SENT": "",
@ -24,11 +24,11 @@
"EXPIRED_CODE": "",
"SENDING": "",
"SENT": "",
"PASSWORD": "",
"PASSWORD": "Lösenord",
"LINK_PASSWORD": "",
"RETURN_PASSPHRASE_HINT": "",
"RETURN_PASSPHRASE_HINT": "Lösenord",
"SET_PASSPHRASE": "",
"VERIFY_PASSPHRASE": "",
"VERIFY_PASSPHRASE": "Logga in",
"INCORRECT_PASSPHRASE": "",
"ENTER_ENC_PASSPHRASE": "",
"PASSPHRASE_DISCLAIMER": "",
@ -36,19 +36,19 @@
"WELCOME_TO_ENTE_SUBHEADING": "",
"WHERE_YOUR_BEST_PHOTOS_LIVE": "",
"KEY_GENERATION_IN_PROGRESS_MESSAGE": "",
"PASSPHRASE_HINT": "",
"CONFIRM_PASSPHRASE": "",
"PASSPHRASE_HINT": "Lösenord",
"CONFIRM_PASSPHRASE": "Bekräfta lösenord",
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
"PASSPHRASE_MATCH_ERROR": "Lösenorden matchar inte",
"CONSOLE_WARNING_STOP": "",
"CONSOLE_WARNING_DESC": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
"ENTER_FILE_NAME": "",
"CLOSE": "",
"NO": "",
"ENTER_FILE_NAME": "Filnamn",
"CLOSE": "Stäng",
"NO": "Nej",
"NOTHING_HERE": "",
"UPLOAD": "",
"IMPORT": "",
@ -108,7 +108,7 @@
"SESSION_EXPIRED_MESSAGE": "",
"SESSION_EXPIRED": "",
"PASSWORD_GENERATION_FAILED": "",
"CHANGE_PASSWORD": "",
"CHANGE_PASSWORD": "Ändra lösenord",
"GO_BACK": "",
"RECOVERY_KEY": "",
"SAVE_LATER": "",
@ -116,7 +116,7 @@
"RECOVERY_KEY_DESCRIPTION": "",
"RECOVER_KEY_GENERATION_FAILED": "",
"KEY_NOT_STORED_DISCLAIMER": "",
"FORGOT_PASSWORD": "",
"FORGOT_PASSWORD": "Glömt lösenord",
"RECOVER_ACCOUNT": "",
"RECOVERY_KEY_HINT": "",
"RECOVER": "",
@ -369,11 +369,11 @@
"shared_with_people_zero": "",
"shared_with_people_one": "",
"shared_with_people_other": "",
"participants_zero": "",
"participants_one": "",
"participants_zero": "Inga deltagare",
"participants_one": "1 deltagare",
"participants_other": "",
"ADD_VIEWERS": "",
"PARTICIPANTS": "",
"PARTICIPANTS": "Deltagare",
"CHANGE_PERMISSIONS_TO_VIEWER": "",
"CHANGE_PERMISSIONS_TO_COLLABORATOR": "",
"CONVERT_TO_VIEWER": "",
@ -416,14 +416,14 @@
"PASSWORD_LOCK": "",
"LOCK": "",
"DOWNLOAD_UPLOAD_LOGS": "",
"UPLOAD_FILES": "",
"UPLOAD_DIRS": "",
"UPLOAD_FILES": "Fil",
"UPLOAD_DIRS": "Mapp",
"UPLOAD_GOOGLE_TAKEOUT": "",
"DEDUPLICATE_FILES": "",
"AUTHENTICATOR_SECTION": "",
"NO_DUPLICATES_FOUND": "",
"CLUB_BY_CAPTURE_TIME": "",
"FILES": "",
"FILES": "Filer",
"EACH": "",
"DEDUPLICATE_BASED_ON_SIZE": "",
"STOP_ALL_UPLOADS_MESSAGE": "",
@ -432,8 +432,8 @@
"STOP_DOWNLOADS_HEADER": "",
"YES_STOP_DOWNLOADS": "",
"STOP_ALL_DOWNLOADS_MESSAGE": "",
"albums_one": "",
"albums_other": "",
"albums_one": "1 album",
"albums_other": "{{count, number}} album",
"ALL_ALBUMS": "",
"ALBUMS": "",
"ALL_HIDDEN_ALBUMS": "",
@ -464,14 +464,14 @@
"STOP_WATCHING_FOLDER": "",
"STOP_WATCHING_DIALOG_MESSAGE": "",
"YES_STOP": "",
"MONTH_SHORT": "",
"MONTH_SHORT": "mån",
"YEAR": "",
"FAMILY_PLAN": "",
"DOWNLOAD_LOGS": "",
"DOWNLOAD_LOGS_MESSAGE": "",
"CHANGE_FOLDER": "",
"TWO_MONTHS_FREE": "",
"GB": "",
"GB": "GB",
"POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "",
@ -492,7 +492,7 @@
"IGNORE_THIS_VERSION": "",
"TODAY": "",
"YESTERDAY": "",
"NAME_PLACEHOLDER": "",
"NAME_PLACEHOLDER": "Namn...",
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "",
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "",
"CHOSE_THEME": "",
@ -514,7 +514,7 @@
"PASSPHRASE_STRENGTH_MODERATE": "",
"PASSPHRASE_STRENGTH_STRONG": "",
"PREFERENCES": "",
"LANGUAGE": "",
"LANGUAGE": "Språk",
"EXPORT_DIRECTORY_DOES_NOT_EXIST": "",
"EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "",
"SUBSCRIPTION_VERIFICATION_ERROR": "",
@ -532,8 +532,8 @@
"MONTH": "",
"YEAR": ""
},
"COPY_LINK": "",
"DONE": "",
"COPY_LINK": "Kopiera länk",
"DONE": "Klar",
"LINK_SHARE_TITLE": "",
"REMOVE_LINK": "",
"CREATE_PUBLIC_SHARING": "",
@ -579,7 +579,7 @@
"HIDE": "",
"UNHIDE": "",
"UNHIDE_TO_COLLECTION": "",
"SORT_BY": "",
"SORT_BY": "Sortera efter",
"NEWEST_FIRST": "",
"OLDEST_FIRST": "",
"CONVERSION_FAILED_NOTIFICATION_MESSAGE": "",
@ -595,7 +595,7 @@
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"IMAGE": "",
"IMAGE": "Bild",
"VIDEO": "",
"LIVE_PHOTO": "",
"CONVERT": "",
@ -616,10 +616,10 @@
"SAVE_A_COPY_TO_ENTE": "",
"RESTORE_ORIGINAL": "",
"TRANSFORM": "",
"COLORS": "",
"COLORS": "Färger",
"FLIP": "",
"ROTATION": "",
"RESET": "",
"RESET": "Återställ",
"PHOTO_EDITOR": "",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",

View file

@ -9,7 +9,7 @@
"SIGN_UP": "注册",
"NEW_USER": "刚来到 ente",
"EXISTING_USER": "现有用户",
"ENTER_NAME": "现有用户",
"ENTER_NAME": "输入名字",
"PUBLIC_UPLOADER_NAME_MESSAGE": "请添加一个名字,以便您的朋友知晓该感谢谁拍摄了这些精美的照片!",
"ENTER_EMAIL": "请输入电子邮件地址",
"EMAIL_ERROR": "请输入有效的电子邮件",
@ -85,9 +85,9 @@
"ZOOM_IN_OUT": "放大/缩小",
"PREVIOUS": "上一个 (←)",
"NEXT": "下一个 (→)",
"TITLE_PHOTOS": "ente 照片",
"TITLE_ALBUMS": "ente 照片",
"TITLE_AUTH": "ente 验证器",
"TITLE_PHOTOS": "Ente 照片",
"TITLE_ALBUMS": "Ente 照片",
"TITLE_AUTH": "Ente 验证器",
"UPLOAD_FIRST_PHOTO": "上传您的第一张照片",
"IMPORT_YOUR_FOLDERS": "导入您的文件夹",
"UPLOAD_DROPZONE_MESSAGE": "拖放以备份您的文件",

View file

@ -1,40 +1,45 @@
# Deploying the web apps
# Deploying
## tl;dr;
The various web apps and static sites in this repository are deployed on
Cloudflare Pages.
```sh
yarn deploy:photos
```
* Production deployments are triggered by pushing to the `deploy/*` branches.
## Details
* [help.ente.io](https://help.ente.io) gets deployed whenever a PR that changes
anything inside `docs/` gets merged to `main`.
The various web apps (Ente Photos, Ente Auth) are deployed on Cloudflare Pages.
* Every night, all the web apps get automatically deployed to a nightly preview
URLs using the current code in main.
The deployment is done using the GitHub app provided by Cloudflare Pages. The
Cloudflare integration watches for pushes to all branches named "deploy/*". In
all cases, it runs the same script, `scripts/deploy.sh`, using the
`CF_PAGES_BRANCH` environment variable to decide what exactly to build ([CF
docs](https://developers.cloudflare.com/pages/how-to/build-commands-branches/)).
Use the various `yarn deploy:*` commands to help with production deployments.
For example, `yarn deploy:photos` will open a PR to merge the current `main`
onto `deploy/photos`, which'll trigger the deployment workflow, which'll build
and publish to [web.ente.io](https://web.ente.io).
For each of these branches, we have configured CNAME aliases (Cloudflare calls
them Custom Domains) to give a stable URL to the deployments.
> When merging these deployment PRs, remember to use rebase and merge so that
> their HEAD is a fast forward of `main` instead of diverging from it because of
> the merge commit.
- `deploy/photos` → _web.ente.io_
- `deploy/auth` → _auth.ente.io_
- `deploy/accounts` → _accounts.ente.io_
- `deploy/cast` → _cast.ente.io_
## Deployments
Here is a list of all the deployments, whether or not they are production
deployments, and the action that triggers them:
Thus to trigger a, say, production deployment of the photos app, we can open and
merge a PR into the `deploy/photos` branch. Cloudflare will then build and
deploy the code to _web.ente.io_.
| URL | Type |Deployment action |
|-----|------|------------------|
| [web.ente.io](https://web.ente.io) | Production | Push to `deploy/photos` |
| [photos.ente.io](https://photos.ente.io) | Production | Alias of [web.ente.io](https://web.ente.io) |
| [auth.ente.io](https://auth.ente.io) | Production | Push to `deploy/auth` |
| [accounts.ente.io](https://accounts.ente.io) | Production | Push to `deploy/accounts` |
| [cast.ente.io](https://cast.ente.io) | Production | Push to `deploy/cast` |
| [help.ente.io](https://help.ente.io) | Production | Push to `main` + changes in `docs/` |
| [accounts.ente.sh](https://accounts.ente.sh) | Preview | Nightly deploy of `main` |
| [auth.ente.sh](https://auth.ente.sh) | Preview | Nightly deploy of `main` |
| [cast.ente.sh](https://cast.ente.sh) | Preview | Nightly deploy of `main` |
| [photos.ente.sh](https://photos.ente.sh) | Preview | Nightly deploy of `main` |
The command `yarn deploy:photos` just does that - it'll open a new PR to fast
forward the current main onto `deploy/photos`. There are similar `yarn deploy:*`
commands for the other apps.
### Other subdomains
## Other subdomains
Apart from this, there are also some subdomains:
Apart from this, there are also some other deployments:
- `albums.ente.io` is a CNAME alias to the production deployment
(`web.ente.io`). However, when the code detects that it is being served from
@ -44,22 +49,67 @@ Apart from this, there are also some subdomains:
- `payments.ente.io` and `family.ente.io` are currently in a separate
repositories (Enhancement: bring them in here).
## NODE_VERSION
---
In Cloudflare Pages setting the `NODE_VERSION` environment variables is defined.
## Details
This determines which version of Node is used when we do `yarn build:foo`.
Currently this is set to `20.11.1`. The major version here should match that of
`@types/node` in our dev dependencies.
The rest of the document describes details about how things were setup. You
likely don't need to know them to be able to deploy.
It is a good idea to also use the same major version of node on your machine.
For example, for macOS you can install the the latest from the v20 series using
`brew install node@20`.
## First time preparation
## Adding a new app
Create a new Pages project in Cloudflare, setting it up to use [Direct
Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/).
1. Add a mapping in `scripts/deploy.sh`.
> [!NOTE]
>
> Direct upload doesn't work for existing projects tied to your repository using
> the [Git
> integration](https://developers.cloudflare.com/pages/get-started/git-integration/).
>
> If you want to keep the pages.dev domain from an existing project, you should
> be able to delete your existing project and recreate it (assuming no one
> claims the domain in the middle). I've not seen this documented anywhere, but
> it worked when I tried, and it seems to have worked for [other people
> too](https://community.cloudflare.com/t/linking-git-repo-to-existing-cf-pages-project/530888).
There are two ways to create a new project, using Wrangler
[[1](https://github.com/cloudflare/pages-action/issues/51)] or using the
Cloudflare dashboard
[[2](https://github.com/cloudflare/pages-action/issues/115)]. Since this is one
time thing, the second option might be easier.
The remaining steps are documented in [Cloudflare's guide for using Direct
Upload with
CI](https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/).
As a checklist,
- Generate `CLOUDFLARE_API_TOKEN`
- Add `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` to the GitHub secrets
- Add your workflow. e.g. see `docs-deploy.yml`.
This is the basic setup, and should already work.
## Deploying multiple sites
However, we wish to deploy multiple sites from this same repository, so the
standard Cloudflare conception of a single "production" branch doesn't work for
us.
Instead, we tie each deployment to a branch name. Note that we don't have to
actually create the branch or push to it, this branch name is just used as the
the `branch` parameter that gets passed to `cloudflare/pages-action`.
Since our root pages project is `ente.pages.dev`, so a branch named `foo` would
be available at `foo.ente.pages.dev`.
Finally, we create CNAME aliases using a [Custom Domain in
Cloudflare](https://developers.cloudflare.com/pages/how-to/custom-branch-aliases/)
to point to these deployments from our user facing DNS names.
As a concrete example, the GitHub workflow that deploys `docs/` passes "help" as
the branch name. The resulting deployment is available at "help.ente.pages.dev".
Finally, we add a custom domain to point to it from
[help.ente.io](https://help.ente.io).
2. Add a [Custom Domain in
Cloudflare](https://developers.cloudflare.com/pages/how-to/custom-branch-aliases/)
pointing to this branch's deployment.

View file

@ -13,3 +13,14 @@ development, here is a recommended workflow:
`yarn` comes with it.
That's it. Enjoy coding!
### Yarn
Note that we use Yarn classic
```
$ yarn --version
1.22.21
```
You should be seeing a 1.xx.xx version, otherwise your `yarn install` will fail.

View file

@ -1,51 +0,0 @@
#!/bin/sh
# This script is run by the Cloudflare Pages integration when deploying the apps
# in this repository. The app to build is decided based on the value of the
# CF_PAGES_BRANCH environment variable.
#
# The Cloudflare Pages build configuration is set to use `out/` as the build
# output directory, so once we're done building we copy the app specific output
# to `out/` (symlinking didn't work).
#
# Ref: https://developers.cloudflare.com/pages/how-to/build-commands-branches/
#
# To test this script locally, run
#
# CF_PAGES_BRANCH=deploy/foo ./scripts/deploy.sh
#
set -o errexit
set -o xtrace
if test "$(basename $(pwd))" != "web"
then
echo "ERROR: This script should be run from the web directory"
exit 1
fi
rm -rf out
case "$CF_PAGES_BRANCH" in
deploy/accounts)
yarn build:accounts
cp -R apps/accounts/out .
;;
deploy/auth)
yarn build:auth
cp -R apps/auth/out .
;;
deploy/cast)
yarn build:cast
cp -R apps/cast/out .
;;
deploy/photos)
yarn build:photos
cp -R apps/photos/out .
;;
*)
echo "ERROR: We don't know how to build and deploy a branch named $CF_PAGES_BRANCH."
echo " Maybe you forgot to add a new case in web/scripts/deploy.sh"
exit 1
;;
esac