Merge branch 'main' into smart_dedupe

This commit is contained in:
laurenspriem
2025-08-21 12:22:29 +05:30
41 changed files with 1083 additions and 33 deletions

View File

@@ -142,6 +142,22 @@ var _updateFreeUserStorage = &cobra.Command{
},
}
var _sendMail = &cobra.Command{
Use: "send-mail <to-email> <from-email> <from-name>",
Args: cobra.ExactArgs(3),
Short: "Sends a test mail via the admin api",
RunE: func(cmd *cobra.Command, args []string) error {
recoverWithLog()
var flags = &model.AdminActionForUser{}
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if f.Name == "admin-user" {
flags.AdminEmail = f.Value.String()
}
})
return ctrl.SendTestMail(context.Background(), *flags, args[0], args[1], args[2])
},
}
func init() {
rootCmd.AddCommand(_adminCmd)
_ = _userDetailsCmd.MarkFlagRequired("admin-user")
@@ -159,5 +175,6 @@ func init() {
_updateFreeUserStorage.Flags().StringP("user", "u", "", "The email of the user to update subscription for. (required)")
// add a flag with no value --no-limit
_updateFreeUserStorage.Flags().String("no-limit", "True", "When true, sets 100TB as storage limit, and expiry to current date + 100 years")
_adminCmd.AddCommand(_userDetailsCmd, _disable2faCmd, _disablePasskeyCmd, _updateFreeUserStorage, _listUsers, _deleteUser)
_sendMail.Flags().StringP("admin-user", "a", "", "The email of the admin user. ")
_adminCmd.AddCommand(_userDetailsCmd, _disable2faCmd, _disablePasskeyCmd, _updateFreeUserStorage, _listUsers, _deleteUser, _sendMail)
}

View File

@@ -139,5 +139,28 @@ func (c *Client) UpdateFreePlanSub(ctx context.Context, userDetails *models.User
}
}
return nil
}
func (c *Client) SendTestMail(ctx context.Context, toEmail, fromEmail, fromName string) error {
body := map[string]interface{}{
"to": []string{toEmail},
"fromName": fromName,
"fromEmail": fromEmail,
"subject": "Test mail from Ente",
"body": "This is a test mail from Ente",
}
r, err := c.restClient.R().
SetContext(ctx).
SetBody(body).
Post("/admin/mail")
if err != nil {
return err
}
if r.IsError() {
return &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return nil
}

View File

@@ -156,6 +156,23 @@ func (c *ClICtrl) UpdateFreeStorage(ctx context.Context, params model.AdminActio
return nil
}
func (c *ClICtrl) SendTestMail(ctx context.Context, params model.AdminActionForUser, to, from, fromName string) error {
accountCtx, err := c.buildAdminContext(ctx, params.AdminEmail)
if err != nil {
return err
}
err = c.Client.SendTestMail(accountCtx, to, from, fromName)
if err != nil {
if apiErr, ok := err.(*api.ApiError); ok && apiErr.StatusCode == 400 && strings.Contains(apiErr.Message, "Token is too old") {
fmt.Printf("Error: old admin token, please re-authenticate using `ente account add` \n")
return nil
}
return err
}
fmt.Printf("Successfully sent test email to %s\n", to)
return nil
}
func (c *ClICtrl) buildAdminContext(ctx context.Context, adminEmail string) (context.Context, error) {
accounts, err := c.GetAccounts(ctx)
if err != nil {

View File

@@ -171,6 +171,8 @@ smtp:
email:
# Optional name for sender
sender-name:
# Optional encryption
encryption:
```
| Variable | Description | Default |
@@ -181,6 +183,7 @@ smtp:
| `smtp.password` | SMTP auth password | |
| `smtp.email` | Sender email address | |
| `smtp.sender-name` | Custom name for email sender | |
| `smtp.encryption` | Encryption method (tls, ssl) | |
| `transmail.key` | Zeptomail API key | |
### WebAuthn Passkey Support

45
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
melos_*.iml
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@@ -1040,6 +1040,13 @@
"MistralAI"
]
},
{
"title": "Mobile01",
"slug": "mobile01",
"altNames": [
"M01"
]
},
{
"title": "Mozilla"
},

View File

@@ -0,0 +1,26 @@
<?xml version="1.0"?>
<svg width="320" height="280" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1">
<g class="layer">
<title>Layer 1</title>
<g id="Layer1000">
<g id="Layer1002">
<g id="Layer1003">
<path d="m123.08,34.29c-66.43,0 -120.27,53.85 -120.27,120.27c0,66.43 53.85,120.27 120.27,120.27c66.47,0 120.32,-53.85 120.32,-120.27c0,-66.43 -53.85,-120.27 -120.32,-120.27zm0,215.67c-52.67,0 -95.36,-42.73 -95.36,-95.4c0,-52.67 42.68,-95.4 95.36,-95.4c52.72,0 95.4,42.73 95.4,95.4c0,52.67 -42.68,95.4 -95.4,95.4z" fill="#2a5e00" fill-rule="evenodd" id="path7"/>
<g id="Layer1004">
<g id="Layer1005">
<path d="m138.72,146.29l59.61,-41.47l7.78,33.7l-67.39,7.78z" fill="#2a5e00" fill-rule="evenodd" id="path8"/>
<path d="m110.88,146.29l-59.61,-41.47l-7.78,33.7l67.39,7.78z" fill="#2a5e00" fill-rule="evenodd" id="path9"/>
</g>
<path d="m43.95,192.02l74.62,49.75l87.12,-78.8l-161.75,29.05z" fill="#2a5e00" fill-rule="evenodd" id="path10"/>
</g>
<path d="m94.24,59.29l-30.48,-55.1l54.26,33.24l-23.79,21.86z" fill="#2a5e00" fill-rule="evenodd" id="path11"/>
<path d="m202.64,78.1l30.43,-55.1l-54.22,33.24l23.79,21.86z" fill="#2a5e00" fill-rule="evenodd" id="path12"/>
</g>
<path d="m275.63,274.67l29.35,0l0,-240.76l-29.35,0l0,240.76z" fill="#2a5e00" fill-rule="evenodd" id="path13"/>
<path d="m317.94,125.93c0,15.3 -12.33,27.63 -27.63,27.63c-15.26,0 -27.63,-12.33 -27.63,-27.63c0,-15.26 12.37,-27.63 27.63,-27.63c15.3,0 27.63,12.37 27.63,27.63z" fill="#2a5e00" fill-rule="evenodd" id="path14"/>
<path d="m288.84,33.91l-41.76,0l16.76,45.99l23.58,0l1.42,-45.99z" fill="#2a5e00" fill-rule="evenodd" id="path15"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -126,6 +126,8 @@ PODS:
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5)
- ua_client_hints (1.4.1):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
@@ -158,6 +160,7 @@ DEPENDENCIES:
- sodium_libs (from `.symlinks/plugins/sodium_libs/ios`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- ua_client_hints (from `.symlinks/plugins/ua_client_hints/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS:
@@ -228,6 +231,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
ua_client_hints:
:path: ".symlinks/plugins/ua_client_hints/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
@@ -268,6 +273,7 @@ SPEC CHECKSUMS:
sqlite3: 3e82a2daae39ba3b41ae6ee84a130494585460fc
sqlite3_flutter_libs: 2c48c4ee7217fd653251975e43412250d5bcbbe2
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
PODFILE CHECKSUM: 78f002751f1a8f65042b8da97902ba4124271c5a

View File

@@ -111,6 +111,7 @@
"importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nIf your vault is encrypted, you will need to enter vault password to decrypt the vault.",
"import2FasGuide": "Use the \"Settings->Backup -Export\" option in 2FAS.\n\nIf your backup is encrypted, you will need to enter the password to decrypt the backup",
"importLastpassGuide": "Use the \"Transfer accounts\" option within Lastpass Authenticator Settings and press \"Export accounts to file\". Import the JSON downloaded.",
"importProtonAuthGuide": "Use the \"Export\" option in Proton Authenticator Settings to export your codes.",
"exportCodes": "Export codes",
"importLabel": "Import",
"importInstruction": "Please select a file that contains a list of your codes in the following format",
@@ -519,5 +520,12 @@
"algorithm": "Algorithm",
"type": "Type",
"period": "Period",
"digits": "Digits"
"digits": "Digits",
"importFromGallery": "Import from gallery",
"errorCouldNotReadImage": "Could not read the selected image file.",
"errorInvalidQRCode": "Invalid QR Code",
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
"errorNoQRCode": "No QR code found",
"errorGenericTitle": "An Error Occurred",
"errorGenericBody": "An unexpected error occurred while importing."
}

View File

@@ -35,18 +35,22 @@ import 'package:ente_auth/ui/settings_page.dart';
import 'package:ente_auth/ui/sort_option_menu.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/platform_util.dart';
import 'package:ente_auth/utils/toast_util.dart';
import 'package:ente_auth/utils/totp_util.dart';
import 'package:ente_events/event_bus.dart';
import 'package:ente_lock_screen/lock_screen_settings.dart';
import 'package:ente_lock_screen/ui/app_lock.dart';
import 'package:ente_ui/pages/base_home_page.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:image/image.dart' as img;
import 'package:logging/logging.dart';
import 'package:move_to_background/move_to_background.dart';
import 'package:zxing2/qrcode.dart';
class HomePage extends BaseHomePage {
const HomePage({super.key});
@@ -62,6 +66,7 @@ class _HomePageState extends State<HomePage> {
);
bool _hasLoaded = false;
bool _isSettingsOpen = false;
bool _isImportingFromGallery = false;
final Logger _logger = Logger("HomePage");
final scaffoldKey = GlobalKey<ScaffoldState>();
@@ -288,6 +293,70 @@ class _HomePageState extends State<HomePage> {
}
}
Future<void> _importFromGallery() async {
final l10n = AppLocalizations.of(context);
if (_isImportingFromGallery) {
return;
}
setState(() {
_isImportingFromGallery = true;
});
try {
final FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.image,
);
if (result == null || result.files.single.path == null) {
return;
}
final String imagePath = result.files.single.path!;
final rawImage = await File(imagePath).readAsBytes();
final image = img.decodeImage(rawImage);
if (image == null) {
await showErrorDialog(context, l10n.error, l10n.errorCouldNotReadImage);
return;
}
final source = RGBLuminanceSource(
image.width, image.height,
image.getBytes(order: img.ChannelOrder.rgba).buffer.asInt32List(),
);
final bitmap = BinaryBitmap(HybridBinarizer(source));
final reader = QRCodeReader();
final Result decodeResult = reader.decode(bitmap);
final String code = decodeResult.text;
try{
final newCode = Code.fromOTPAuthUrl(code);
await CodeStore.instance.addCode(newCode, shouldSync: false);
}
catch (e){
await showErrorDialog(
context, l10n.errorInvalidQRCode, l10n.errorInvalidQRCodeBody,
);
}
}
on ReaderException {
showToast(context, l10n.errorNoQRCode);
}
catch (e) {
await showErrorDialog(
context, l10n.errorGenericTitle, l10n.errorGenericBody,
);
}
finally {
setState(() {
_isImportingFromGallery = false;
});
}
}
Future<void> _redirectToScannerPage() async {
final Code? code = await Navigator.of(context).push(
MaterialPageRoute(
@@ -745,6 +814,13 @@ class _HomePageState extends State<HomePage> {
labelWidget: SpeedDialLabelWidget(context.l10n.enterDetailsManually),
onTap: _redirectToManualEntryPage,
),
SpeedDialChild(
child: const Icon(Icons.image),
backgroundColor: Theme.of(context).colorScheme.fabBackgroundColor,
foregroundColor: Theme.of(context).colorScheme.fabForegroundColor,
labelWidget: SpeedDialLabelWidget(context.l10n.importFromGallery),
onTap: _importFromGallery,
),
],
);
}

View File

@@ -4,6 +4,7 @@ import 'package:ente_auth/ui/settings/data/import/encrypted_ente_import.dart';
import 'package:ente_auth/ui/settings/data/import/google_auth_import.dart';
import 'package:ente_auth/ui/settings/data/import/lastpass_import.dart';
import 'package:ente_auth/ui/settings/data/import/plain_text_import.dart';
import 'package:ente_auth/ui/settings/data/import/proton_import.dart';
import 'package:ente_auth/ui/settings/data/import/raivo_plain_text_import.dart';
import 'package:ente_auth/ui/settings/data/import/two_fas_import.dart';
import 'package:ente_auth/ui/settings/data/import_page.dart';
@@ -43,6 +44,9 @@ class ImportService {
case ImportType.lastpass:
await showLastpassImportInstruction(context);
break;
case ImportType.proton:
await showProtonImportInstruction(context);
break;
}
}
}

View File

@@ -0,0 +1,171 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:ente_auth/l10n/l10n.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/ui/common/progress_dialog.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/dialog_widget.dart';
import 'package:ente_auth/ui/components/models/button_type.dart';
import 'package:ente_auth/ui/settings/data/import/import_success.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
Future<void> showProtonImportInstruction(BuildContext context) async {
final l10n = context.l10n;
final result = await showDialogWidget(
context: context,
title: l10n.importFromApp("Proton Authenticator"),
body: l10n.importProtonAuthGuide,
buttons: [
ButtonWidget(
buttonType: ButtonType.primary,
labelText: l10n.importSelectJsonFile,
isInAlert: true,
buttonSize: ButtonSize.large,
buttonAction: ButtonAction.first,
),
ButtonWidget(
buttonType: ButtonType.secondary,
labelText: context.l10n.cancel,
buttonSize: ButtonSize.large,
isInAlert: true,
buttonAction: ButtonAction.second,
),
],
);
if (result?.action != null && result!.action != ButtonAction.cancel) {
if (result.action == ButtonAction.first) {
await _pickProtonJsonFile(context);
}
}
}
Future<void> _pickProtonJsonFile(BuildContext context) async {
final l10n = context.l10n;
FilePickerResult? result = await FilePicker.platform
.pickFiles(dialogTitle: l10n.importSelectJsonFile);
if (result == null) {
return;
}
final ProgressDialog progressDialog =
createProgressDialog(context, l10n.pleaseWait);
await progressDialog.show();
try {
String path = result.files.single.path!;
int? count = await _processProtonExportFile(context, path, progressDialog);
await progressDialog.hide();
if (count != null) {
await importSuccessDialog(context, count);
}
} catch (e, s) {
Logger('ProtonImport')
.severe('exception while processing proton import', e, s);
await progressDialog.hide();
await showErrorDialog(
context,
context.l10n.sorry,
"${context.l10n.importFailureDescNew}\n Error: ${e.toString()}",
);
}
}
Future<int?> _processProtonExportFile(
BuildContext context,
String path,
final ProgressDialog dialog,
) async {
File file = File(path);
final jsonString = await file.readAsString();
final decodedJson = jsonDecode(jsonString);
// Validate that this is a Proton export
if (decodedJson['version'] == null || decodedJson['entries'] == null) {
await dialog.hide();
await showErrorDialog(
context,
'Invalid Proton export',
'The selected file is not a valid Proton Authenticator export.',
);
return null;
}
final parsedCodes = <Code>[];
final entries = decodedJson['entries'] as List;
for (var entry in entries) {
try {
final content = entry['content'];
if (content == null) {
continue; // Skip entries without content
}
final entryType = content['entry_type'] as String?;
if (entryType != 'Totp' && entryType != 'Steam') {
// log warning
Logger('ProtonImport').warning('Unsupported entry type: $entryType');
continue; // Skip non-TOTP and non-Steam entries
}
Code code;
if (entryType == 'Steam') {
// Handle Steam entries with steam:// format
final steamUri = content['uri'] as String?;
if (steamUri == null || !steamUri.startsWith('steam://')) {
continue; // Skip invalid Steam URIs
}
final secret = steamUri.split('steam://')[1];
final name = content['name'] as String? ?? '';
code = Code.fromAccountAndSecret(
Type.steam,
'', // Steam doesn't typically have separate account
name, // Use name as issuer
secret,
null,
Code.steamDigits,
);
} else {
// Handle TOTP entries with otpauth:// format
final otpUri = content['uri'] as String?;
if (otpUri == null || !otpUri.startsWith('otpauth://')) {
continue; // Skip invalid OTP URIs
}
// Create code from OTP auth URL
code = Code.fromOTPAuthUrl(otpUri);
}
// Add note if present
final note = entry['note'] as String?;
if (note != null && note.isNotEmpty) {
code = code.copyWith(
display: code.display.copyWith(note: note),
);
}
parsedCodes.add(code);
} catch (e, s) {
Logger('ProtonImport').warning('Failed to parse entry', e, s);
// Continue processing other entries
}
}
// Add all parsed codes to the store
for (final code in parsedCodes) {
await CodeStore.instance.addCode(code, shouldSync: false);
}
// Trigger sync
unawaited(AuthenticatorService.instance.onlineSync());
return parsedCodes.length;
}

View File

@@ -17,6 +17,7 @@ enum ImportType {
twoFas,
bitwarden,
lastpass,
proton,
}
class ImportCodePage extends StatelessWidget {
@@ -29,6 +30,7 @@ class ImportCodePage extends StatelessWidget {
ImportType.aegis,
ImportType.bitwarden,
ImportType.googleAuthenticator,
ImportType.proton,
ImportType.ravio,
ImportType.lastpass,
];
@@ -51,6 +53,8 @@ class ImportCodePage extends StatelessWidget {
return 'Bitwarden';
case ImportType.lastpass:
return 'LastPass Authenticator';
case ImportType.proton:
return 'Proton Authenticator';
}
}

View File

@@ -201,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -466,7 +474,7 @@ packages:
source: path
version: "1.0.0"
ente_utils:
dependency: transitive
dependency: "direct overridden"
description:
path: "../../packages/utils"
relative: true
@@ -532,10 +540,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a
sha256: e7e16c9d15c36330b94ca0e2ad8cb61f93cd5282d0158c09805aed13b5452f22
url: "https://pub.dev"
source: hosted
version: "10.2.0"
version: "10.3.2"
file_saver:
dependency: "direct main"
description:
@@ -951,7 +959,7 @@ packages:
source: hosted
version: "0.1.0"
image:
dependency: transitive
dependency: "direct main"
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
@@ -2021,6 +2029,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.3"
zxing2:
dependency: "direct main"
description:
name: zxing2
sha256: "2677c49a3b9ca9457cb1d294fd4bd5041cac6aab8cdb07b216ba4e98945c684f"
url: "https://pub.dev"
source: hosted
version: "0.2.4"
sdks:
dart: ">=3.7.2 <4.0.0"
flutter: ">=3.29.0"

View File

@@ -50,7 +50,7 @@ dependencies:
expansion_tile_card: ^3.0.0
ffi: ^2.1.0
figma_squircle: ^0.6.3
file_picker: ^10.2.0
file_picker: ^10.3.2
file_saver: ^0.3.1
fixnum: ^1.1.0
fk_user_agent: # no package updates on pub.dev
@@ -84,6 +84,7 @@ dependencies:
google_nav_bar: ^5.0.5 #supported
gradient_borders: ^1.0.0
http: ^1.1.0
image: ^4.5.4
intl: ^0.20.2
io: ^1.0.4
json_annotation: ^4.5.0
@@ -130,6 +131,7 @@ dependencies:
win32: ^5.1.1
window_manager: ^0.5.0
xdg_directories: ^1.0.4
zxing2: ^0.2.4
dev_dependencies:
build_runner: ^2.1.11

View File

@@ -0,0 +1,22 @@
# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils
dependency_overrides:
ente_accounts:
path: ../../packages/accounts
ente_base:
path: ../../packages/base
ente_configuration:
path: ../../packages/configuration
ente_events:
path: ../../packages/events
ente_lock_screen:
path: ../../packages/lock_screen
ente_logging:
path: ../../packages/logging
ente_network:
path: ../../packages/network
ente_strings:
path: ../../packages/strings
ente_ui:
path: ../../packages/ui
ente_utils:
path: ../../packages/utils

View File

@@ -0,0 +1,22 @@
# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils
dependency_overrides:
ente_accounts:
path: ../../packages/accounts
ente_base:
path: ../../packages/base
ente_configuration:
path: ../../packages/configuration
ente_events:
path: ../../packages/events
ente_lock_screen:
path: ../../packages/lock_screen
ente_logging:
path: ../../packages/logging
ente_network:
path: ../../packages/network
ente_strings:
path: ../../packages/strings
ente_ui:
path: ../../packages/ui
ente_utils:
path: ../../packages/utils

View File

@@ -1000,20 +1000,33 @@ class FilesDB with SqlDbBase {
final batch = localIDsList.sublist(i, endIndex);
final placeholders = List.filled(batch.length, '?').join(',');
final List<String> alreadyUploaded = [];
// find localIDs that are already uploaded
final result = await db.execute('''
SELECT DISTINCT $columnLocalID
FROM $filesTable
WHERE $columnLocalID IN ($placeholders)
AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID != -1)
''');
for (final row in result) {
alreadyUploaded.add(row[columnLocalID] as String);
}
final uploadedPlaceholders =
alreadyUploaded.map((id) => "'$id'").join(',');
final r = await db.execute(
'''
DELETE FROM $filesTable
WHERE $columnLocalID IN ($placeholders)
AND ($columnCollectionID IS NULL OR $columnCollectionID = -1)
WHERE $columnLocalID IN ($uploadedPlaceholders)
AND ($columnUploadedFileID IS NULL OR $columnUploadedFileID = -1)
''',
batch,
);
if (r.isNotEmpty) {
_logger
.fine("Batch ${(i ~/ batchSize) + 1}: Removed ${r.length} files");
_logger.warning(
"Batch ${(i ~/ batchSize) + 1}: Removed duplicate ${r.length} files",
);
totalRemoved += r.length;
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import "dart:convert";
import "dart:io";
import "dart:math";
import 'package:bip39/bip39.dart' as bip39;
@@ -102,6 +103,7 @@ class UserService {
data: {
"email": email,
"purpose": isChangeEmail ? "change" : purpose ?? "",
"mobile": Platform.isIOS || Platform.isAndroid,
},
);
await dialog.hide();

View File

@@ -124,6 +124,10 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
GlobalWidgetsLocalizations.delegate,
],
onGenerateRoute: (settings) {
// On Android disabling deep links doesn't work, so this function
// also gets triggered like /?generatedId=xyz&mainKey=abcd
// Related: https://github.com/flutter/flutter/issues/119938
switch (settings.name) {
case '/lock-screen':
return PageRouteBuilder(
@@ -135,7 +139,7 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
this.widget.builder(settings.arguments),
);
}
return PageRouteBuilder(pageBuilder: (_, __, ___) => this._lockScreen);
return null;
},
);
}

View File

@@ -36,6 +36,7 @@ class GalleryFileWidget extends StatefulWidget {
}
class _GalleryFileWidgetState extends State<GalleryFileWidget> {
static const borderRadius = BorderRadius.all(Radius.circular(1));
late bool _isFileSelected;
@override
@@ -92,7 +93,7 @@ class _GalleryFileWidgetState extends State<GalleryFileWidget> {
children: [
ClipRRect(
key: ValueKey(heroTag),
borderRadius: BorderRadius.circular(1),
borderRadius: borderRadius,
child: Hero(
tag: heroTag,
flightShuttleBuilder: (
@@ -104,13 +105,13 @@ class _GalleryFileWidgetState extends State<GalleryFileWidget> {
) =>
thumbnailWidget,
transitionOnUserGestures: true,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Color.fromARGB(102, 0, 0, 0),
BlendMode.darken,
),
child: thumbnailWidget,
),
child: thumbnailWidget,
),
),
Container(
decoration: const BoxDecoration(
color: Color.fromARGB(102, 0, 0, 0),
borderRadius: borderRadius,
),
),
Positioned(
@@ -126,7 +127,7 @@ class _GalleryFileWidgetState extends State<GalleryFileWidget> {
)
: ClipRRect(
key: ValueKey(heroTag),
borderRadius: BorderRadius.circular(1),
borderRadius: borderRadius,
child: Hero(
tag: heroTag,
flightShuttleBuilder: (

View File

@@ -0,0 +1,33 @@
# melos_managed_dependency_overrides: ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32
dependency_overrides:
ffi: 2.1.0
flutter_sodium:
git:
url: https://github.com/ente-io/flutter_sodium
ref: v2-embeddings-only
intl: ^0.20.2
js: ^0.6.7
media_kit:
git:
url: https://github.com/media-kit/media-kit
path: media_kit
media_kit_libs_ios_video:
git:
url: https://github.com/media-kit/media-kit
path: libs/ios/media_kit_libs_ios_video
media_kit_libs_video:
git:
url: https://github.com/media-kit/media-kit
path: libs/universal/media_kit_libs_video
media_kit_video:
git:
url: https://github.com/media-kit/media-kit
path: media_kit_video
protobuf: ^3.1.0
video_player:
git:
url: https://github.com/ente-io/packages.git
ref: android_video_roation_fix
path: packages/video_player/video_player/
watcher: ^1.1.0
win32: 5.10.1

View File

@@ -1,3 +1,4 @@
- Neeraj: Fix for double enteries for local file
- (prtk) Fix widget initial launch on iOS
- Similar images debug screen (Settings > Backup > Free up space > Similar images)
- (prtk) Upgrade Flutter version to 3.32.8

70
mobile/melos.yaml Normal file
View File

@@ -0,0 +1,70 @@
# A name for your monorepo
name: ente_workspace
# Location of your packages, using glob patterns.
# Melos will find any pubspec.yaml file inside these directories.
packages:
- apps/*
- packages/*
# Scripts that can be run with `melos run <script_name>`
scripts:
# The very first command you should run. It links all your local packages
# together and runs `flutter pub get` everywhere.
bootstrap:
run: melos bootstrap
description: Bootstrap the workspace, linking local packages.
# --- GLOBAL COMMANDS (run on all apps & packages) ---
get:all:
run: melos exec -- "flutter pub get"
description: Run "flutter pub get" in all projects.
clean:all:
run: melos exec -- "flutter clean"
description: Run "flutter clean" in all projects.
# --- APP-SPECIFIC CLEAN COMMANDS ---
# These scripts use `--scope` to target only a specific app.
# IMPORTANT: The scope name (e.g., "photos") must match the `name`
# field in that app's `pubspec.yaml` file.
clean:photos:
run: melos exec --scope="photos" -- "flutter clean"
description: Clean the 'photos' app.
clean:auth:
run: melos exec --scope="auth" -- "flutter clean"
description: Clean the 'auth' app.
clean:locker:
run: melos exec --scope="locker" -- "flutter clean"
description: Clean the 'locker' app.
run:photos:apk:
run: melos exec --scope="photos" -- "flutter run -t lib/main.dart --flavor independent"
description: Run the 'photos' app in independent mode.
run:auth:apk:
run: melos exec --scope="auth" -- "flutter run -t lib/main.dart --flavor independent"
description: Run the 'auth' app in independent mode.
run:locker:apk:
run: melos exec --scope="locker" -- "flutter run -t lib/main.dart --flavor independent"
description: Run the 'locker' app in independent mode.
# --- APP-SPECIFIC BUILD COMMANDS ---
build:photos:apk:
run: melos exec --scope="photos" -- "flutter build apk --release"
description: Build a release APK for the 'photos' app.
build:auth:appbundle:
run: melos exec --scope="auth" -- "flutter build appbundle --release"
description: Build a release AppBundle for the 'auth' app.
build:locker:ios:
run: melos exec --scope="locker" -- "flutter build ios --release"
description: Build a release iOS archive for the 'locker' app.

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import "dart:convert";
import "dart:io";
import "dart:math";
import 'package:bip39/bip39.dart' as bip39;
@@ -88,6 +89,7 @@ class UserService {
data: {
"email": email,
"purpose": isChangeEmail ? "change" : purpose ?? "",
"mobile": Platform.isIOS || Platform.isAndroid,
},
);
await dialog.hide();

View File

@@ -231,7 +231,7 @@ packages:
source: path
version: "1.0.0"
ente_logging:
dependency: transitive
dependency: "direct overridden"
description:
path: "../logging"
relative: true

View File

@@ -0,0 +1,20 @@
# melos_managed_dependency_overrides: ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils
dependency_overrides:
ente_base:
path: ../base
ente_configuration:
path: ../configuration
ente_events:
path: ../events
ente_lock_screen:
path: ../lock_screen
ente_logging:
path: ../logging
ente_network:
path: ../network
ente_strings:
path: ../strings
ente_ui:
path: ../ui
ente_utils:
path: ../utils

View File

@@ -0,0 +1,8 @@
# melos_managed_dependency_overrides: ente_base,ente_events,ente_logging
dependency_overrides:
ente_base:
path: ../base
ente_events:
path: ../events
ente_logging:
path: ../logging

View File

@@ -201,7 +201,7 @@ packages:
source: path
version: "1.0.0"
ente_base:
dependency: transitive
dependency: "direct overridden"
description:
path: "../base"
relative: true
@@ -231,14 +231,14 @@ packages:
source: path
version: "1.0.0"
ente_logging:
dependency: transitive
dependency: "direct overridden"
description:
path: "../logging"
relative: true
source: path
version: "1.0.0"
ente_network:
dependency: transitive
dependency: "direct overridden"
description:
path: "../network"
relative: true

View File

@@ -0,0 +1,20 @@
# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_logging,ente_network,ente_strings,ente_ui,ente_utils
dependency_overrides:
ente_accounts:
path: ../accounts
ente_base:
path: ../base
ente_configuration:
path: ../configuration
ente_events:
path: ../events
ente_logging:
path: ../logging
ente_network:
path: ../network
ente_strings:
path: ../strings
ente_ui:
path: ../ui
ente_utils:
path: ../utils

View File

@@ -130,7 +130,7 @@ packages:
source: hosted
version: "2.1.1"
ente_base:
dependency: transitive
dependency: "direct overridden"
description:
path: "../base"
relative: true
@@ -160,7 +160,7 @@ packages:
source: path
version: "1.0.0"
ente_logging:
dependency: transitive
dependency: "direct overridden"
description:
path: "../logging"
relative: true

View File

@@ -0,0 +1,10 @@
# melos_managed_dependency_overrides: ente_base,ente_configuration,ente_events,ente_logging
dependency_overrides:
ente_base:
path: ../base
ente_configuration:
path: ../configuration
ente_events:
path: ../events
ente_logging:
path: ../logging

View File

@@ -161,7 +161,7 @@ packages:
source: git
version: "1.0.0"
ente_events:
dependency: transitive
dependency: "direct overridden"
description:
path: "../events"
relative: true

View File

@@ -0,0 +1,14 @@
# melos_managed_dependency_overrides: ente_base,ente_configuration,ente_events,ente_logging,ente_strings,ente_utils
dependency_overrides:
ente_base:
path: ../base
ente_configuration:
path: ../configuration
ente_events:
path: ../events
ente_logging:
path: ../logging
ente_strings:
path: ../strings
ente_utils:
path: ../utils

View File

@@ -138,7 +138,7 @@ packages:
source: hosted
version: "3.0.0"
ente_base:
dependency: transitive
dependency: "direct overridden"
description:
path: "../base"
relative: true
@@ -161,7 +161,7 @@ packages:
source: git
version: "1.0.0"
ente_events:
dependency: transitive
dependency: "direct overridden"
description:
path: "../events"
relative: true

View File

@@ -0,0 +1,14 @@
# melos_managed_dependency_overrides: ente_base,ente_configuration,ente_events,ente_logging,ente_strings,ente_ui
dependency_overrides:
ente_base:
path: ../base
ente_configuration:
path: ../configuration
ente_events:
path: ../events
ente_logging:
path: ../logging
ente_strings:
path: ../strings
ente_ui:
path: ../ui

285
mobile/pubspec.lock Normal file
View File

@@ -0,0 +1,285 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
ansi_styles:
dependency: transitive
description:
name: ansi_styles
sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a"
url: "https://pub.dev"
source: hosted
version: "0.3.2+1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_launcher:
dependency: transitive
description:
name: cli_launcher
sha256: "17d2744fb9a254c49ec8eda582536abe714ea0131533e24389843a4256f82eac"
url: "https://pub.dev"
source: hosted
version: "0.3.2+1"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
conventional_commit:
dependency: transitive
description:
name: conventional_commit
sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3
url: "https://pub.dev"
source: hosted
version: "0.6.1+1"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: transitive
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev"
source: hosted
version: "1.5.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
melos:
dependency: "direct dev"
description:
name: melos
sha256: "4280dc46bd5b741887cce1e67e5c1a6aaf3c22310035cf5bd33dceeeda62ed22"
url: "https://pub.dev"
source: hosted
version: "6.3.3"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mustache_template:
dependency: transitive
description:
name: mustache_template
sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
pool:
dependency: transitive
description:
name: pool
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
prompts:
dependency: transitive
description:
name: prompts
sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pub_updater:
dependency: transitive
description:
name: pub_updater
sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
yaml_edit:
dependency: transitive
description:
name: yaml_edit
sha256: fb38626579fb345ad00e674e2af3a5c9b0cc4b9bfb8fd7f7ff322c7c9e62aef5
url: "https://pub.dev"
source: hosted
version: "2.2.2"
sdks:
dart: ">=3.8.0 <4.0.0"

10
mobile/pubspec.yaml Normal file
View File

@@ -0,0 +1,10 @@
# This pubspec.yaml is for the root of your monorepo.
# Its purpose is to lock the version of the melos tool.
name: ente_workspace
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dev_dependencies:
melos: ^6.0.0

View File

@@ -255,6 +255,8 @@ smtp:
# Optional override for the sender name in the emails. If specified, it will
# be used for all emails sent by the instance (default is email specific).
sender-name:
# Optional encryption method. If tls or ssl is chosen, encryption is enabled.
encryption:
# Zoho Zeptomail config (optional)
#

View File

@@ -7,6 +7,7 @@ package email
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"html/template"
@@ -47,6 +48,7 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s
smtpPassword := viper.GetString("smtp.password")
smtpEmail := viper.GetString("smtp.email")
smtpSenderName := viper.GetString("smtp.sender-name")
smtpEncryption := viper.GetString("smtp.encryption")
var emailMessage string
var auth smtp.Auth = nil
@@ -104,7 +106,7 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s
// Send the email to each recipient
for _, toEmail := range toEmails {
err := smtp.SendMail(smtpServer+":"+smtpPort, auth, fromEmail, []string{toEmail}, []byte(emailMessage))
err := sendMailWithEncryption(smtpServer, smtpPort, auth, fromEmail, []string{toEmail}, []byte(emailMessage), smtpEncryption)
if err != nil {
errMsg := err.Error()
for i := range knownInvalidEmailErrors {
@@ -119,6 +121,76 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s
return nil
}
// sendMailWithEncryption sends an email with the specified encryption type
// encryption can be one of:
// - "tls" or "ssl": Uses TLS/SSL encryption for the entire connection
// - "" (empty string) or any other value: No encryption
func sendMailWithEncryption(host, port string, auth smtp.Auth, from string, to []string, msg []byte, encryption string) error {
addr := host + ":" + port
switch strings.ToLower(encryption) {
case "tls", "ssl":
// For TLS/SSL, establish a secure connection directly
tlsConfig := &tls.Config{
ServerName: host,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return stacktrace.Propagate(err, "failed to establish TLS connection")
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return stacktrace.Propagate(err, "failed to create SMTP client over TLS")
}
defer client.Close()
return sendWithClient(client, auth, from, to, msg)
default:
// No encryption, use standard SendMail
return smtp.SendMail(addr, auth, from, to, msg)
}
}
// sendWithClient sends an email using an established SMTP client
func sendWithClient(client *smtp.Client, auth smtp.Auth, from string, to []string, msg []byte) error {
if auth != nil {
if err := client.Auth(auth); err != nil {
return stacktrace.Propagate(err, "authentication failed")
}
}
if err := client.Mail(from); err != nil {
return stacktrace.Propagate(err, "failed to set sender")
}
for _, addr := range to {
if err := client.Rcpt(addr); err != nil {
return stacktrace.Propagate(err, "failed to add recipient")
}
}
w, err := client.Data()
if err != nil {
return stacktrace.Propagate(err, "failed to create message writer")
}
_, err = w.Write(msg)
if err != nil {
return stacktrace.Propagate(err, "failed to write message")
}
err = w.Close()
if err != nil {
return stacktrace.Propagate(err, "failed to close message writer")
}
err = client.Quit()
return stacktrace.Propagate(err, "")
}
func sendViaTransmail(toEmails []string, fromName string, fromEmail string, subject string, htmlBody string, inlineImages []map[string]interface{}) error {
if len(toEmails) == 0 {
return ente.ErrBadRequest