diff --git a/desktop/CHANGELOG.md b/desktop/CHANGELOG.md index 22d6ad2309..2cff804323 100644 --- a/desktop/CHANGELOG.md +++ b/desktop/CHANGELOG.md @@ -1,9 +1,12 @@ # CHANGELOG -## v1.7.14 (Unreleased) +## v1.7.15 (Unreleased) + +- . + +## v1.7.14 - Increase file size limit to 10 GB. -- . ## v1.7.13 diff --git a/desktop/package.json b/desktop/package.json index ae2bfafeb2..9dc9e4f2e2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "ente", - "version": "1.7.14-beta", + "version": "1.7.15-beta", "private": true, "description": "Desktop client for Ente Photos", "repository": "github:ente-io/photos-desktop", diff --git a/mobile/docs/vscode/launch.json b/mobile/docs/vscode/launch.json index 437d6ac30a..80d910083b 100644 --- a/mobile/docs/vscode/launch.json +++ b/mobile/docs/vscode/launch.json @@ -10,7 +10,12 @@ "type": "dart", "flutterMode": "debug", "program": "mobile/lib/main.dart", - "args": ["--flavor", "independent"] + "args": [ + "--flavor", + "independent", + "--dart-define", + "cronetHttpNoPlay=true" + ] }, { "name": "Photos Android Local", @@ -24,7 +29,9 @@ "--dart-define", "endpoint=http://localhost:8080", "--dart-define", - "web-family=http://localhost:3003" + "web-family=http://localhost:3003", + "--dart-define", + "cronetHttpNoPlay=true" ] }, { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2e59b78812..404a60da91 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import "package:flutter/rendering.dart"; import "package:flutter/services.dart"; import "package:flutter_displaymode/flutter_displaymode.dart"; +import "package:intl/date_symbol_data_local.dart"; import 'package:logging/logging.dart'; import "package:media_kit/media_kit.dart"; import "package:package_info_plus/package_info_plus.dart"; @@ -116,8 +117,13 @@ Future _homeWidgetSync([bool isBackground = false]) async { return; } + if (isBackground) { + final locale = await getLocale(); + await initializeDateFormatting(locale?.languageCode ?? "en"); + } + try { - await HomeWidgetService.instance.initHomeWidget(); + await HomeWidgetService.instance.initHomeWidget(isBackground); } catch (e, s) { _logger.severe("Error in syncing home widget", e, s); } diff --git a/mobile/lib/services/album_home_widget_service.dart b/mobile/lib/services/album_home_widget_service.dart index af06b70fef..9513427e1f 100644 --- a/mobile/lib/services/album_home_widget_service.dart +++ b/mobile/lib/services/album_home_widget_service.dart @@ -65,9 +65,9 @@ class AlbumHomeWidgetService { await _prefs.setString(ALBUMS_LAST_HASH_KEY, hash); } - Future initAlbumHomeWidget() async { + Future initAlbumHomeWidget(bool isBg) async { await HomeWidgetService.instance.computeLock.synchronized(() async { - if (await _hasAnyBlockers()) { + if (await _hasAnyBlockers(isBg)) { await clearWidget(); return; } @@ -133,13 +133,13 @@ class AlbumHomeWidgetService { _logger.info("Checking pending albums sync"); if (await _shouldUpdateWidgetCache()) { - await initAlbumHomeWidget(); + await initAlbumHomeWidget(false); } } Future _refreshOnSelection() async { final lastHash = getAlbumsLastHash(); - final selectedAlbumIds = await _getEffectiveSelectedAlbumIds(); + final selectedAlbumIds = await _getEffectiveSelectedAlbumIds(false); final currentHash = _calculateHash(selectedAlbumIds); if (lastHash != null && currentHash == lastHash) { _logger.info("No changes detected in albums"); @@ -147,7 +147,7 @@ class AlbumHomeWidgetService { } await setSelectionChange(true); - await initAlbumHomeWidget(); + await initAlbumHomeWidget(false); } List getAlbumsByIds(List albumIds) { @@ -157,7 +157,7 @@ class AlbumHomeWidgetService { final collection = CollectionsService.instance.getCollectionByID(albumId); if (collection != null && !collection.isDeleted && - collection.isHidden()) { + !collection.isHidden()) { albums.add(collection); } } @@ -232,7 +232,7 @@ class AlbumHomeWidgetService { return hash; } - Future _hasAnyBlockers() async { + Future _hasAnyBlockers([bool isBg = false]) async { // Check if first import is completed final hasCompletedFirstImport = LocalSyncService.instance.hasCompletedFirstImport(); @@ -241,7 +241,7 @@ class AlbumHomeWidgetService { } // Check if selected albums exist - final selectedAlbumIds = await _getEffectiveSelectedAlbumIds(); + final selectedAlbumIds = await _getEffectiveSelectedAlbumIds(isBg); final albums = getAlbumsByIds(selectedAlbumIds); if (albums.isEmpty) { @@ -254,7 +254,7 @@ class AlbumHomeWidgetService { Future _refreshAlbumsWidget() async { // only refresh if widget was synced without issues - if (getAlbumsStatus() == WidgetStatus.syncedAll) return; + if (await countHomeWidgets() == 0) return; await _refreshWidget(message: "Refreshing from existing album set"); } @@ -289,14 +289,18 @@ class AlbumHomeWidgetService { return true; } - Future> _getEffectiveSelectedAlbumIds() async { + Future> _getEffectiveSelectedAlbumIds([bool isBg = false]) async { final selectedAlbumIds = getSelectedAlbumIds(); // If no albums selected, use favorites as default if (selectedAlbumIds == null || selectedAlbumIds.isEmpty) { + if (isBg) { + await FavoritesService.instance.initFav(); + } final favoriteId = await FavoritesService.instance.getFavoriteCollectionID(); if (favoriteId != null) { + await updateSelectedAlbums([favoriteId.toString()]); return [favoriteId]; } } diff --git a/mobile/lib/services/home_widget_service.dart b/mobile/lib/services/home_widget_service.dart index 36aa3abe59..3c86fbe51b 100644 --- a/mobile/lib/services/home_widget_service.dart +++ b/mobile/lib/services/home_widget_service.dart @@ -62,19 +62,19 @@ class HomeWidgetService { } void _initializeWidgetServices(SharedPreferences prefs) { - MemoryHomeWidgetService.instance.init(prefs); - PeopleHomeWidgetService.instance.init(prefs); AlbumHomeWidgetService.instance.init(prefs); + PeopleHomeWidgetService.instance.init(prefs); + MemoryHomeWidgetService.instance.init(prefs); } void setAppGroupID(String id) { hw.HomeWidget.setAppGroupId(id).ignore(); } - Future initHomeWidget() async { - await MemoryHomeWidgetService.instance.initMemoryHomeWidget(); + Future initHomeWidget([bool isBg = false]) async { + await AlbumHomeWidgetService.instance.initAlbumHomeWidget(isBg); await PeopleHomeWidgetService.instance.initPeopleHomeWidget(); - await AlbumHomeWidgetService.instance.initAlbumHomeWidget(); + await MemoryHomeWidgetService.instance.initMemoryHomeWidget(); } Future updateWidget({ @@ -222,9 +222,9 @@ class HomeWidgetService { } await Future.wait([ - MemoryHomeWidgetService.instance.clearWidget(), - PeopleHomeWidgetService.instance.clearWidget(), AlbumHomeWidgetService.instance.clearWidget(), + PeopleHomeWidgetService.instance.clearWidget(), + MemoryHomeWidgetService.instance.clearWidget(), ]); try { diff --git a/mobile/lib/services/memories_cache_service.dart b/mobile/lib/services/memories_cache_service.dart index 46fd63058f..51ea8710bb 100644 --- a/mobile/lib/services/memories_cache_service.dart +++ b/mobile/lib/services/memories_cache_service.dart @@ -433,6 +433,7 @@ class MemoriesCacheService { required bool onThisDay, required bool pastYears, required bool smart, + required bool hasAnyWidgets, }) async { if (!onThisDay && !pastYears && !smart) { _logger.info( @@ -440,7 +441,7 @@ class MemoriesCacheService { ); return []; } - final allMemories = await getMemories(onlyUseCache: true); + final allMemories = await getMemories(onlyUseCache: !hasAnyWidgets); if (onThisDay && pastYears && smart) { return allMemories; } diff --git a/mobile/lib/services/memory_home_widget_service.dart b/mobile/lib/services/memory_home_widget_service.dart index 00482c8308..ca69b75eb4 100644 --- a/mobile/lib/services/memory_home_widget_service.dart +++ b/mobile/lib/services/memory_home_widget_service.dart @@ -182,7 +182,7 @@ class MemoryHomeWidgetService { Future _refreshMemoriesWidget() async { // only refresh if widget was synced without issues - if (getMemoriesStatus() == WidgetStatus.syncedAll) return; + if (await countHomeWidgets() == 0) return; await _refreshWidget(message: "Refreshing from existing memory set"); } @@ -222,6 +222,7 @@ class MemoryHomeWidgetService { onThisDay: onThisDayValue, pastYears: lastYearValue, smart: smartMemoryValue, + hasAnyWidgets: await countHomeWidgets() > 0, ); return memories; diff --git a/mobile/lib/services/people_home_widget_service.dart b/mobile/lib/services/people_home_widget_service.dart index ec53641dd9..c204e140b1 100644 --- a/mobile/lib/services/people_home_widget_service.dart +++ b/mobile/lib/services/people_home_widget_service.dart @@ -131,7 +131,7 @@ class PeopleHomeWidgetService { Future checkPeopleChanged() async { final havePeopleChanged = await peopleChangedLock.synchronized(() async { - final peopleIds = getSelectedPeople() ?? []; + final peopleIds = await _getEffectiveSelections(); final currentHash = await _calculateHash(peopleIds); final lastHash = getPeopleLastHash(); @@ -204,6 +204,22 @@ class PeopleHomeWidgetService { await _refreshPeopleWidget(); } + Future> _getEffectiveSelections() async { + var selection = getSelectedPeople(); + + if ((selection?.isEmpty ?? true) && + getPeopleStatus() == WidgetStatus.syncedAll) { + selection = await SearchService.instance.getTopTwoFaces(); + if (selection.isEmpty) { + await clearWidget(); + return []; + } + await setSelectedPeople(selection); + } + + return selection ?? []; + } + Future _calculateHash(List peopleIds) async { return await entityService.getHashForIds(peopleIds); } @@ -226,7 +242,7 @@ class PeopleHomeWidgetService { } // Check if selected people or hash exist - final peopleIds = getSelectedPeople() ?? []; + final peopleIds = await _getEffectiveSelections(); final hash = await _calculateHash(peopleIds); final noSelectionOrHashEmpty = peopleIds.isEmpty || hash.isEmpty; @@ -240,7 +256,7 @@ class PeopleHomeWidgetService { Future _refreshPeopleWidget() async { // only refresh if widget was synced without issues - if (getPeopleStatus() == WidgetStatus.syncedAll) return; + if (await countHomeWidgets() == 0) return; await _refreshWidget(message: "Refreshing from existing people set"); } @@ -304,7 +320,7 @@ class PeopleHomeWidgetService { } Future _updatePeopleWidgetCache() async { - final peopleIds = getSelectedPeople() ?? []; + final peopleIds = await _getEffectiveSelections(); final peopleWithFiles = await _getPeople(peopleIds); if (peopleWithFiles.isEmpty) { diff --git a/mobile/lib/services/search_service.dart b/mobile/lib/services/search_service.dart index 2b747087f5..3530e407c1 100644 --- a/mobile/lib/services/search_service.dart +++ b/mobile/lib/services/search_service.dart @@ -663,6 +663,19 @@ class SearchService { return searchResults; } + Future> getTopTwoFaces() async { + final searchFilter = await SectionType.face.getData(null).then( + (value) => (value as List).where( + (element) => (element.params[kPersonParamID] as String?) != null, + ), + ); + + return searchFilter + .take(2) + .map((e) => e.params[kPersonParamID] as String) + .toList(); + } + Future> getLocationResults(String query) async { final locationTagEntities = (await locationService.getLocationTags()); final Map, List> result = {}; diff --git a/mobile/lib/services/update_service.dart b/mobile/lib/services/update_service.dart index e144548a79..4c7b1dc881 100644 --- a/mobile/lib/services/update_service.dart +++ b/mobile/lib/services/update_service.dart @@ -14,7 +14,7 @@ import 'package:url_launcher/url_launcher_string.dart'; class UpdateService { static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key"; static const changeLogVersionKey = "update_change_log_key"; - static const currentChangeLogVersion = 29; + static const currentChangeLogVersion = 30; LatestVersionInfo? _latestVersion; final _logger = Logger("UpdateService"); diff --git a/mobile/lib/ui/notification/update/change_log_page.dart b/mobile/lib/ui/notification/update/change_log_page.dart index 42ddaa4407..e386546fb2 100644 --- a/mobile/lib/ui/notification/update/change_log_page.dart +++ b/mobile/lib/ui/notification/update/change_log_page.dart @@ -1,3 +1,5 @@ +import "dart:io"; + import 'package:flutter/material.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/l10n/l10n.dart"; @@ -101,29 +103,30 @@ class _ChangeLogPageState extends State { final List items = []; items.addAll([ ChangeLogEntry( - context.l10n.cLTitle1, - context.l10n.cLDesc1, - ), - ChangeLogEntry( - context.l10n.cLTitle2, - context.l10n.cLDesc2, + context.l10n.cLTitle4, + context.l10n.cLDesc4, ), ChangeLogEntry( context.l10n.cLTitle3, context.l10n.cLDesc3, ), - ChangeLogEntry( - context.l10n.cLTitle4, - context.l10n.cLDesc4, - ), ChangeLogEntry( context.l10n.cLTitle5, context.l10n.cLDesc5, ), + if (!Platform.isAndroid) + ChangeLogEntry( + context.l10n.cLTitle2, + context.l10n.cLDesc2, + ), ChangeLogEntry( context.l10n.cLTitle6, context.l10n.cLDesc6, ), + ChangeLogEntry( + context.l10n.cLTitle1, + context.l10n.cLDesc1, + ), ]); return Container( diff --git a/mobile/lib/ui/settings/widgets/albums_widget_settings.dart b/mobile/lib/ui/settings/widgets/albums_widget_settings.dart index de1e0270a4..e6cbf9b867 100644 --- a/mobile/lib/ui/settings/widgets/albums_widget_settings.dart +++ b/mobile/lib/ui/settings/widgets/albums_widget_settings.dart @@ -103,7 +103,6 @@ class _AlbumsWidgetSettingsState extends State { await AlbumHomeWidgetService.instance .updateSelectedAlbums(albums); Navigator.pop(context); - } : null, isDisabled: _selectedAlbums.albums.isEmpty, @@ -123,7 +122,7 @@ class _AlbumsWidgetSettingsState extends State { flexibleSpaceTitle: TitleBarTitleWidget( title: S.of(context).albums, ), - expandedHeight: 120, + expandedHeight: MediaQuery.textScalerOf(context).scale(120), flexibleSpaceCaption: hasInstalledAny ? S.of(context).albumsWidgetDesc : context.l10n.addAlbumWidgetPrompt, diff --git a/mobile/lib/ui/settings/widgets/memories_widget_settings.dart b/mobile/lib/ui/settings/widgets/memories_widget_settings.dart index 5b22d0627d..7deecfdf73 100644 --- a/mobile/lib/ui/settings/widgets/memories_widget_settings.dart +++ b/mobile/lib/ui/settings/widgets/memories_widget_settings.dart @@ -104,7 +104,7 @@ class _MemoriesWidgetSettingsState extends State { flexibleSpaceTitle: TitleBarTitleWidget( title: S.of(context).memories, ), - expandedHeight: 120, + expandedHeight: MediaQuery.textScalerOf(context).scale(120), flexibleSpaceCaption: hasInstalledAny ? S.of(context).memoriesWidgetDesc : context.l10n.addMemoriesWidgetPrompt, diff --git a/mobile/lib/ui/settings/widgets/people_widget_settings.dart b/mobile/lib/ui/settings/widgets/people_widget_settings.dart index b760eade80..fc78d8332f 100644 --- a/mobile/lib/ui/settings/widgets/people_widget_settings.dart +++ b/mobile/lib/ui/settings/widgets/people_widget_settings.dart @@ -88,7 +88,7 @@ class _PeopleWidgetSettingsState extends State { flexibleSpaceTitle: TitleBarTitleWidget( title: S.of(context).people, ), - expandedHeight: 120, + expandedHeight: MediaQuery.textScalerOf(context).scale(120), flexibleSpaceCaption: hasInstalledAny ? S.of(context).peopleWidgetDesc : context.l10n.addPeopleWidgetPrompt, diff --git a/mobile/lib/ui/tabs/home_widget.dart b/mobile/lib/ui/tabs/home_widget.dart index 4f540a0d57..247907446b 100644 --- a/mobile/lib/ui/tabs/home_widget.dart +++ b/mobile/lib/ui/tabs/home_widget.dart @@ -279,9 +279,9 @@ class _HomeWidgetState extends State { await Future.delayed(const Duration(seconds: 5)); _logger.info("Syncing home widget"); - await MemoryHomeWidgetService.instance.checkPendingMemorySync(); - await PeopleHomeWidgetService.instance.checkPendingPeopleSync(); await AlbumHomeWidgetService.instance.checkPendingAlbumsSync(); + await PeopleHomeWidgetService.instance.checkPendingPeopleSync(); + await MemoryHomeWidgetService.instance.checkPendingMemorySync(); } final Map _linkedPublicAlbums = {}; diff --git a/mobile/lib/ui/viewer/file/zoomable_image.dart b/mobile/lib/ui/viewer/file/zoomable_image.dart index 45f4f2b016..f29315a2f2 100644 --- a/mobile/lib/ui/viewer/file/zoomable_image.dart +++ b/mobile/lib/ui/viewer/file/zoomable_image.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'dart:typed_data' show Uint8List; import 'package:flutter/material.dart'; @@ -69,7 +70,7 @@ class _ZoomableImageState extends State { // This is to prevent the app from crashing when loading 200MP images // https://github.com/flutter/flutter/issues/110331 - bool get isTooLargeImage => _photo.width * _photo.height > 160000000; + bool get isTooLargeImage => _photo.width * _photo.height > 100000000; //100MP @override void initState() { @@ -385,23 +386,18 @@ class _ZoomableImageState extends State { ImageProvider imageProvider; if (isTooLargeImage) { _logger.info( - "Handling very large image (${_photo.width}x${_photo.height}) to prevent crash", + "Handling very large image (${_photo.width}x${_photo.height}) by decreasing resolution to 50MP to prevent crash", ); final aspectRatio = _photo.width / _photo.height; - int targetWidth, targetHeight; - if (aspectRatio > 1) { - targetWidth = 4096; - targetHeight = (targetWidth / aspectRatio).round(); - } else { - targetHeight = 4096; - targetWidth = (targetHeight * aspectRatio).round(); - } + const maxPixels = 50000000; + final targetHeight = sqrt(maxPixels / aspectRatio); + final targetWidth = aspectRatio * targetHeight; imageProvider = Image.file( file, gaplessPlayback: true, - cacheWidth: targetWidth, - cacheHeight: targetHeight, + cacheWidth: targetWidth.round(), + cacheHeight: targetHeight.round(), ).image; } else { imageProvider = Image.file( @@ -482,23 +478,17 @@ class _ZoomableImageState extends State { Uint8List? compressedFile; if (isTooLargeImage) { _logger.info( - "Compressing very large image (${_photo.width}x${_photo.height}) more aggressively", + "Compressing very large image (${_photo.width}x${_photo.height}) more aggressively down to 50MP", ); final aspectRatio = _photo.width / _photo.height; - int targetWidth, targetHeight; - - if (aspectRatio > 1) { - targetWidth = 4096; - targetHeight = (targetWidth / aspectRatio).round(); - } else { - targetHeight = 4096; - targetWidth = (targetHeight * aspectRatio).round(); - } + const maxPixels = 50000000; + final targetHeight = sqrt(maxPixels / aspectRatio); + final targetWidth = aspectRatio * targetHeight; compressedFile = await FlutterImageCompress.compressWithFile( file.path, - minWidth: targetWidth, - minHeight: targetHeight, + minWidth: targetWidth.round(), + minHeight: targetHeight.round(), quality: 85, ); } else { diff --git a/mobile/lib/ui/viewer/search/result/people_section_all_page.dart b/mobile/lib/ui/viewer/search/result/people_section_all_page.dart index 33c671f515..d78eb4b9ba 100644 --- a/mobile/lib/ui/viewer/search/result/people_section_all_page.dart +++ b/mobile/lib/ui/viewer/search/result/people_section_all_page.dart @@ -399,14 +399,6 @@ class _PeopleSectionAllWidgetState extends State { results.removeWhere( (element) => element.params[kPersonParamID] == null, ); - if (widget.selectedPeople?.personIds.isEmpty ?? false) { - widget.selectedPeople!.select( - results - .take(2) - .map((e) => e.params[kPersonParamID] as String) - .toSet(), - ); - } } _isLoaded = true; return results; diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index 9aada5ae8a..ec70ea527b 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -3,7 +3,7 @@ import { CssBaseline } from "@mui/material"; import { ThemeProvider } from "@mui/material/styles"; import { accountLogout } from "ente-accounts/services/logout"; import type { User } from "ente-accounts/services/user"; -import { clientPackageName, staticAppTitle } from "ente-base/app"; +import { staticAppTitle } from "ente-base/app"; import { CustomHead } from "ente-base/components/Head"; import { LoadingIndicator, @@ -19,7 +19,6 @@ import { import { authTheme } from "ente-base/components/utils/theme"; import { BaseContext, deriveBaseContext } from "ente-base/context"; import { logStartupBanner } from "ente-base/log-web"; -import HTTPService from "ente-shared/network/HTTPService"; import { getData } from "ente-shared/storage/localStorage"; import { t } from "i18next"; import type { AppProps } from "next/app"; @@ -35,7 +34,6 @@ const App: React.FC = ({ Component, pageProps }) => { useEffect(() => { const user = getData("user") as User | undefined | null; logStartupBanner(user?.id); - HTTPService.setHeaders({ "X-Client-Package": clientPackageName }); }, []); const logout = useCallback(() => { diff --git a/web/apps/photos/src/components/Collections/CollectionShare.tsx b/web/apps/photos/src/components/Collections/CollectionShare.tsx index d49f9e389e..0bd3871d59 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare.tsx @@ -68,7 +68,6 @@ import { } from "ente-new/photos/services/collection"; import type { CollectionSummary } from "ente-new/photos/services/collection-summary"; import { usePhotosAppContext } from "ente-new/photos/types/context"; -import { CustomError, parseSharingErrorCodes } from "ente-shared/error"; import { wait } from "ente-utils/promise"; import { useFormik } from "formik"; import { t } from "i18next"; @@ -300,25 +299,6 @@ const SharingDetails: React.FC = ({ ); }; -const handleSharingErrors = (error) => { - const parsedError = parseSharingErrorCodes(error); - let errorMessage = ""; - switch (parsedError.message) { - case CustomError.BAD_REQUEST: - errorMessage = t("sharing_album_not_allowed"); - break; - case CustomError.SUBSCRIPTION_NEEDED: - errorMessage = t("sharing_disabled_for_free_accounts"); - break; - case CustomError.NOT_FOUND: - errorMessage = t("sharing_user_does_not_exist"); - break; - default: - errorMessage = `${t("generic_error_retry")} ${parsedError.message}`; - } - return errorMessage; -}; - type EmailShareProps = { onRootClose: () => void; wrap: (f: () => Promise) => () => void; @@ -956,51 +936,55 @@ const ManageParticipant: React.FC = ({ onClose(); }; - const handleRoleChange = (role: string) => () => { - if (role !== selectedParticipant.role) { - changeRolePermission(selectedParticipant.email, role); - } - }; + const confirmChangeRolePermission = useCallback( + ( + selectedEmail: string, + newRole: CollectionNewParticipantRole, + action: () => Promise, + ) => { + let message: React.ReactNode; + let buttonText: string; - const updateCollectionRole = async (selectedEmail, newRole) => { - try { - await shareCollection(collection, selectedEmail, newRole); - selectedParticipant.role = newRole; - await onRemotePull({ silent: true }); - } catch (e) { - log.error(handleSharingErrors(e), e); - } - }; + if (newRole == "VIEWER") { + message = ( + + ); - const changeRolePermission = (selectedEmail, newRole) => { - let contentText; - let buttonText; + buttonText = t("confirm_convert_to_viewer"); + } else if (newRole == "COLLABORATOR") { + message = t("change_permission_to_collaborator", { + selectedEmail, + }); + buttonText = t("confirm_convert_to_collaborator"); + } - if (newRole == "VIEWER") { - contentText = ( - - ); - - buttonText = t("confirm_convert_to_viewer"); - } else if (newRole == "COLLABORATOR") { - contentText = t("change_permission_to_collaborator", { - selectedEmail, + showMiniDialog({ + title: t("change_permission_title"), + message: message, + continue: { text: buttonText, color: "critical", action }, }); - buttonText = t("confirm_convert_to_collaborator"); - } + }, + [showMiniDialog], + ); - showMiniDialog({ - title: t("change_permission_title"), - message: contentText, - continue: { - text: buttonText, - color: "critical", - action: () => updateCollectionRole(selectedEmail, newRole), - }, - }); + const updateCollectionRole = async ( + selectedEmail: string, + newRole: CollectionNewParticipantRole, + ) => { + await shareCollection(collection, selectedEmail, newRole); + selectedParticipant.role = newRole; + await onRemotePull({ silent: true }); + }; + + const createOnRoleChange = (role: CollectionNewParticipantRole) => () => { + if (role == selectedParticipant.role) return; + const { email } = selectedParticipant; + confirmChangeRolePermission(email, role, () => + updateCollectionRole(email, role), + ); }; const removeParticipant = () => { @@ -1044,7 +1028,7 @@ const ManageParticipant: React.FC = ({ } endIcon={ @@ -1057,7 +1041,7 @@ const ManageParticipant: React.FC = ({ } endIcon={ diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 5824cb1890..67ce61f0c3 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -4,7 +4,7 @@ import { CssBaseline, Typography } from "@mui/material"; import { styled, ThemeProvider } from "@mui/material/styles"; import { useNotification } from "components/utils/hooks-app"; import type { User } from "ente-accounts/services/user"; -import { clientPackageName, isDesktop, staticAppTitle } from "ente-base/app"; +import { isDesktop, staticAppTitle } from "ente-base/app"; import { CenteredRow } from "ente-base/components/containers"; import { CustomHead } from "ente-base/components/Head"; import { @@ -39,7 +39,6 @@ import { runMigrations } from "ente-new/photos/services/migration"; import { initML, isMLSupported } from "ente-new/photos/services/ml"; import { getFamilyPortalRedirectURL } from "ente-new/photos/services/user-details"; import { PhotosAppContext } from "ente-new/photos/types/context"; -import HTTPService from "ente-shared/network/HTTPService"; import { getData, isLocalStorageAndIndexedDBMismatch, @@ -71,7 +70,6 @@ const App: React.FC = ({ Component, pageProps }) => { useEffect(() => { const user = getData("user") as User | undefined | null; logStartupBanner(user?.id); - HTTPService.setHeaders({ "X-Client-Package": clientPackageName }); void isLocalStorageAndIndexedDBMismatch().then((mismatch) => { if (mismatch) { log.error("Logging out (IndexedDB and local storage mismatch)"); diff --git a/web/apps/photos/src/pages/shared-albums.tsx b/web/apps/photos/src/pages/shared-albums.tsx index 23230f49ce..92a49006ab 100644 --- a/web/apps/photos/src/pages/shared-albums.tsx +++ b/web/apps/photos/src/pages/shared-albums.tsx @@ -41,7 +41,11 @@ import { useIsTouchscreen, } from "ente-base/components/utils/hooks"; import { useBaseContext } from "ente-base/context"; -import { isHTTP401Error, PublicAlbumsCredentials } from "ente-base/http"; +import { + isHTTP401Error, + isHTTPErrorWithStatus, + PublicAlbumsCredentials, +} from "ente-base/http"; import log from "ente-base/log"; import { FullScreenDropZone } from "ente-gallery/components/FullScreenDropZone"; import { downloadManager } from "ente-gallery/services/download"; @@ -51,10 +55,20 @@ import { sortFiles } from "ente-gallery/utils/file"; import type { Collection } from "ente-media/collection"; import { type EnteFile } from "ente-media/file"; import { + removePublicCollectionAccessTokenJWT, + removePublicCollectionByKey, savedLastPublicCollectionReferralCode, + savedPublicCollectionAccessTokenJWT, + savedPublicCollectionByKey, savedPublicCollectionFiles, + savePublicCollectionAccessTokenJWT, } from "ente-new/albums/services/public-albums-fdb"; -import { verifyPublicAlbumPassword } from "ente-new/albums/services/public-collection"; +import { + pullCollection, + pullPublicCollectionFiles, + removePublicCollectionFileData, + verifyPublicAlbumPassword, +} from "ente-new/albums/services/public-collection"; import { GalleryItemsHeaderAdapter, GalleryItemsSummary, @@ -62,21 +76,10 @@ import { import { isHiddenCollection } from "ente-new/photos/services/collection"; import { PseudoCollectionID } from "ente-new/photos/services/collection-summary"; import { usePhotosAppContext } from "ente-new/photos/types/context"; -import { CustomError, parseSharingErrorCodes } from "ente-shared/error"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FileWithPath } from "react-dropzone"; -import { - getLocalPublicCollection, - getLocalPublicCollectionPassword, - getPublicCollection, - getPublicCollectionUID, - removePublicCollectionWithFiles, - removePublicFiles, - savePublicCollectionPassword, - syncPublicFiles, -} from "services/publicCollectionService"; import { uploadManager } from "services/upload-manager"; import { SelectedState, @@ -88,19 +91,24 @@ import { downloadSelectedFiles, getSelectedFiles } from "utils/file"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; export default function PublicCollectionGallery() { + const { showMiniDialog, onGenericError } = useBaseContext(); + const { showLoadingBar, hideLoadingBar } = usePhotosAppContext(); + const credentials = useRef(undefined); const collectionKey = useRef(null); const url = useRef(null); const referralCode = useRef(""); - const [publicFiles, setPublicFiles] = useState(null); - const [publicCollection, setPublicCollection] = useState(null); + const [publicCollection, setPublicCollection] = useState< + Collection | undefined + >(undefined); + const [publicFiles, setPublicFiles] = useState( + undefined, + ); const [errorMessage, setErrorMessage] = useState(null); - const { showMiniDialog } = useBaseContext(); - const { showLoadingBar, hideLoadingBar } = usePhotosAppContext(); const [loading, setLoading] = useState(true); + const [isPasswordProtected, setIsPasswordProtected] = useState(false); + const router = useRouter(); - const [isPasswordProtected, setIsPasswordProtected] = - useState(false); const [photoListHeader, setPhotoListHeader] = useState(null); @@ -160,7 +168,7 @@ export default function PublicCollectionGallery() { }, []); const onAddPhotos = useMemo(() => { - return publicCollection?.publicURLs?.[0]?.enableCollect + return publicCollection?.publicURLs[0]?.enableCollect ? () => setUploadTypeSelectorView(true) : undefined; }, [publicCollection]); @@ -200,6 +208,10 @@ export default function PublicCollectionGallery() { { shallow: true }, ); } + /** + * Determine credentials, read the locally cached state, then start + * pulling the latest from remote. + */ const main = async () => { let redirectingToWebsite = false; try { @@ -216,27 +228,26 @@ export default function PublicCollectionGallery() { } collectionKey.current = ck; url.current = window.location.href; - const localCollection = await getLocalPublicCollection( + const collection = await savedPublicCollectionByKey( collectionKey.current, ); const accessToken = t; let accessTokenJWT: string | undefined; - if (localCollection) { + if (collection) { referralCode.current = await savedLastPublicCollectionReferralCode(); - const sortAsc: boolean = - localCollection?.pubMagicMetadata?.data.asc ?? false; - setPublicCollection(localCollection); - const isPasswordProtected = - localCollection?.publicURLs?.[0]?.passwordEnabled; - setIsPasswordProtected(isPasswordProtected); - const collectionUID = getPublicCollectionUID(accessToken); - const localFiles = - await savedPublicCollectionFiles(accessToken); - const localPublicFiles = sortFiles(localFiles, sortAsc); - setPublicFiles(localPublicFiles); + setPublicCollection(collection); + setIsPasswordProtected( + !!collection.publicURLs[0]?.passwordEnabled, + ); + setPublicFiles( + sortFilesForCollection( + await savedPublicCollectionFiles(accessToken), + collection, + ), + ); accessTokenJWT = - await getLocalPublicCollectionPassword(collectionUID); + await savedPublicCollectionAccessTokenJWT(accessToken); } credentials.current = { accessToken, accessTokenJWT }; downloadManager.setPublicAlbumsCredentials(credentials.current); @@ -293,80 +304,91 @@ export default function PublicCollectionGallery() { * both our local database and component state. */ const publicAlbumsRemotePull = useCallback(async () => { - const collectionUID = getPublicCollectionUID( - credentials.current.accessToken, - ); + const accessToken = credentials.current.accessToken; + showLoadingBar(); + setLoading(true); try { - showLoadingBar(); - setLoading(true); - const [collection, userReferralCode] = await getPublicCollection( - credentials.current.accessToken, - collectionKey.current, - ); + const { collection, referralCode: userReferralCode } = + await pullCollection(accessToken, collectionKey.current); referralCode.current = userReferralCode; setPublicCollection(collection); const isPasswordProtected = - collection?.publicURLs?.[0]?.passwordEnabled; + !!collection.publicURLs[0]?.passwordEnabled; setIsPasswordProtected(isPasswordProtected); setErrorMessage(null); - // Remove the locally saved outdated password token if the sharer - // has disabled password protection on the link. + // Remove the locally cached accessTokenJWT if the sharer has + // disabled password protection on the link. if (!isPasswordProtected && credentials.current.accessTokenJWT) { credentials.current.accessTokenJWT = undefined; downloadManager.setPublicAlbumsCredentials(credentials.current); - savePublicCollectionPassword(collectionUID, null); + removePublicCollectionAccessTokenJWT(accessToken); } - if ( - !isPasswordProtected || - (isPasswordProtected && credentials.current.accessTokenJWT) - ) { + if (isPasswordProtected && !credentials.current.accessTokenJWT) { + await removePublicCollectionFileData(accessToken); + } else { try { - await syncPublicFiles( - credentials.current.accessToken, - credentials.current.accessTokenJWT, + await pullPublicCollectionFiles( + credentials.current, collection, - setPublicFiles, + (files) => + setPublicFiles( + sortFilesForCollection(files, collection), + ), ); } catch (e) { - const parsedError = parseSharingErrorCodes(e); - if (parsedError.message === CustomError.TOKEN_EXPIRED) { - // passwordToken has expired, sharer has changed the password, - // so,clearing local cache token value to prompt user to re-enter password + // If we reached the try block and attempted to pull files, + // it means the accessToken itself is very likely valid + // (since the `pullCollection` succeeded just a moment ago). + // + // So a 401 Unauthorized now indicates that the + // accessTokenJWT is no longer valid since the sharer has + // changed the password. + // + // Clear the locally cached accessTokenJWT and ask the user + // to reenter the password. + if (isHTTP401Error(e)) { credentials.current.accessTokenJWT = undefined; downloadManager.setPublicAlbumsCredentials( credentials.current, ); + } else { + throw e; } } } - - if (isPasswordProtected && !credentials.current.accessTokenJWT) { - await removePublicFiles(collectionUID); - } } catch (e) { - const parsedError = parseSharingErrorCodes(e); + // The 410 Gone or 429 Rate limited can arise from either the + // collection pull or the files pull since they're part of the + // remote's access token check sequence. + // + // In practice, it almost always will be a consequence of the + // collection pull since it happens first. + // + // The 401 Unauthorized can only arise from the collection pull + // since we already handle that separately for the files pull. if ( - parsedError.message === CustomError.TOKEN_EXPIRED || - parsedError.message === CustomError.TOO_MANY_REQUESTS + isHTTPErrorWithStatus(e, 401) || + isHTTPErrorWithStatus(e, 410) || + isHTTPErrorWithStatus(e, 429) ) { setErrorMessage( - parsedError.message === CustomError.TOO_MANY_REQUESTS + isHTTPErrorWithStatus(e, 429) ? t("link_request_limit_exceeded") : t("link_expired_message"), ); - // share has been disabled - // local cache should be cleared - removePublicCollectionWithFiles( - collectionUID, - collectionKey.current, - ); - setPublicCollection(null); - setPublicFiles(null); + // Sharing has been disabled. Clear out local cache. + await removePublicCollectionFileData(accessToken); + await removePublicCollectionByKey(collectionKey.current); + setPublicCollection(undefined); + setPublicFiles(undefined); } else { log.error("Public album remote pull failed", e); + // Don't use the `setErrorMessage`, show a dialog instead, + // because this might be a transient network error. + onGenericError(e); } } finally { hideLoadingBar(); @@ -385,17 +407,18 @@ export default function PublicCollectionGallery() { setFieldError, ) => { try { + const accessToken = credentials.current.accessToken; const accessTokenJWT = await verifyPublicAlbumPassword( publicCollection.publicURLs[0]!, password, - credentials.current.accessToken, + accessToken, ); credentials.current.accessTokenJWT = accessTokenJWT; downloadManager.setPublicAlbumsCredentials(credentials.current); - const collectionUID = getPublicCollectionUID( - credentials.current.accessToken, + await savePublicCollectionAccessTokenJWT( + accessToken, + accessTokenJWT, ); - await savePublicCollectionPassword(collectionUID, accessTokenJWT); } catch (e) { log.error("Failed to verifyLinkPassword", e); if (isHTTP401Error(e)) { @@ -420,6 +443,11 @@ export default function PublicCollectionGallery() { }); }; + const handleUploadFile = (file: EnteFile) => + setPublicFiles( + sortFilesForCollection([...publicFiles, file], publicCollection), + ); + const downloadFilesHelper = async () => { try { const selectedFiles = getSelectedFiles(selected, publicFiles); @@ -538,9 +566,7 @@ export default function PublicCollectionGallery() { uploadTypeSelectorIntent="collect" uploadTypeSelectorView={uploadTypeSelectorView} onRemotePull={publicAlbumsRemotePull} - onUploadFile={(file) => - setPublicFiles(sortFiles([...publicFiles, file])) - } + onUploadFile={handleUploadFile} closeUploadTypeSelector={closeUploadTypeSelectorView} onShowSessionExpiredDialog={showPublicLinkExpiredMessage} {...{ dragAndDropFiles }} @@ -554,6 +580,13 @@ export default function PublicCollectionGallery() { ); } +/** + * Sort the given {@link files} using {@link sortFiles}, using the ascending + * ordering preference if specified in the given {@link collection}'s metadata. + */ +const sortFilesForCollection = (files: EnteFile[], collection?: Collection) => + sortFiles(files, collection?.pubMagicMetadata?.data.asc ?? false); + const EnteLogoLink = styled("a")(({ theme }) => ({ // Remove the excess space at the top. svg: { verticalAlign: "middle" }, diff --git a/web/apps/photos/src/services/publicCollectionService.ts b/web/apps/photos/src/services/publicCollectionService.ts deleted file mode 100644 index 8ade970689..0000000000 --- a/web/apps/photos/src/services/publicCollectionService.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { sharedCryptoWorker } from "ente-base/crypto"; -import log from "ente-base/log"; -import { apiURL } from "ente-base/origins"; -import { sortFiles } from "ente-gallery/utils/file"; -import type { - Collection, - CollectionPublicMagicMetadataData, -} from "ente-media/collection"; -import type { EnteFile, RemoteEnteFile } from "ente-media/file"; -import { decryptRemoteFile } from "ente-media/file"; -import { - savedPublicCollectionFiles, - savedPublicCollections, - saveLastPublicCollectionReferralCode, - savePublicCollectionFiles, -} from "ente-new/albums/services/public-albums-fdb"; -import { CustomError, parseSharingErrorCodes } from "ente-shared/error"; -import HTTPService from "ente-shared/network/HTTPService"; -import localForage from "ente-shared/storage/localForage"; - -const PUBLIC_COLLECTION_FILES_TABLE = "public-collection-files"; -const PUBLIC_COLLECTIONS_TABLE = "public-collections"; - -// Fix this once we can trust the types. -// eslint-disable-next-line @typescript-eslint/no-unnecessary-template-expression -export const getPublicCollectionUID = (token: string) => `${token}`; - -const getPublicCollectionLastSyncTimeKey = (collectionUID: string) => - `public-${collectionUID}-time`; - -const getPublicCollectionPasswordKey = (collectionUID: string) => - `public-${collectionUID}-passkey`; - -export interface LocalSavedPublicCollectionFiles { - collectionUID: string; - files: EnteFile[]; -} - -export const getLocalPublicCollectionPassword = async ( - collectionUID: string, -): Promise => { - return ( - (await localForage.getItem( - getPublicCollectionPasswordKey(collectionUID), - )) || "" - ); -}; - -export const savePublicCollectionPassword = async ( - collectionUID: string, - passToken: string, -): Promise => { - return await localForage.setItem( - getPublicCollectionPasswordKey(collectionUID), - passToken, - ); -}; - -export const getLocalPublicCollection = async (collectionKey: string) => { - const localCollections = await savedPublicCollections(); - const publicCollection = - localCollections.find( - (localSavedPublicCollection) => - localSavedPublicCollection.key === collectionKey, - ) || null; - return publicCollection; -}; - -export const savePublicCollection = async (collection: Collection) => { - const publicCollections = await savedPublicCollections(); - await localForage.setItem( - PUBLIC_COLLECTIONS_TABLE, - dedupeCollections([collection, ...publicCollections]), - ); -}; - -const dedupeCollections = (collections: Collection[]) => { - const keySet = new Set([]); - return collections.filter((collection) => { - if (!keySet.has(collection.key)) { - keySet.add(collection.key); - return true; - } else { - return false; - } - }); -}; - -const getPublicCollectionLastSyncTime = async (collectionUID: string) => - (await localForage.getItem( - getPublicCollectionLastSyncTimeKey(collectionUID), - )) ?? 0; - -const savePublicCollectionLastSyncTime = async ( - collectionUID: string, - time: number, -) => - await localForage.setItem( - getPublicCollectionLastSyncTimeKey(collectionUID), - time, - ); - -export const syncPublicFiles = async ( - token: string, - passwordToken: string, - collection: Collection, - setPublicFiles: (files: EnteFile[]) => void, -) => { - try { - let files: EnteFile[] = []; - const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false; - const collectionUID = getPublicCollectionUID(token); - const localFiles = await savedPublicCollectionFiles(collectionUID); - - files = [...files, ...localFiles]; - try { - if (!token) { - return sortFiles(files, sortAsc); - } - const lastSyncTime = - await getPublicCollectionLastSyncTime(collectionUID); - if (collection.updationTime === lastSyncTime) { - return sortFiles(files, sortAsc); - } - const fetchedFiles = await getPublicFiles( - token, - passwordToken, - collection, - lastSyncTime, - files, - setPublicFiles, - ); - - files = [...files, ...fetchedFiles]; - const latestVersionFiles = new Map(); - files.forEach((file) => { - const uid = `${file.collectionID}-${file.id}`; - if ( - !latestVersionFiles.has(uid) || - latestVersionFiles.get(uid).updationTime < file.updationTime - ) { - latestVersionFiles.set(uid, file); - } - }); - files = []; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, file] of latestVersionFiles) { - // TODO(RE): - if ("isDeleted" in file && file.isDeleted) { - continue; - } - files.push(file); - } - await savePublicCollectionFiles(collectionUID, files); - await savePublicCollectionLastSyncTime( - collectionUID, - collection.updationTime, - ); - setPublicFiles([...sortFiles(files, sortAsc)]); - } catch (e) { - const parsedError = parseSharingErrorCodes(e); - log.error("failed to sync shared collection files", e); - if (parsedError.message === CustomError.TOKEN_EXPIRED) { - throw e; - } - } - return [...sortFiles(files, sortAsc)]; - } catch (e) { - log.error("failed to get local or sync shared collection files", e); - throw e; - } -}; - -const getPublicFiles = async ( - token: string, - passwordToken: string, - collection: Collection, - sinceTime: number, - files: EnteFile[], - setPublicFiles: (files: EnteFile[]) => void, -): Promise => { - try { - let decryptedFiles: EnteFile[] = []; - let time = sinceTime; - let resp; - const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false; - do { - if (!token) { - break; - } - resp = await HTTPService.get( - await apiURL("/public-collection/diff"), - { sinceTime: time }, - { - "X-Auth-Access-Token": token, - ...(passwordToken && { - "X-Auth-Access-Token-JWT": passwordToken, - }), - }, - ); - decryptedFiles = [ - ...decryptedFiles, - ...(await Promise.all( - resp.data.diff.map(async (file: RemoteEnteFile) => { - if (!file.isDeleted) { - return await decryptRemoteFile( - file, - collection.key, - ); - } else { - return file; - } - }) as Promise[], - )), - ]; - - if (resp.data.diff.length) { - time = resp.data.diff.slice(-1)[0].updationTime; - } - setPublicFiles( - sortFiles( - [...(files || []), ...decryptedFiles].filter( - // TODO(RE): - // (item) => !item.isDeleted, - (file) => !("isDeleted" in file && file.isDeleted), - ), - sortAsc, - ), - ); - } while (resp.data.hasMore); - return decryptedFiles; - } catch (e) { - log.error("Get public files failed", e); - throw e; - } -}; - -export interface MagicMetadataCore { - version: number; - count: number; - header: string; - data: T; -} - -export const getPublicCollection = async ( - token: string, - collectionKey: string, -): Promise<[Collection, string]> => { - try { - if (!token) { - return; - } - const resp = await HTTPService.get( - await apiURL("/public-collection/info"), - null, - { "X-Auth-Access-Token": token }, - ); - const fetchedCollection = resp.data.collection; - const referralCode = resp.data.referralCode ?? ""; - - const cryptoWorker = await sharedCryptoWorker(); - - const collectionName = (fetchedCollection.name = - fetchedCollection.name || - new TextDecoder().decode( - await cryptoWorker.decryptBoxBytes( - { - encryptedData: fetchedCollection.encryptedName, - nonce: fetchedCollection.nameDecryptionNonce, - }, - collectionKey, - ), - )); - - let collectionPublicMagicMetadata: MagicMetadataCore; - if (fetchedCollection.pubMagicMetadata?.data) { - collectionPublicMagicMetadata = { - ...fetchedCollection.pubMagicMetadata, - data: await cryptoWorker.decryptMetadataJSON( - { - encryptedData: fetchedCollection.pubMagicMetadata.data, - decryptionHeader: - fetchedCollection.pubMagicMetadata.header, - }, - collectionKey, - ), - }; - } - - const collection = { - ...fetchedCollection, - name: collectionName, - key: collectionKey, - pubMagicMetadata: collectionPublicMagicMetadata, - }; - await savePublicCollection(collection); - await saveLastPublicCollectionReferralCode(referralCode); - return [collection, referralCode]; - } catch (e) { - log.error("failed to get public collection", e); - throw e; - } -}; - -export const removePublicCollectionWithFiles = async ( - collectionUID: string, - collectionKey: string, -) => { - const publicCollections = await savedPublicCollections(); - await localForage.setItem( - PUBLIC_COLLECTIONS_TABLE, - publicCollections.filter( - (collection) => collection.key !== collectionKey, - ), - ); - await removePublicFiles(collectionUID); -}; - -export const removePublicFiles = async (collectionUID: string) => { - await localForage.removeItem(getPublicCollectionPasswordKey(collectionUID)); - await localForage.removeItem( - getPublicCollectionLastSyncTimeKey(collectionUID), - ); - - const publicCollectionFiles = - (await localForage.getItem( - PUBLIC_COLLECTION_FILES_TABLE, - )) ?? []; - await localForage.setItem( - PUBLIC_COLLECTION_FILES_TABLE, - publicCollectionFiles.filter( - (collectionFiles) => - collectionFiles.collectionUID !== collectionUID, - ), - ); -}; diff --git a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx index 73d0b529ba..f5ed68553a 100644 --- a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx +++ b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx @@ -8,10 +8,10 @@ import { LoadingButton } from "ente-base/components/mui/LoadingButton"; import { ShowHidePasswordInputAdornment } from "ente-base/components/mui/PasswordInputAdornment"; import { sharedCryptoWorker } from "ente-base/crypto"; import log from "ente-base/log"; -import { CustomError } from "ente-shared/error"; import { useFormik } from "formik"; import { t } from "i18next"; import { useCallback, useState } from "react"; +import { twoFactorEnabledErrorMessage } from "./utils/second-factor-choice"; export interface VerifyMasterPasswordFormProps { /** @@ -30,9 +30,9 @@ export interface VerifyMasterPasswordFormProps { * used for reauthenticating the user after they've already logged in, then * this function will not be provided. * - * This function can throw an `CustomError.TWO_FACTOR_ENABLED` to signal to - * the form that some other form of second factor is enabled and the user - * has been redirected to a two factor verification page. + * @throws A Error with message {@link twoFactorEnabledErrorMessage} to + * signal to the form that some other form of second factor is enabled and + * the user has been redirected to a two factor verification page. * * @throws A Error with message * {@link srpVerificationUnauthorizedErrorMessage} to signal that either @@ -154,7 +154,7 @@ export const VerifyMasterPasswordForm: React.FC< } catch (e) { if (e instanceof Error) { switch (e.message) { - case CustomError.TWO_FACTOR_ENABLED: + case twoFactorEnabledErrorMessage: // Two factor enabled, user has been redirected to // the two-factor verification page. return; diff --git a/web/packages/accounts/components/utils/second-factor-choice.ts b/web/packages/accounts/components/utils/second-factor-choice.ts index 1557255ae3..e2e2ca19a3 100644 --- a/web/packages/accounts/components/utils/second-factor-choice.ts +++ b/web/packages/accounts/components/utils/second-factor-choice.ts @@ -8,6 +8,15 @@ import { useModalVisibility } from "ente-base/components/utils/modal"; import { useCallback, useMemo, useRef } from "react"; import type { SecondFactorType } from "../SecondFactorChoice"; +/** + * The message of the {@link Error} that is thrown when the user has enabled a + * second factor so further authentication is needed during the login sequence. + * + * TODO: This is not really an error but rather is a code flow flag; consider + * not using exceptions for flow control. + */ +export const twoFactorEnabledErrorMessage = "two factor enabled"; + /** * A convenience hook for keeping track of the state and logic that is needed * after password verification to determine which second factor (if any) we diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index c03b269ae0..975437b049 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -6,7 +6,10 @@ import { } from "ente-accounts/components/LoginComponents"; import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice"; import { sessionExpiredDialogAttributes } from "ente-accounts/components/utils/dialog"; -import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice"; +import { + twoFactorEnabledErrorMessage, + useSecondFactorChoiceIfNeeded, +} from "ente-accounts/components/utils/second-factor-choice"; import { VerifyMasterPasswordForm, type VerifyMasterPasswordFormProps, @@ -47,7 +50,6 @@ import { unstashKeyEncryptionKeyFromSession, updateSessionFromElectronSafeStorageIfNeeded, } from "ente-base/session"; -import { CustomError } from "ente-shared/error"; import { getData, setData, setLSUser } from "ente-shared/storage/localStorage"; import { getToken, @@ -205,7 +207,7 @@ const Page: React.FC = () => { ); setPasskeyVerificationData({ passkeySessionID, url }); openPasskeyVerificationURL({ passkeySessionID, url }); - throw Error(CustomError.TWO_FACTOR_ENABLED); + throw new Error(twoFactorEnabledErrorMessage); } else if (twoFactorSessionID) { await stashKeyEncryptionKeyInSessionStore(kek); const user = getData("user"); @@ -215,7 +217,7 @@ const Page: React.FC = () => { isTwoFactorEnabled: true, }); void router.push("/two-factor/verify"); - throw Error(CustomError.TWO_FACTOR_ENABLED); + throw new Error(twoFactorEnabledErrorMessage); } else { const user = getData("user"); await setLSUser({ @@ -231,7 +233,7 @@ const Page: React.FC = () => { } catch (e) { if ( e instanceof Error && - e.message != CustomError.TWO_FACTOR_ENABLED + e.message != twoFactorEnabledErrorMessage ) { log.error("getKeyAttributes failed", e); } diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index c8f8b26ad0..fba3a9ad1e 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -1,5 +1,4 @@ import { Box, Typography } from "@mui/material"; -import { HttpStatusCode } from "axios"; import { AccountsPageContents, AccountsPageFooter, @@ -39,7 +38,6 @@ import { isDevBuild } from "ente-base/env"; import { isHTTPErrorWithStatus } from "ente-base/http"; import log from "ente-base/log"; import { clearSessionStorage } from "ente-base/session"; -import { ApiError } from "ente-shared/error"; import localForage from "ente-shared/storage/localForage"; import { getData, setData, setLSUser } from "ente-shared/storage/localStorage"; import { @@ -169,14 +167,6 @@ const Page: React.FC = () => { setFieldError(t("invalid_code_error")); } else if (isHTTPErrorWithStatus(e, 410)) { setFieldError(t("expired_code_error")); - } else if (e instanceof ApiError) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (e?.httpStatusCode === HttpStatusCode.Unauthorized) { - setFieldError(t("invalid_code_error")); - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - } else if (e?.httpStatusCode === HttpStatusCode.Gone) { - setFieldError(t("expired_code_error")); - } } else { log.error("OTT verification failed", e); throw e; diff --git a/web/packages/media/file.ts b/web/packages/media/file.ts index 3eb09a229f..12ab7bbed4 100644 --- a/web/packages/media/file.ts +++ b/web/packages/media/file.ts @@ -322,6 +322,7 @@ export type RemoteEnteFile = z.infer; * a provided timestamp. * * - "/collections/v2/diff" + * - "/public-collection/diff" * - "/cast/diff" */ export const FileDiffResponse = z.object({ diff --git a/web/packages/new/albums/services/public-albums-fdb.ts b/web/packages/new/albums/services/public-albums-fdb.ts index 7c25ff2502..5a20984e1b 100644 --- a/web/packages/new/albums/services/public-albums-fdb.ts +++ b/web/packages/new/albums/services/public-albums-fdb.ts @@ -5,6 +5,7 @@ import { LocalCollections, LocalEnteFiles, + LocalTimestamp, transformFilesIfNeeded, } from "ente-gallery/services/files-db"; import { type Collection } from "ente-media/collection"; @@ -18,7 +19,7 @@ import { z } from "zod/v4"; * * Use {@link savePublicCollections} to update the database. */ -export const savedPublicCollections = async (): Promise => +const savedPublicCollections = async (): Promise => // TODO: // // See: [Note: strict mode migration] @@ -34,10 +35,57 @@ export const savedPublicCollections = async (): Promise => * * This is the setter corresponding to {@link savedPublicCollections}. */ -export const savePublicCollections = (collections: Collection[]) => +const savePublicCollections = (collections: Collection[]) => localForage.setItem("public-collections", collections); -const LocalReferralCode = z.string().nullish().transform(nullToUndefined); +/** + * Return the saved public collection with the given {@link key} if present in + * our local database. + * + * Use {@link savePublicCollection} to save collections in our local database. + * + * @param key The collection key that can be used to identify the public album + * we want from amongst all the locally saved public albums. + */ +export const savedPublicCollectionByKey = async ( + collectionKey: string, +): Promise => + savedPublicCollections().then((cs) => + cs.find((c) => c.key == collectionKey), + ); + +/** + * Save a public collection to our local database. + * + * The collection can later be retrieved using {@link savedPublicCollection}. + * The collection can be removed using {@link removePublicCollection}. + */ +export const savePublicCollection = async (collection: Collection) => { + const collections = await savedPublicCollections(); + await savePublicCollections([ + collection, + ...collections.filter((c) => c.id != collection.id), + ]); +}; + +/** + * Remove a public collection, identified using its collection key, from our + * local database. + * + * @param key The collection key that can be used to identify the public album + * we want to remove. + */ +export const removePublicCollectionByKey = async (collectionKey: string) => { + const collections = await savedPublicCollections(); + await savePublicCollections([ + ...collections.filter((c) => c.key != collectionKey), + ]); +}; + +/** + * Zod schema for a nullish string, with `null` transformed to `undefined`. + */ +const LocalString = z.string().nullish().transform(nullToUndefined); /** * Return the last saved referral code present in our local database. @@ -55,7 +103,7 @@ const LocalReferralCode = z.string().nullish().transform(nullToUndefined); * out a new value using {@link saveLastPublicCollectionReferralCode}. */ export const savedLastPublicCollectionReferralCode = async () => - LocalReferralCode.parse(await localForage.getItem("public-referral-code")); + LocalString.parse(await localForage.getItem("public-referral-code")); /** * Update the referral code present in our local database. @@ -83,24 +131,35 @@ type LocalSavedPublicCollectionFilesEntry = z.infer< typeof LocalSavedPublicCollectionFilesEntry >; -// A purely synactic and local alias to avoid the code from looking scary. -type ES = LocalSavedPublicCollectionFilesEntry[]; - /** * Return all files for a public collection present in our local database. * - * Use {@link savePublicCollectionFiles} to update the database. + * Use {@link savePublicCollectionFiles} to update the list of files in the + * database, and {@link removePublicCollectionFiles} to remove them. * - * @param accessToken The access token of the public album whose files we want. + * @param accessToken The access token that identifies the public album whose + * files we want. */ export const savedPublicCollectionFiles = async ( accessToken: string, ): Promise => { + const entry = (await pcfEntries()).find( + (e) => e.collectionUID == accessToken, + ); + return transformFilesIfNeeded(entry ? entry.files : []); +}; + +/** + * A convenience routine to read the DB entries for "public-collection-files". + */ +const pcfEntries = async () => { + // A local alias to avoid the code from looking scary. + type ES = LocalSavedPublicCollectionFilesEntry[]; + // See: [Note: Avoiding Zod parsing for large DB arrays] for why we use an // (implied) cast here instead of parsing using the Zod schema. const entries = await localForage.getItem("public-collection-files"); - const entry = (entries ?? []).find((e) => e.collectionUID == accessToken); - return transformFilesIfNeeded(entry ? entry.files : []); + return entries ?? []; }; /** @@ -108,8 +167,8 @@ export const savedPublicCollectionFiles = async ( * * This is the setter corresponding to {@link savedPublicCollectionFiles}. * - * @param accessToken The access token of the public album whose files we want - * to replace. + * @param accessToken The access token that identifies the public album whose + * files we want to update. * * @param files The files to save. */ @@ -117,15 +176,99 @@ export const savePublicCollectionFiles = async ( accessToken: string, files: EnteFile[], ): Promise => { - // See: [Note: Avoiding Zod parsing for large DB arrays]. - const entries = await localForage.getItem("public-collection-files"); await localForage.setItem("public-collection-files", [ { collectionUID: accessToken, files }, - ...(entries ?? []).filter((e) => e.collectionUID != accessToken), + ...(await pcfEntries()).filter((e) => e.collectionUID != accessToken), ]); }; -const LocalUploaderName = z.string().nullish().transform(nullToUndefined); +/** + * Remove the list of files, in any, in our local database for the given + * collection (identified by its {@link accessToken}). + */ +export const removePublicCollectionFiles = async ( + accessToken: string, +): Promise => { + await localForage.setItem("public-collection-files", [ + ...(await pcfEntries()).filter((e) => e.collectionUID != accessToken), + ]); +}; + +/** + * Return the locally persisted "last sync time" for a public collection that we + * have pulled from remote. This can be used to perform a paginated delta pull + * from the saved time onwards. + * + * Use {@link savePublic CollectionLastSyncTime} to update the value saved in + * the database, and {@link removePublicCollectionLastSyncTime} to remove the + * saved value from the database. + * + * @param accessToken The access token that identifies the public album whose + * last sync time we want. + */ +export const savedPublicCollectionLastSyncTime = async (accessToken: string) => + LocalTimestamp.parse( + await localForage.getItem(`public-${accessToken}-time`), + ); + +/** + * Update the locally persisted timestamp that will be returned by subsequent + * calls to {@link savedPublicCollectionLastSyncTime}. + */ +export const savePublicCollectionLastSyncTime = async ( + accessToken: string, + time: number, +) => { + await localForage.setItem(`public-${accessToken}-time`, time); +}; + +/** + * Remove the locally persisted timestamp, if any, previously saved for a + * collection using {@link savedPublicCollectionLastSyncTime}. + */ +export const removePublicCollectionLastSyncTime = async ( + accessToken: string, +) => { + await localForage.removeItem(`public-${accessToken}-time`); +}; + +/** + * Return the access token JWT, if any, present in our local database for the + * given public collection (as identified by its {@link accessToken}). + * + * Use {@link savePublicCollectionAccessTokenJWT} to save the value, and + * {@link removePublicCollectionAccessTokenJWT} to remove it. + */ +export const savedPublicCollectionAccessTokenJWT = async ( + accessToken: string, +) => + LocalString.parse( + await localForage.getItem(`public-${accessToken}-passkey`), + ); + +/** + * Update the access token JWT in our local database for the given public + * collection (as identified by its {@link accessToken}). + * + * This is the setter corresponding to + * {@link savedPublicCollectionAccessTokenJWT}. + */ +export const savePublicCollectionAccessTokenJWT = async ( + accessToken: string, + passwordJWT: string, +) => { + await localForage.setItem(`public-${accessToken}-passkey`, passwordJWT); +}; + +/** + * Remove the access token JWT in our local database for the given public + * collection (as identified by its {@link accessToken}). + */ +export const removePublicCollectionAccessTokenJWT = async ( + accessToken: string, +) => { + await localForage.removeItem(`public-${accessToken}-passkey`); +}; /** * Return the previously saved uploader name, if any, present in our local @@ -143,11 +286,11 @@ const LocalUploaderName = z.string().nullish().transform(nullToUndefined); * public collection, in the local database so that it can prefill it the next * time there is an upload from the same client. * - * @param accessToken The access token of the public album whose persisted - * uploader name we we want. + * @param accessToken The access token that identifies the public album whose + * saved uploader name we want. */ export const savedPublicCollectionUploaderName = async (accessToken: string) => - LocalUploaderName.parse( + LocalString.parse( await localForage.getItem(`public-${accessToken}-uploaderName`), ); diff --git a/web/packages/new/albums/services/public-collection.ts b/web/packages/new/albums/services/public-collection.ts index 83100af109..5000f364b0 100644 --- a/web/packages/new/albums/services/public-collection.ts +++ b/web/packages/new/albums/services/public-collection.ts @@ -2,10 +2,32 @@ import { deriveKey } from "ente-base/crypto"; import { authenticatedPublicAlbumsRequestHeaders, ensureOk, + type PublicAlbumsCredentials, } from "ente-base/http"; import { apiURL } from "ente-base/origins"; -import { RemoteCollection, type PublicURL } from "ente-media/collection"; +import { + decryptRemoteCollection, + RemoteCollection, + type Collection, + type PublicURL, +} from "ente-media/collection"; +import { + decryptRemoteFile, + FileDiffResponse, + type EnteFile, +} from "ente-media/file"; import { z } from "zod/v4"; +import { + removePublicCollectionAccessTokenJWT, + removePublicCollectionFiles, + removePublicCollectionLastSyncTime, + savedPublicCollectionFiles, + savedPublicCollectionLastSyncTime, + saveLastPublicCollectionReferralCode, + savePublicCollection, + savePublicCollectionFiles, + savePublicCollectionLastSyncTime, +} from "./public-albums-fdb"; /** * Verify with remote that the password entered by the user is the same as the @@ -54,8 +76,45 @@ export const verifyPublicAlbumPassword = async ( return z.object({ jwtToken: z.string() }).parse(await res.json()).jwtToken; }; -// TODO(RE): Use me -export const PublicCollectionInfo = z.object({ +/** + * Fetch a public collection from remote using its access key, decrypt it using + * the provided key, save the collection in our local database for subsequent + * use, and return it. + * + * This function modifies local state. + * + * @param accessToken A public collection access key obtained from the "t=" + * query parameter of the public URL. + * + * The access key serves to both identify the public collection, and also + * authenticate the request. See: [Note: Public album access token]. + * + * @param collectionKey The base64 encoded key that can be used to decrypt the + * collection obtained from remote. + * + * The collection key is obtained from the fragment portion of the public URL + * (the fragment is a client side only portion that can be used to have local + * secrets that are not sent by the browser to the server). + */ +export const pullCollection = async ( + accessToken: string, + collectionKey: string, +): Promise<{ collection: Collection; referralCode: string }> => { + const { collection: remoteCollection, referralCode } = + await getPublicCollectionInfo(accessToken); + + const collection = await decryptRemoteCollection( + remoteCollection, + collectionKey, + ); + + await savePublicCollection(collection); + await saveLastPublicCollectionReferralCode(referralCode); + + return { collection, referralCode }; +}; + +const PublicCollectionInfo = z.object({ collection: RemoteCollection, /** * A referral code of the owner of the public album. @@ -68,3 +127,126 @@ export const PublicCollectionInfo = z.object({ */ referralCode: z.string(), }); + +/** + * Fetch information from remote about a public collection using its access key. + * + * Remote only, does not modify local state. + * + * @param accessToken A public collection access key. + */ +const getPublicCollectionInfo = async (accessToken: string) => { + const res = await fetch(await apiURL("/public-collection/info"), { + headers: authenticatedPublicAlbumsRequestHeaders({ accessToken }), + }); + ensureOk(res); + return PublicCollectionInfo.parse(await res.json()); +}; + +/** + * Pull any changes to the files belonging to the given collection, updating our + * local database and also calling the provided callback. + * + * This function modifies local state. + * + * The pull uses a persisted timestamp for the most recent change we've already + * fetched, and will be only fetch the delta of changes since the last pull. The + * files are fetched in a paginated manner, so the provided callback can get + * called multiple times during the pull (one for each page). + * + * @param credentials A public collection access key and an optional password + * unlocked access token JWT. The credentials serve to both identify the + * collection, and authenticate the request. + * + * @param collection The public collection corresponding to the credentials. + * + * This function assumes that collection has already been pulled from remote and + * is at its latest, remote, value. This assumption is used to skip fetching + * files if the collection has not changed on remote (any updates to the files + * will also increase the updation time of the collection that contains them). + * + * @param onSetFiles A callback that is invoked each time a new batch of updates + * to the collection's files is fetched and processed. The callback is called + * the consolidated list of files after applying the updates received so far. + * + * The provided files are in an arbitrary order, and must be sorted before use. + * + * This callback can get called multiple times during the pull. The callback can + * also never get called if no changes were pulled (or needed to be pulled). + */ +export const pullPublicCollectionFiles = async ( + credentials: PublicAlbumsCredentials, + collection: Collection, + onSetFiles: (files: EnteFile[]) => void, +) => { + const { accessToken } = credentials; + + let sinceTime = (await savedPublicCollectionLastSyncTime(accessToken)) ?? 0; + + // Prior to reaching here, we would've already fetched the latest + // collection. If the updation time of the collection is the same as the + // last sync time, then we know there were no new updates (since updates to + // files also increase the updation time of their containing collection). + if (sinceTime == collection.updationTime) return; + + const files = await savedPublicCollectionFiles(accessToken); + const filesByID = new Map(files.map((f) => [f.id, f])); + + while (true) { + const { diff, hasMore } = await getPublicCollectionDiff( + credentials, + sinceTime, + ); + if (!diff.length) break; + for (const change of diff) { + sinceTime = Math.max(sinceTime, change.updationTime); + if (change.isDeleted) { + filesByID.delete(change.id); + } else { + filesByID.set( + change.id, + await decryptRemoteFile(change, collection.key), + ); + } + } + + const files = [...filesByID.values()]; + await savePublicCollectionFiles(accessToken, files); + await savePublicCollectionLastSyncTime(accessToken, sinceTime); + onSetFiles(files); + + if (!hasMore) break; + } +}; + +/** + * Fetch the public collection diff to obtain updates to the collection + * (identified by its {@link credentials}) since {@link sinceTime}. + * + * Remote only, does not modify local state. + */ +const getPublicCollectionDiff = async ( + credentials: PublicAlbumsCredentials, + sinceTime: number, +) => { + const res = await fetch( + await apiURL("/public-collection/diff", { sinceTime }), + { headers: authenticatedPublicAlbumsRequestHeaders(credentials) }, + ); + ensureOk(res); + return FileDiffResponse.parse(await res.json()); +}; + +/** + * Remove the files, sync time and accessTokenJWT associated with the given + * collection (identified by its {@link accessToken}). + * + * This function modifies local state. + */ +export const removePublicCollectionFileData = async (accessToken: string) => { + await Promise.all([ + removePublicCollectionAccessTokenJWT(accessToken), + removePublicCollectionLastSyncTime(accessToken), + removePublicCollectionFiles(accessToken), + ]); +}; diff --git a/web/packages/new/photos/components/Export.tsx b/web/packages/new/photos/components/Export.tsx index d008f00289..2fa2f6d42f 100644 --- a/web/packages/new/photos/components/Export.tsx +++ b/web/packages/new/photos/components/Export.tsx @@ -39,7 +39,6 @@ import log from "ente-base/log"; import { type EnteFile } from "ente-media/file"; import { fileFileName } from "ente-media/file-metadata"; import { ItemCard, PreviewItemTile } from "ente-new/photos/components/Tiles"; -import { CustomError } from "ente-shared/error"; import { t } from "i18next"; import React, { memo, useCallback, useEffect, useState } from "react"; import { Trans } from "react-i18next"; @@ -50,6 +49,7 @@ import { type ListItemKeySelector, } from "react-window"; import exportService, { + CustomError, ExportStage, selectAndPrepareExportDirectory, type ExportOpts, diff --git a/web/packages/new/photos/services/export.ts b/web/packages/new/photos/services/export.ts index c8df22cedb..aef25fb6e3 100644 --- a/web/packages/new/photos/services/export.ts +++ b/web/packages/new/photos/services/export.ts @@ -31,13 +31,19 @@ import { safeDirectoryName, safeFileName, } from "ente-new/photos/utils/native-fs"; -import { CustomError } from "ente-shared/error"; import { getData, setData } from "ente-shared/storage/localStorage"; import { PromiseQueue } from "ente-utils/promise"; import i18n from "i18next"; import { migrateExport, type ExportRecord } from "./export-migration"; import { savedCollectionFiles, savedCollections } from "./photos-fdb"; +// TODO: Audit the uses of these constants +export const CustomError = { + UPDATE_EXPORTED_RECORD_FAILED: "update file exported record failed", + EXPORT_STOPPED: "export stopped", + EXPORT_FOLDER_DOES_NOT_EXIST: "export folder does not exist", +}; + /** Name of the JSON file in which we keep the state of the export. */ const exportRecordFileName = "export_status.json"; diff --git a/web/packages/new/photos/services/photos-fdb.ts b/web/packages/new/photos/services/photos-fdb.ts index 8c549c2a2c..4c5329d94e 100644 --- a/web/packages/new/photos/services/photos-fdb.ts +++ b/web/packages/new/photos/services/photos-fdb.ts @@ -188,8 +188,12 @@ export const saveCollectionFiles = async (files: EnteFile[]) => { }; /** - * Return the locally persisted {@link updationTime} of the latest file from the - * given {@link collection} that we have pulled from remote. + * Return the locally persisted "last sync time" for a collection that we have + * pulled from remote. This can be used to perform a paginated delta pull from + * the saved time onwards. + * + * > Specifically, this is the {@link updationTime} of the latest file from the + * > {@link collection}, or the the collection itself if it is fully synced. * * Use {@link saveCollectionLastSyncTime} to update the value saved in the * database, and {@link removeCollectionIDLastSyncTime} to remove the saved diff --git a/web/packages/new/photos/services/pull.ts b/web/packages/new/photos/services/pull.ts index 485b6d9d3a..21e276a6ca 100644 --- a/web/packages/new/photos/services/pull.ts +++ b/web/packages/new/photos/services/pull.ts @@ -79,7 +79,8 @@ interface PullFilesOpts { } /** - * Pull the latest collections, collections files and trash items from remote. + * Pull the latest collections, collections files and trash items from remote, + * updating our local database and also calling the provided callbacks. * * This is a subset of a full remote pull, independently exposed for use at * times when we only want to pull the file related information (e.g. we just diff --git a/web/packages/shared/error/index.ts b/web/packages/shared/error/index.ts deleted file mode 100644 index 788df9acb1..0000000000 --- a/web/packages/shared/error/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { HttpStatusCode } from "axios"; - -export interface ApiErrorResponse { - code: string; - message: string; -} - -export class ApiError extends Error { - httpStatusCode: number; - errCode: string; - - constructor(message: string, errCode: string, httpStatus: number) { - super(message); - this.name = "ApiError"; - this.errCode = errCode; - this.httpStatusCode = httpStatus; - } -} - -export function isApiErrorResponse(object: any): object is ApiErrorResponse { - return object && "code" in object && "message" in object; -} - -export const CustomError = { - TOKEN_EXPIRED: "token expired", - TOO_MANY_REQUESTS: "too many requests", - BAD_REQUEST: "bad request", - SUBSCRIPTION_NEEDED: "subscription not present", - NOT_FOUND: "not found ", - UPDATE_EXPORTED_RECORD_FAILED: "update file exported record failed", - EXPORT_STOPPED: "export stopped", - EXPORT_FOLDER_DOES_NOT_EXIST: "export folder does not exist", - TWO_FACTOR_ENABLED: "two factor enabled", -}; - -export const parseSharingErrorCodes = (error: any) => { - let parsedMessage = null; - if (error instanceof ApiError) { - switch (error.httpStatusCode) { - case HttpStatusCode.BadRequest: - parsedMessage = CustomError.BAD_REQUEST; - break; - case HttpStatusCode.PaymentRequired: - parsedMessage = CustomError.SUBSCRIPTION_NEEDED; - break; - case HttpStatusCode.NotFound: - parsedMessage = CustomError.NOT_FOUND; - break; - case HttpStatusCode.Unauthorized: - case HttpStatusCode.Gone: - parsedMessage = CustomError.TOKEN_EXPIRED; - break; - case HttpStatusCode.TooManyRequests: - parsedMessage = CustomError.TOO_MANY_REQUESTS; - break; - default: - parsedMessage = `Something went wrong (statusCode:${error.httpStatusCode})`; - } - } else { - parsedMessage = error.message; - } - return new Error(parsedMessage); -}; diff --git a/web/packages/shared/network/HTTPService.ts b/web/packages/shared/network/HTTPService.ts deleted file mode 100644 index 66c5851317..0000000000 --- a/web/packages/shared/network/HTTPService.ts +++ /dev/null @@ -1,223 +0,0 @@ -import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; -import log from "ente-base/log"; -import { ApiError, isApiErrorResponse } from "../error"; - -type IHTTPHeaders = Record; - -type IQueryPrams = Record; - -/** - * Service to manage all HTTP calls. - */ -class HTTPService { - constructor() { - axios.interceptors.response.use( - (response) => Promise.resolve(response), - (error) => { - const config = error.config as AxiosRequestConfig; - if (error.response) { - const response = error.response as AxiosResponse; - let apiError: ApiError; - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - if (isApiErrorResponse(response.data)) { - const responseData = response.data; - log.error( - `HTTP Service Error - ${JSON.stringify({ - url: config?.url, - method: config?.method, - xRequestId: response.headers["x-request-id"], - httpStatus: response.status, - errMessage: responseData.message, - errCode: responseData.code, - })}`, - error, - ); - apiError = new ApiError( - responseData.message, - responseData.code, - response.status, - ); - } else { - if (response.status >= 400 && response.status < 500) { - apiError = new ApiError( - "client error", - "", - response.status, - ); - } else { - apiError = new ApiError( - "server error", - "", - response.status, - ); - } - } - log.error( - `HTTP Service Error - ${JSON.stringify({ - url: config.url, - method: config.method, - cfRay: response.headers["cf-ray"], - xRequestId: response.headers["x-request-id"], - httpStatus: response.status, - })}`, - apiError, - ); - throw apiError; - } else if (error.request) { - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - log.info( - `request failed - no response (${config.method} ${config.url}`, - ); - return Promise.reject(error); - } else { - // Something happened in setting up the request that - // triggered an Error - log.info( - `request failed - axios error (${config.method} ${config.url}`, - ); - return Promise.reject(error); - } - }, - ); - } - - /** - * header object to be append to all api calls. - */ - private headers: IHTTPHeaders = { "content-type": "application/json" }; - - /** - * Sets the headers to the given object. - */ - public setHeaders(headers: IHTTPHeaders) { - this.headers = { ...this.headers, ...headers }; - } - - /** - * Adds a header to list of headers. - */ - public appendHeader(key: string, value: string) { - this.headers = { ...this.headers, [key]: value }; - } - - /** - * Removes the given header. - */ - public removeHeader(key: string) { - this.headers[key] = undefined; - } - - /** - * Returns axios interceptors. - */ - public getInterceptors() { - return axios.interceptors; - } - - /** - * Generic HTTP request. - * This is done so that developer can use any functionality - * provided by axios. Here, only the set headers are spread - * over what was sent in config. - */ - public async request(config: AxiosRequestConfig, customConfig?: any) { - config.headers = { - ...this.headers, - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...config.headers, - }; - if (customConfig?.cancel) { - config.cancelToken = new axios.CancelToken( - (c) => (customConfig.cancel.exec = c), - ); - } - return await axios({ ...config, ...customConfig }); - } - - /** - * Get request. - */ - public get( - url: string, - params?: IQueryPrams, - headers?: IHTTPHeaders, - customConfig?: any, - ) { - return this.request( - { headers, method: "GET", params, url }, - customConfig, - ); - } - - /** - * Post request - */ - public post( - url: string, - data?: any, - params?: IQueryPrams, - headers?: IHTTPHeaders, - customConfig?: any, - ) { - return this.request( - { data, headers, method: "POST", params, url }, - customConfig, - ); - } - - /** - * Patch request - */ - public patch( - url: string, - data?: any, - params?: IQueryPrams, - headers?: IHTTPHeaders, - customConfig?: any, - ) { - return this.request( - { data, headers, method: "PATCH", params, url }, - customConfig, - ); - } - - /** - * Put request - */ - public put( - url: string, - data: any, - params?: IQueryPrams, - headers?: IHTTPHeaders, - customConfig?: any, - ) { - return this.request( - { data, headers, method: "PUT", params, url }, - customConfig, - ); - } - - /** - * Delete request - */ - public delete( - url: string, - data: any, - params?: IQueryPrams, - headers?: IHTTPHeaders, - customConfig?: any, - ) { - return this.request( - { data, headers, method: "DELETE", params, url }, - customConfig, - ); - } -} - -// Creates a Singleton Service. -// This will help me maintain common headers / functionality -// at a central place. -export default new HTTPService(); diff --git a/web/packages/shared/package.json b/web/packages/shared/package.json index afe14de210..1a016ce083 100644 --- a/web/packages/shared/package.json +++ b/web/packages/shared/package.json @@ -3,7 +3,6 @@ "version": "0.0.0", "private": true, "dependencies": { - "axios": "^1.9.0", "ente-base": "*" }, "devDependencies": { diff --git a/web/yarn.lock b/web/yarn.lock index 17d47637a6..e717930e12 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1469,11 +1469,6 @@ arraybuffer.prototype.slice@^1.0.4: get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - attr-accept@^2.2.2: version "2.2.5" resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" @@ -1486,15 +1481,6 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.9.0.tgz#25534e3b72b54540077d33046f77e3b8d7081901" - integrity sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg== - dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - babel-plugin-macros@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1" @@ -1670,13 +1656,6 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - comlink@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.2.tgz#cbbcd82742fbebc06489c28a183eedc5c60a2bca" @@ -1817,11 +1796,6 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - detect-indent@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-7.0.1.tgz#cbb060a12842b9c4d333f1cac4aa4da1bb66bc25" @@ -2287,11 +2261,6 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.2.tgz#adba1448a9841bec72b42c532ea23dbbedef1a27" integrity sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA== -follow-redirects@^1.15.6: - version "1.15.9" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" - integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== - for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -2299,15 +2268,6 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" -form-data@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48" - integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - formik@^2.4.6: version "2.4.6" resolved "https://registry.yarnpkg.com/formik/-/formik-2.4.6.tgz#4da75ca80f1a827ab35b08fd98d5a76e928c9686" @@ -3051,18 +3011,6 @@ micromatch@^4.0.4: braces "^3.0.3" picomatch "^2.3.1" -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - mimic-function@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" @@ -3397,11 +3345,6 @@ prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"