diff --git a/mobile/apps/photos/lib/core/error-reporting/super_logging.dart b/mobile/apps/photos/lib/core/error-reporting/super_logging.dart index 2f2e6a8bc9..e3d1983d90 100644 --- a/mobile/apps/photos/lib/core/error-reporting/super_logging.dart +++ b/mobile/apps/photos/lib/core/error-reporting/super_logging.dart @@ -221,15 +221,14 @@ class SuperLogging { // Initialize log viewer integration in debug mode // Initialize log viewer in debug mode only - if (kDebugMode) { - try { - await LogViewer.initialize(); - // Register LogViewer with SuperLogging to receive logs with process prefix - LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback); - $.info("Log viewer initialized successfully"); - } catch (e) { - $.warning("Failed to initialize log viewer: $e"); - } + if (_preferences.getBool("enable_db_logging") ?? kDebugMode) { + try { + await LogViewer.initialize(); + // Register LogViewer with SuperLogging to receive logs with process prefix + LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback); + $.info("Log viewer initialized successfully"); + } catch (e) { + $.warning("Failed to initialize log viewer: $e"); } if (appConfig.body == null) return; diff --git a/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart b/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart index 3d523ad2ca..adb34315d2 100644 --- a/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart +++ b/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart @@ -23,6 +23,7 @@ class DebugSectionWidget extends StatefulWidget { } class _DebugSectionWidgetState extends State { + @override Widget build(BuildContext context) { return ExpandableMenuItemWidget( @@ -35,6 +36,27 @@ class _DebugSectionWidgetState extends State { Widget _getSectionOptions(BuildContext context) { return Column( children: [ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Enable database logging", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => localSettings.enableDatabaseLogging, + onChanged: () async { + final newValue = !localSettings.enableDatabaseLogging; + await localSettings.setEnableDatabaseLogging(newValue); + setState(() {}); + showShortToast( + context, + newValue + ? "Database logging enabled. Restart app." + : "Database logging disabled. Restart app.", + ); + }, + ), + ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: const CaptionedTextWidget( diff --git a/mobile/apps/photos/lib/ui/settings_page.dart b/mobile/apps/photos/lib/ui/settings_page.dart index 04061007ea..545b51fcd6 100644 --- a/mobile/apps/photos/lib/ui/settings_page.dart +++ b/mobile/apps/photos/lib/ui/settings_page.dart @@ -36,6 +36,7 @@ class SettingsPage extends StatelessWidget { const SettingsPage({super.key, required this.emailNotifier}); + @override Widget build(BuildContext context) { Bus.instance.fire(OpenedSettingsEvent()); @@ -82,7 +83,7 @@ class SettingsPage extends StatelessWidget { ), ), ), - if (kDebugMode) + if (localSettings.enableDatabaseLogging) GestureDetector( onTap: () { Navigator.of(context).push( diff --git a/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart b/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart index 7af57cdaab..d4d25ca67b 100644 --- a/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart +++ b/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart @@ -7,6 +7,7 @@ import "package:flutter/services.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/public_url.dart"; import 'package:photos/models/collection/collection.dart'; +import 'package:photos/service_locator.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/ente_theme.dart'; @@ -19,6 +20,7 @@ import "package:photos/ui/components/toggle_switch_widget.dart"; import 'package:photos/ui/notification/toast.dart'; import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart'; import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart'; +import 'package:photos/ui/sharing/qr_code_dialog_widget.dart'; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/navigation_util.dart'; import "package:photos/utils/share_util.dart"; @@ -291,6 +293,32 @@ class _ManageSharedLinkWidgetState extends State { ); }, isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: flagService.internalUser, + ), + if (!url.isExpired && flagService.internalUser) + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + if (!url.isExpired && flagService.internalUser) + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Send QR Code (i)", + makeTextBold: true, + ), + leadingIcon: Icons.qr_code_outlined, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + await showDialog( + context: context, + builder: (BuildContext context) { + return QrCodeDialogWidget( + collection: widget.collection!, + ); + }, + ); + }, + isTopBorderRadiusRemoved: true, ), const SizedBox( height: 24, diff --git a/mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart b/mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart new file mode 100644 index 0000000000..e5f53f3467 --- /dev/null +++ b/mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart @@ -0,0 +1,211 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photos/models/collection/collection.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/buttons/button_widget.dart'; +import 'package:photos/ui/components/models/button_type.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; + +class QrCodeDialogWidget extends StatefulWidget { + final Collection collection; + + const QrCodeDialogWidget({ + super.key, + required this.collection, + }); + + @override + State createState() => _QrCodeDialogWidgetState(); +} + +class _QrCodeDialogWidgetState extends State { + final GlobalKey _qrKey = GlobalKey(); + + Future _shareQrCode() async { + try { + final RenderRepaintBoundary boundary = + _qrKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final ui.Image image = await boundary.toImage(pixelRatio: 3.0); + final ByteData? byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData != null) { + final Uint8List pngBytes = byteData.buffer.asUint8List(); + + final directory = await getTemporaryDirectory(); + final file = File( + '${directory.path}/ente_qr_${widget.collection.displayName}.png', + ); + await file.writeAsBytes(pngBytes); + + await Share.shareXFiles( + [XFile(file.path)], + text: + 'Scan this QR code to view my ${widget.collection.displayName} album on ente', + ); + + // Close the dialog after sharing is initiated + if (mounted) { + Navigator.of(context).pop(); + } + } + } catch (e) { + debugPrint('Error sharing QR code: $e'); + } + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final double qrSize = min(screenWidth - 80, 300.0); + final enteTextTheme = getEnteTextTheme(context); + final enteColorScheme = getEnteColorScheme(context); + + // Get the public URL for the collection + final String publicUrl = + CollectionsService.instance.getPublicUrl(widget.collection); + + // Get album name, truncate if too long + final String albumName = widget.collection.displayName.length > 30 + ? '${widget.collection.displayName.substring(0, 27)}...' + : widget.collection.displayName; + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: enteColorScheme.backgroundBase, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with close button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "QR Code", + style: enteTextTheme.largeBold, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + color: enteColorScheme.strokeBase, + ), + ], + ), + const SizedBox(height: 8), + + // QR Code with RepaintBoundary for sharing + RepaintBoundary( + key: _qrKey, + child: Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.grey.shade200, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Album name at top center (inside border) - Reduced size + Text( + albumName, + style: enteTextTheme.bodyBold.copyWith( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + + // QR Code with better spacing + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey.shade100, + width: 1, + ), + ), + child: QrImageView( + data: publicUrl, + version: QrVersions.auto, + size: qrSize - 100, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + errorCorrectionLevel: QrErrorCorrectLevel.M, + ), + ), + const SizedBox(height: 24), + ], + ), + + // Ente branding at bottom right (inside border) - Fixed positioning + Positioned( + bottom: -2, + right: 2, + child: Text( + 'ente', + style: enteTextTheme.small.copyWith( + color: enteColorScheme.primary700, + fontSize: 14, + letterSpacing: 1.2, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Share button + ButtonWidget( + buttonType: ButtonType.primary, + icon: Icons.adaptive.share, + labelText: "Share", + onTap: _shareQrCode, + shouldSurfaceExecutionStates: false, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart b/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart index 1f9931e612..c9608a2367 100644 --- a/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart +++ b/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart @@ -1,10 +1,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import "package:photos/extensions/user_extension.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; import 'package:photos/models/collection/collection.dart'; +import 'package:photos/service_locator.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; @@ -19,6 +21,7 @@ import 'package:photos/ui/sharing/album_participants_page.dart'; import "package:photos/ui/sharing/album_share_info_widget.dart"; import "package:photos/ui/sharing/manage_album_participant.dart"; import 'package:photos/ui/sharing/manage_links_widget.dart'; +import 'package:photos/ui/sharing/qr_code_dialog_widget.dart'; import 'package:photos/ui/sharing/user_avator_widget.dart'; import 'package:photos/utils/navigation_util.dart'; import 'package:photos/utils/share_util.dart'; @@ -214,8 +217,34 @@ class _ShareCollectionPageState extends State { ); }, isTopBorderRadiusRemoved: true, - isBottomBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: flagService.internalUser, ), + if (flagService.internalUser) + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + if (flagService.internalUser) + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Send QR Code (i)", + makeTextBold: true, + ), + leadingIcon: Icons.qr_code_outlined, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + await showDialog( + context: context, + builder: (BuildContext context) { + return QrCodeDialogWidget( + collection: widget.collection, + ); + }, + ); + }, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + ), ], ); } diff --git a/mobile/apps/photos/lib/utils/local_settings.dart b/mobile/apps/photos/lib/utils/local_settings.dart index d6bd933d53..13b541fbeb 100644 --- a/mobile/apps/photos/lib/utils/local_settings.dart +++ b/mobile/apps/photos/lib/utils/local_settings.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import 'package:photos/core/constants.dart'; import 'package:photos/ui/viewer/gallery/component/group/type.dart'; import "package:photos/utils/ram_check_util.dart"; @@ -42,6 +44,7 @@ class LocalSettings { static const kCollectionViewType = "collection_view_type"; static const kCollectionSortDirection = "collection_sort_direction"; static const kShowLocalIDOverThumbnails = "show_local_id_over_thumbnails"; + static const kEnableDatabaseLogging = "enable_db_logging"; // Thumbnail queue configuration keys static const kSmallQueueMaxConcurrent = "small_queue_max_concurrent"; @@ -234,6 +237,13 @@ class LocalSettings { await _prefs.setBool(kShowLocalIDOverThumbnails, value); } + bool get enableDatabaseLogging => + _prefs.getBool(kEnableDatabaseLogging) ?? kDebugMode; + + Future setEnableDatabaseLogging(bool value) async { + await _prefs.setBool(kEnableDatabaseLogging, value); + } + // Thumbnail queue configuration - Small queue int get smallQueueMaxConcurrent => _prefs.getInt(kSmallQueueMaxConcurrent) ?? 15; diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index 57a450d5c1..968db38e08 100644 --- a/mobile/apps/photos/pubspec.yaml +++ b/mobile/apps/photos/pubspec.yaml @@ -177,6 +177,7 @@ dependencies: url: https://github.com/ente-io/privacy_screen.git ref: v2-only pro_image_editor: ^6.0.0 + qr_flutter: ^4.1.0 receive_sharing_intent: # pub.dev is behind git: url: https://github.com/KasemJaffer/receive_sharing_intent.git diff --git a/mobile/apps/photos/scripts/internal_changes.txt b/mobile/apps/photos/scripts/internal_changes.txt index aa68c02d91..0ad8dc584f 100644 --- a/mobile/apps/photos/scripts/internal_changes.txt +++ b/mobile/apps/photos/scripts/internal_changes.txt @@ -1,2 +1,3 @@ - Laurens: text embedding caching for memories and discover +- Neeraj: (i) Debug option to enable logViewer - Neeraj: Potential fix for ios in-app payment \ No newline at end of file diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index cf05b263e8..5a4a4a4053 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -858,7 +858,12 @@ func runServer(environment string, server *gin.Engine) { log.Fatal(server.RunTLS(":443", certPath, keyPath)) } else { - server.Run(":8080") + port := 8080 + if viper.IsSet("http.port") { + port = viper.GetInt("http.port") + } + log.Infof("starting server on port %d", port) + server.Run(fmt.Sprintf(":%d", port)) } } diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index 484e8ff400..60a0b9f51f 100644 --- a/server/configurations/local.yaml +++ b/server/configurations/local.yaml @@ -71,8 +71,13 @@ log-file: "" # HTTP connection parameters http: - # If true, bind to 443 and use TLS. - # By default, this is false, and museum will bind to 8080 without TLS. + # The port to bind to. + # If not specified, defaults to 8080 (HTTP) or 443 (HTTPS with use-tls: true) + # port: 8080 + + # If true, use TLS for HTTPS connections. + # When true and port is not specified, defaults to port 443. + # When false and port is not specified, defaults to port 8080. # use-tls: true # Specify the base endpoints for various apps diff --git a/server/ente/remotestore.go b/server/ente/remotestore.go index ea96d8de1c..d4468c04d6 100644 --- a/server/ente/remotestore.go +++ b/server/ente/remotestore.go @@ -3,6 +3,7 @@ package ente import ( "fmt" "github.com/ente-io/stacktrace" + "golang.org/x/net/idna" "regexp" "strings" ) @@ -139,8 +140,17 @@ func isValidDomainWithoutScheme(input string) error { if strings.Contains(trimmed, "://") { return NewBadRequestWithMessage("domain should not contain scheme (e.g., http:// or https://)") } - if !domainRegex.MatchString(trimmed) { + + // Convert IDN to ASCII (Punycode) for validation + asciiDomain, err := idna.ToASCII(trimmed) + if err != nil { + return NewBadRequestWithMessage(fmt.Sprintf("invalid idn domain format: %s", trimmed)) + } + + // Validate the ASCII version + if !domainRegex.MatchString(asciiDomain) { return NewBadRequestWithMessage(fmt.Sprintf("invalid domain format: %s", trimmed)) } + return nil } diff --git a/server/ente/remotestore_test.go b/server/ente/remotestore_test.go index 1056e5ca6b..636df4b40b 100644 --- a/server/ente/remotestore_test.go +++ b/server/ente/remotestore_test.go @@ -11,7 +11,9 @@ func TestIsValidDomainWithoutScheme(t *testing.T) { // ✅ Valid cases {"simple domain", "google.com", false}, {"multi-level domain", "sub.example.co.in", false}, + {"multi-level domain", "photos.ä.com", false}, {"numeric in label", "a1b2c3.com", false}, + {"idn", "テスト.jp", false}, {"long but valid label", "my-very-long-subdomain-name.example.com", false}, // ❌ Leading/trailing spaces diff --git a/server/pkg/middleware/collection_link.go b/server/pkg/middleware/collection_link.go index 71f9a9e69b..c80cb8e9c5 100644 --- a/server/pkg/middleware/collection_link.go +++ b/server/pkg/middleware/collection_link.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "fmt" + "golang.org/x/net/idna" "net/http" "net/url" "strings" @@ -220,11 +221,26 @@ func (m *CollectionLinkMiddleware) validateOrigin(c *gin.Context, ownerID int64) m.DiscordController.NotifyPotentialAbuse(alertMessage + " - originParseFailed") return nil } - if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) { - logger.Warnf("domainMismatch for owner %d, origin %s, domain %s host %s", ownerID, origin, *domain, parse.Host) + unicodeDomain, err := idna.ToUnicode(*domain) + if err != nil { + logger.WithError(err).Error("domainToUnicodeFailed") + m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainToUnicodeFailed") + return nil + } + + if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) && !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(unicodeDomain)) { + logger.Warnf("domainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host) m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainMismatch") return ente.NewPermissionDeniedError("unknown custom domain") } + // Additional exact match check. In the future, remove the contains check above and only keep this exact match check. + if !strings.EqualFold(parse.Host, *domain) && !strings.EqualFold(parse.Host, unicodeDomain) { + logger.Warnf("exactDomainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host) + m.DiscordController.NotifyPotentialAbuse(alertMessage + " - exactDomainMismatch") + // Do not return error here till we are fully sure that this won't cause any issues for existing + // custom domains. + // return ente.NewPermissionDeniedError("unknown custom domain") + } return nil }