Merge branch 'main' of https://github.com/ente-io/auth into release_mob_jun_25
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`),
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
@@ -3,7 +3,6 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"ente-base": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user