Merge branch 'main' of https://github.com/ente-io/auth into release_mob_jun_25

This commit is contained in:
Neeraj Gupta
2025-07-02 14:13:50 +05:30
38 changed files with 662 additions and 956 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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"
]
},
{

View File

@@ -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<void> _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);
}

View File

@@ -65,9 +65,9 @@ class AlbumHomeWidgetService {
await _prefs.setString(ALBUMS_LAST_HASH_KEY, hash);
}
Future<void> initAlbumHomeWidget() async {
Future<void> 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<void> _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<Collection> getAlbumsByIds(List<int> 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<bool> _hasAnyBlockers() async {
Future<bool> _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<void> _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<List<int>> _getEffectiveSelectedAlbumIds() async {
Future<List<int>> _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];
}
}

View File

@@ -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<void> initHomeWidget() async {
await MemoryHomeWidgetService.instance.initMemoryHomeWidget();
Future<void> initHomeWidget([bool isBg = false]) async {
await AlbumHomeWidgetService.instance.initAlbumHomeWidget(isBg);
await PeopleHomeWidgetService.instance.initPeopleHomeWidget();
await AlbumHomeWidgetService.instance.initAlbumHomeWidget();
await MemoryHomeWidgetService.instance.initMemoryHomeWidget();
}
Future<bool?> 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 {

View File

@@ -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;
}

View File

@@ -182,7 +182,7 @@ class MemoryHomeWidgetService {
Future<void> _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;

View File

@@ -131,7 +131,7 @@ class PeopleHomeWidgetService {
Future<void> 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<List<String>> _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<String> _calculateHash(List<String> 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<void> _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<void> _updatePeopleWidgetCache() async {
final peopleIds = getSelectedPeople() ?? [];
final peopleIds = await _getEffectiveSelections();
final peopleWithFiles = await _getPeople(peopleIds);
if (peopleWithFiles.isEmpty) {

View File

@@ -663,6 +663,19 @@ class SearchService {
return searchResults;
}
Future<List<String>> getTopTwoFaces() async {
final searchFilter = await SectionType.face.getData(null).then(
(value) => (value as List<GenericSearchResult>).where(
(element) => (element.params[kPersonParamID] as String?) != null,
),
);
return searchFilter
.take(2)
.map((e) => e.params[kPersonParamID] as String)
.toList();
}
Future<List<GenericSearchResult>> getLocationResults(String query) async {
final locationTagEntities = (await locationService.getLocationTags());
final Map<LocalEntity<LocationTag>, List<EnteFile>> result = {};

View File

@@ -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");

View File

@@ -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<ChangeLogPage> {
final List<ChangeLogEntry> 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(

View File

@@ -103,7 +103,6 @@ class _AlbumsWidgetSettingsState extends State<AlbumsWidgetSettings> {
await AlbumHomeWidgetService.instance
.updateSelectedAlbums(albums);
Navigator.pop(context);
}
: null,
isDisabled: _selectedAlbums.albums.isEmpty,
@@ -123,7 +122,7 @@ class _AlbumsWidgetSettingsState extends State<AlbumsWidgetSettings> {
flexibleSpaceTitle: TitleBarTitleWidget(
title: S.of(context).albums,
),
expandedHeight: 120,
expandedHeight: MediaQuery.textScalerOf(context).scale(120),
flexibleSpaceCaption: hasInstalledAny
? S.of(context).albumsWidgetDesc
: context.l10n.addAlbumWidgetPrompt,

View File

@@ -104,7 +104,7 @@ class _MemoriesWidgetSettingsState extends State<MemoriesWidgetSettings> {
flexibleSpaceTitle: TitleBarTitleWidget(
title: S.of(context).memories,
),
expandedHeight: 120,
expandedHeight: MediaQuery.textScalerOf(context).scale(120),
flexibleSpaceCaption: hasInstalledAny
? S.of(context).memoriesWidgetDesc
: context.l10n.addMemoriesWidgetPrompt,

View File

@@ -88,7 +88,7 @@ class _PeopleWidgetSettingsState extends State<PeopleWidgetSettings> {
flexibleSpaceTitle: TitleBarTitleWidget(
title: S.of(context).people,
),
expandedHeight: 120,
expandedHeight: MediaQuery.textScalerOf(context).scale(120),
flexibleSpaceCaption: hasInstalledAny
? S.of(context).peopleWidgetDesc
: context.l10n.addPeopleWidgetPrompt,

View File

@@ -279,9 +279,9 @@ class _HomeWidgetState extends State<HomeWidget> {
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<Uri, (bool, int)> _linkedPublicAlbums = {};

View File

@@ -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<ZoomableImage> {
// 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<ZoomableImage> {
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<ZoomableImage> {
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 {

View File

@@ -399,14 +399,6 @@ class _PeopleSectionAllWidgetState extends State<PeopleSectionAllWidget> {
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;

View File

@@ -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<AppProps> = ({ Component, pageProps }) => {
useEffect(() => {
const user = getData("user") as User | undefined | null;
logStartupBanner(user?.id);
HTTPService.setHeaders({ "X-Client-Package": clientPackageName });
}, []);
const logout = useCallback(() => {

View File

@@ -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<SharingDetailsProps> = ({
);
};
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>) => () => void;
@@ -956,51 +936,55 @@ const ManageParticipant: React.FC<ManageParticipantProps> = ({
onClose();
};
const handleRoleChange = (role: string) => () => {
if (role !== selectedParticipant.role) {
changeRolePermission(selectedParticipant.email, role);
}
};
const confirmChangeRolePermission = useCallback(
(
selectedEmail: string,
newRole: CollectionNewParticipantRole,
action: () => Promise<void>,
) => {
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 = (
<Trans
i18nKey="change_permission_to_viewer"
values={{ selectedEmail }}
/>
);
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 = (
<Trans
i18nKey="change_permission_to_viewer"
values={{ selectedEmail }}
/>
);
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<ManageParticipantProps> = ({
<RowButtonGroup>
<RowButton
fontWeight="regular"
onClick={handleRoleChange("COLLABORATOR")}
onClick={createOnRoleChange("COLLABORATOR")}
label={"Collaborator"}
startIcon={<ModeEditIcon />}
endIcon={
@@ -1057,7 +1041,7 @@ const ManageParticipant: React.FC<ManageParticipantProps> = ({
<RowButton
fontWeight="regular"
onClick={handleRoleChange("VIEWER")}
onClick={createOnRoleChange("VIEWER")}
label={"Viewer"}
startIcon={<PhotoIcon />}
endIcon={

View File

@@ -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<AppProps> = ({ 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)");

View File

@@ -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<PublicAlbumsCredentials | undefined>(undefined);
const collectionKey = useRef<string>(null);
const url = useRef<string>(null);
const referralCode = useRef<string>("");
const [publicFiles, setPublicFiles] = useState<EnteFile[]>(null);
const [publicCollection, setPublicCollection] = useState<Collection>(null);
const [publicCollection, setPublicCollection] = useState<
Collection | undefined
>(undefined);
const [publicFiles, setPublicFiles] = useState<EnteFile[] | undefined>(
undefined,
);
const [errorMessage, setErrorMessage] = useState<string>(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<boolean>(false);
const [photoListHeader, setPhotoListHeader] =
useState<TimeStampListItem>(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" },

View File

@@ -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<string> => {
return (
(await localForage.getItem<string>(
getPublicCollectionPasswordKey(collectionUID),
)) || ""
);
};
export const savePublicCollectionPassword = async (
collectionUID: string,
passToken: string,
): Promise<string> => {
return await localForage.setItem<string>(
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<number>(
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<string, EnteFile>();
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<EnteFile[]> => {
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<EnteFile>[],
)),
];
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<T> {
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<CollectionPublicMagicMetadataData>;
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<LocalSavedPublicCollectionFiles[]>(
PUBLIC_COLLECTION_FILES_TABLE,
)) ?? [];
await localForage.setItem(
PUBLIC_COLLECTION_FILES_TABLE,
publicCollectionFiles.filter(
(collectionFiles) =>
collectionFiles.collectionUID !== collectionUID,
),
);
};

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -322,6 +322,7 @@ export type RemoteEnteFile = z.infer<typeof RemoteEnteFile>;
* a provided timestamp.
*
* - "/collections/v2/diff"
* - "/public-collection/diff"
* - "/cast/diff"
*/
export const FileDiffResponse = z.object({

View File

@@ -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<Collection[]> =>
const savedPublicCollections = async (): Promise<Collection[]> =>
// TODO:
//
// See: [Note: strict mode migration]
@@ -34,10 +35,57 @@ export const savedPublicCollections = async (): Promise<Collection[]> =>
*
* 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<Collection | undefined> =>
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<EnteFile[]> => {
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<ES>("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<void> => {
// See: [Note: Avoiding Zod parsing for large DB arrays].
const entries = await localForage.getItem<ES>("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<void> => {
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`),
);

View File

@@ -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),
]);
};

View File

@@ -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,

View File

@@ -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";

View File

@@ -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

View File

@@ -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

View File

@@ -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);
};

View File

@@ -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<string, any>;
type IQueryPrams = Record<string, any>;
/**
* 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();

View File

@@ -3,7 +3,6 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"axios": "^1.9.0",
"ente-base": "*"
},
"devDependencies": {

View File

@@ -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"