diff --git a/.github/workflows/rust-lint.yml b/.github/workflows/rust-lint.yml index 0f7694e0c6..802482c548 100644 --- a/.github/workflows/rust-lint.yml +++ b/.github/workflows/rust-lint.yml @@ -15,6 +15,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + RUSTFLAGS: -D warnings + jobs: lint: runs-on: ubuntu-latest @@ -33,9 +36,9 @@ jobs: ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - + - run: cargo fmt --check - - run: cargo clippy + - run: cargo clippy --all-targets --all-features - run: cargo build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4b90454e2..83203d474e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ See [docs/](docs/README.md) for how to edit these documents. ## Code contributions -If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](auth/docs/adding-icons.md), or fixing a specific bug. +If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](mobile/apps/auth/docs/adding-icons.md), or fixing a specific bug. Code that changes the behaviour of the product might not get merged, at least not initially. The PR can serve as a discussion bed, but you might find it easier to just start a discussion instead, or post your perspective in the (likely) existing thread about the behaviour change or new feature you wish for. diff --git a/mobile/apps/auth/README.md b/mobile/apps/auth/README.md index 99ad0d1688..db0c84ea26 100644 --- a/mobile/apps/auth/README.md +++ b/mobile/apps/auth/README.md @@ -98,7 +98,7 @@ more, see [docs/adding-icons](docs/adding-icons.md). The best way to support this project is by checking out [Ente Photos](../mobile/README.md) or spreading the word. -For more ways to contribute, see [../CONTRIBUTING.md](../../../CONTRIBUTING.md). +For more ways to contribute, see [../../../CONTRIBUTING.md](../../../CONTRIBUTING.md). ## Certificate Fingerprints diff --git a/mobile/apps/auth/assets/custom-icons/_data/custom-icons.json b/mobile/apps/auth/assets/custom-icons/_data/custom-icons.json index fc558cf8f5..ba502b4046 100644 --- a/mobile/apps/auth/assets/custom-icons/_data/custom-icons.json +++ b/mobile/apps/auth/assets/custom-icons/_data/custom-icons.json @@ -60,6 +60,15 @@ "slug": "amtrak", "hex": "003A5D" }, + { + "title": "Animal Crossing", + "slug:": "animal_crossing", + "altNames": [ + "AnimalCrossing", + "Bell Tree Forums", + "BellTree Forums" + ] + }, { "title": "Ankama", "slug": "ankama" @@ -81,6 +90,13 @@ "Docaposte AR24" ] }, + { + "title": "Art Fight", + "slug": "art_fight", + "altNames": [ + "ArtFight" + ] + }, { "title": "Aruba", "slug": "aruba", @@ -341,6 +357,9 @@ "slug": "cih", "hex": "D14633" }, + { + "title": "Chucklefish" + }, { "title": "Clipper", "slug": "clippercard", @@ -1505,6 +1524,9 @@ { "title": "Skinport" }, + { + "title": "Smogon" + }, { "title": "SMSPool", "slug": "sms_pool_net", @@ -1664,6 +1686,12 @@ { "title": "TorGuard" }, + { + "title": "Toyhouse", + "altNames": [ + "Toyhou.se" + ] + }, { "title": "Trading 212" }, @@ -1685,6 +1713,15 @@ "T Rowe Price Group, Inc" ] }, + { + "title": "TU Dresden", + "slug": "tu_dresden", + "altNames": [ + "Technische Universität Dresden", + "Dresden University of Technology" + ], + "hex": "00305d" + }, { "title": "Tweakers" }, @@ -1699,6 +1736,12 @@ "Twitch tv" ] }, + { + "title": "Twitter", + "altNames": [ + "X" + ] + }, { "title": "Ubiquiti", "slug": "ubiquiti", @@ -1906,6 +1949,22 @@ { "title": "Co-Wheels", "slug": "cowheels" + }, + { + "title": "Zivver", + "slug": "zivver" + }, + { + "title": "Meesman Indexbeleggen", + "slug": "meesman" + }, + { + "title": "Scouting Nederland", + "slug": "scoutingnederland" + }, + { + "title": "ISC2", + "slug": "isc2" } ] -} \ No newline at end of file +} diff --git a/mobile/apps/auth/assets/custom-icons/icons/animal_crossing.svg b/mobile/apps/auth/assets/custom-icons/icons/animal_crossing.svg new file mode 100644 index 0000000000..6800a4d8f7 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/animal_crossing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/apps/auth/assets/custom-icons/icons/art_fight.svg b/mobile/apps/auth/assets/custom-icons/icons/art_fight.svg new file mode 100644 index 0000000000..51c4274bd4 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/art_fight.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/apps/auth/assets/custom-icons/icons/best_buy.svg b/mobile/apps/auth/assets/custom-icons/icons/best_buy.svg new file mode 100644 index 0000000000..86717212e2 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/best_buy.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/apps/auth/assets/custom-icons/icons/chucklefish.svg b/mobile/apps/auth/assets/custom-icons/icons/chucklefish.svg new file mode 100644 index 0000000000..76be26a658 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/chucklefish.svg @@ -0,0 +1,75 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/mobile/apps/auth/assets/custom-icons/icons/isc2.svg b/mobile/apps/auth/assets/custom-icons/icons/isc2.svg new file mode 100644 index 0000000000..477c61750f --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/isc2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/apps/auth/assets/custom-icons/icons/meesman.svg b/mobile/apps/auth/assets/custom-icons/icons/meesman.svg new file mode 100644 index 0000000000..7c98ba0161 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/meesman.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/apps/auth/assets/custom-icons/icons/scoutingnederland.svg b/mobile/apps/auth/assets/custom-icons/icons/scoutingnederland.svg new file mode 100644 index 0000000000..ae617a6aee --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/scoutingnederland.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/apps/auth/assets/custom-icons/icons/smogon.svg b/mobile/apps/auth/assets/custom-icons/icons/smogon.svg new file mode 100644 index 0000000000..6d4d656c88 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/smogon.svg @@ -0,0 +1,170 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/apps/auth/assets/custom-icons/icons/toyhouse.svg b/mobile/apps/auth/assets/custom-icons/icons/toyhouse.svg new file mode 100644 index 0000000000..f385ffbb27 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/toyhouse.svg @@ -0,0 +1,49 @@ + + + + diff --git a/mobile/apps/auth/assets/custom-icons/icons/tu_dresden.svg b/mobile/apps/auth/assets/custom-icons/icons/tu_dresden.svg new file mode 100644 index 0000000000..2c53b913cf --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/tu_dresden.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/apps/auth/assets/custom-icons/icons/twitter.svg b/mobile/apps/auth/assets/custom-icons/icons/twitter.svg new file mode 100644 index 0000000000..d60af2b8c5 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/twitter.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mobile/apps/auth/assets/custom-icons/icons/ubiquiti.svg b/mobile/apps/auth/assets/custom-icons/icons/ubiquiti.svg index 992eb7986a..559fd81869 100644 --- a/mobile/apps/auth/assets/custom-icons/icons/ubiquiti.svg +++ b/mobile/apps/auth/assets/custom-icons/icons/ubiquiti.svg @@ -1,7 +1 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/mobile/apps/auth/assets/custom-icons/icons/zivver.svg b/mobile/apps/auth/assets/custom-icons/icons/zivver.svg new file mode 100644 index 0000000000..40d0977f98 --- /dev/null +++ b/mobile/apps/auth/assets/custom-icons/icons/zivver.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/mobile/apps/auth/assets/icons/auth-icon-monochrome.png b/mobile/apps/auth/assets/icons/auth-icon-monochrome.png new file mode 100644 index 0000000000..09deb41b4c Binary files /dev/null and b/mobile/apps/auth/assets/icons/auth-icon-monochrome.png differ diff --git a/mobile/apps/auth/lib/main.dart b/mobile/apps/auth/lib/main.dart index 54f8da92aa..2bca2feb0d 100644 --- a/mobile/apps/auth/lib/main.dart +++ b/mobile/apps/auth/lib/main.dart @@ -40,8 +40,10 @@ Future initSystemTray() async { if (PlatformUtil.isMobile()) return; String path = Platform.isWindows ? 'assets/icons/auth-icon.ico' - : 'assets/icons/auth-icon.png'; - await trayManager.setIcon(path); + : Platform.isMacOS + ? 'assets/icons/auth-icon-monochrome.png' + : 'assets/icons/auth-icon.png'; + await trayManager.setIcon(path, isTemplate: true); Menu menu = Menu( items: [ MenuItem( diff --git a/mobile/apps/photos/README.md b/mobile/apps/photos/README.md index 958f7c4215..45d096a77d 100644 --- a/mobile/apps/photos/README.md +++ b/mobile/apps/photos/README.md @@ -10,12 +10,12 @@ commit](https://github.com/ente-io/ente/commit/a8cdc811fd20ca4289d8e779c97f08ef5 Hello world -To know more about Ente, see [our main README](../README.md) or visit +To know more about Ente, see [our main README](../../../README.md) or visit [ente.io](https://ente.io). -To use Ente Photos on the web, see [../web](../web/README.md). To use Ente -Photos on the desktop, see [../desktop](../desktop/README.md). There is a also a -[CLI tool](../cli/README.md) for easy / automated exports. +To use Ente Photos on the web, see [../../../web](../../../web/README.md). To use Ente +Photos on the desktop, see [../../../desktop](../../../desktop/README.md). There is a also a +[CLI tool](../../../cli/README.md) for easy / automated exports. If you're looking for Ente Auth instead, see [../auth](../auth/README.md). @@ -32,16 +32,16 @@ without relying on third party stores. You can alternatively install the build from PlayStore or F-Droid. - + - + ### iOS - + ## 🧑‍💻 Building from source @@ -99,4 +99,4 @@ apksigner verify --print-certs ## 💚 Contribute -For more ways to contribute, see [../CONTRIBUTING.md](../CONTRIBUTING.md). +For more ways to contribute, see [../../../CONTRIBUTING.md](../../../CONTRIBUTING.md). diff --git a/mobile/apps/photos/ios/Podfile.lock b/mobile/apps/photos/ios/Podfile.lock index 55224eaaf9..627d2debd6 100644 --- a/mobile/apps/photos/ios/Podfile.lock +++ b/mobile/apps/photos/ios/Podfile.lock @@ -12,6 +12,8 @@ PODS: - Flutter - device_info_plus (0.0.1): - Flutter + - emoji_picker_flutter (0.0.1): + - Flutter - ffmpeg_kit_custom (6.0.3) - ffmpeg_kit_flutter (6.0.3): - ffmpeg_kit_custom @@ -127,9 +129,6 @@ PODS: - libwebp/sharpyuv (1.5.0) - libwebp/webp (1.5.0): - libwebp/sharpyuv - - local_auth_darwin (0.0.1): - - Flutter - - FlutterMacOS - local_auth_ios (0.0.1): - Flutter - Mantle (2.2.0): @@ -230,6 +229,8 @@ PODS: - Flutter - url_launcher_ios (0.0.1): - Flutter + - vibration (1.7.5): + - Flutter - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS @@ -250,6 +251,7 @@ DEPENDENCIES: - cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`) - dart_ui_isolate (from `.symlinks/plugins/dart_ui_isolate/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`) - ffmpeg_kit_flutter (from `.symlinks/plugins/ffmpeg_kit_flutter/ios`) - file_saver (from `.symlinks/plugins/file_saver/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -269,7 +271,6 @@ DEPENDENCIES: - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - launcher_icon_switcher (from `.symlinks/plugins/launcher_icon_switcher/ios`) - - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`) - maps_launcher (from `.symlinks/plugins/maps_launcher/ios`) - media_extension (from `.symlinks/plugins/media_extension/ios`) @@ -297,6 +298,7 @@ DEPENDENCIES: - thermal (from `.symlinks/plugins/thermal/ios`) - ua_client_hints (from `.symlinks/plugins/ua_client_hints/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - vibration (from `.symlinks/plugins/vibration/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`) @@ -304,7 +306,7 @@ DEPENDENCIES: - workmanager (from `.symlinks/plugins/workmanager/ios`) SPEC REPOS: - https://github.com/ente-io/ffmpeg-kit-custom-repo-ios: + https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git: - ffmpeg_kit_custom trunk: - Firebase @@ -339,6 +341,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/dart_ui_isolate/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" + emoji_picker_flutter: + :path: ".symlinks/plugins/emoji_picker_flutter/ios" ffmpeg_kit_flutter: :path: ".symlinks/plugins/ffmpeg_kit_flutter/ios" file_saver: @@ -377,8 +381,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" launcher_icon_switcher: :path: ".symlinks/plugins/launcher_icon_switcher/ios" - local_auth_darwin: - :path: ".symlinks/plugins/local_auth_darwin/darwin" local_auth_ios: :path: ".symlinks/plugins/local_auth_ios/ios" maps_launcher: @@ -433,6 +435,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/ua_client_hints/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + vibration: + :path: ".symlinks/plugins/vibration/ios" video_player_avfoundation: :path: ".symlinks/plugins/video_player_avfoundation/darwin" video_thumbnail: @@ -445,83 +449,84 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/workmanager/ios" SPEC CHECKSUMS: - app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 - battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd - cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c - dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1 - device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89 + app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6 + battery_info: b6c551049266af31556b93c9d9b9452cfec0219f + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba + dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14 + device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58 ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99 - ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5 - file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 + ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe + file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf - firebase_core: 6cbed78b4f298ed103a9fd034e6dbc846320480f - firebase_messaging: 5e0adf2eb18b0ee59aa0c109314c091a0497ecac + firebase_core: 6e223dfa350b2edceb729cea505eaaef59330682 + firebase_messaging: 07fde77ae28c08616a1d4d870450efc2b38cf40d FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629 FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917 FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58 - flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 - flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb - flutter_native_splash: 6cad9122ea0fad137d23137dd14b937f3e90b145 - flutter_secure_storage: 2c2ff13db9e0a5647389bff88b0ecac56e3f3418 - flutter_sodium: 7e4621538491834eba53bd524547854bcbbd6987 - flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 - fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 + flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa + flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e + flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 + flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f + flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a + flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be + flutter_sodium: a00383520fc689c688b66fd3092984174712493e + flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282 + fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f - image_editor_common: 3de87e7c4804f4ae24c8f8a998362b98c105cac1 - in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6 - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da + home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 + image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43 + in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 - local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 - local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451 + local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d - maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45 - media_extension: 671e2567880d96c95c65c9a82ccceed8f2e309fd - media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 - media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 - motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1 - motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1 - move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84 + maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203 + media_extension: 6618f07abd762cdbfaadf1b0c56a287e820f0c84 + media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 + media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e + motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91 + motionphoto: 8b65ce50c7d7ff3c767534fc3768b2eed9ac24e4 + move_to_background: cd3091014529ec7829e342ad2d75c0a11f4378a5 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d - objective_c: 89e720c30d716b036faf9c9684022048eee1eee2 - onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2 + native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e + objective_c: 77e887b5ba1827970907e10e832eec1683f3431d + onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997 onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b - open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11 + open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 - privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a + privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 + receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1 SDWebImage: f29024626962457f3470184232766516dee8dfea SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 - sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 - sqlite3_flutter_libs: 3c323550ef3b928bc0aa9513c841e45a7d242832 - system_info_plus: 555ce7047fbbf29154726db942ae785c29211740 - thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41 - ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b - video_thumbnail: 584ccfa55d8fd2f3d5507218b0a18d84c839c620 - volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 - wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 - workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e + sqlite3_flutter_libs: 069c435986dd4b63461aecd68f4b30be4a9e9daa + system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa + thermal: a9261044101ae8f532fa29cab4e8270b51b3f55c + ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241 + video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 + video_thumbnail: 94ba6705afbaa120b77287080424930f23ea0c40 + volume_controller: 2e3de73d6e7e81a0067310d17fb70f2f86d71ac7 + wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 + workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 PODFILE CHECKSUM: a8ef88ad74ba499756207e7592c6071a96756d18 diff --git a/mobile/apps/photos/ios/Runner.xcodeproj/project.pbxproj b/mobile/apps/photos/ios/Runner.xcodeproj/project.pbxproj index d1c1bff9f4..db6d40da7d 100644 --- a/mobile/apps/photos/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/apps/photos/ios/Runner.xcodeproj/project.pbxproj @@ -532,6 +532,7 @@ "${BUILT_PRODUCTS_DIR}/cupertino_http/cupertino_http.framework", "${BUILT_PRODUCTS_DIR}/dart_ui_isolate/dart_ui_isolate.framework", "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", + "${BUILT_PRODUCTS_DIR}/emoji_picker_flutter/emoji_picker_flutter.framework", "${BUILT_PRODUCTS_DIR}/file_saver/file_saver.framework", "${BUILT_PRODUCTS_DIR}/flutter_email_sender/flutter_email_sender.framework", "${BUILT_PRODUCTS_DIR}/flutter_image_compress_common/flutter_image_compress_common.framework", @@ -548,7 +549,6 @@ "${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework", "${BUILT_PRODUCTS_DIR}/launcher_icon_switcher/launcher_icon_switcher.framework", "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", - "${BUILT_PRODUCTS_DIR}/local_auth_darwin/local_auth_darwin.framework", "${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework", "${BUILT_PRODUCTS_DIR}/maps_launcher/maps_launcher.framework", "${BUILT_PRODUCTS_DIR}/media_extension/media_extension.framework", @@ -576,6 +576,7 @@ "${BUILT_PRODUCTS_DIR}/thermal/thermal.framework", "${BUILT_PRODUCTS_DIR}/ua_client_hints/ua_client_hints.framework", "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", + "${BUILT_PRODUCTS_DIR}/vibration/vibration.framework", "${BUILT_PRODUCTS_DIR}/video_player_avfoundation/video_player_avfoundation.framework", "${BUILT_PRODUCTS_DIR}/video_thumbnail/video_thumbnail.framework", "${BUILT_PRODUCTS_DIR}/volume_controller/volume_controller.framework", @@ -628,6 +629,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/cupertino_http.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/dart_ui_isolate.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/emoji_picker_flutter.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_saver.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_email_sender.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_image_compress_common.framework", @@ -644,7 +646,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/launcher_icon_switcher.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_darwin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/maps_launcher.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_extension.framework", @@ -672,6 +673,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/thermal.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ua_client_hints.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/vibration.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player_avfoundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_thumbnail.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/volume_controller.framework", diff --git a/mobile/apps/photos/lib/db/upload_locks_db.dart b/mobile/apps/photos/lib/db/upload_locks_db.dart index ab786d5c78..dc2bfab079 100644 --- a/mobile/apps/photos/lib/db/upload_locks_db.dart +++ b/mobile/apps/photos/lib/db/upload_locks_db.dart @@ -157,6 +157,23 @@ class UploadLocksDB { ); } + Future getLockData(String id) async { + final db = await instance.database; + final rows = await db.query( + _uploadLocksTable.table, + where: '${_uploadLocksTable.columnID} = ?', + whereArgs: [id], + ); + if (rows.isEmpty) { + return "No lock found for $id"; + } + final row = rows.first; + final time = row[_uploadLocksTable.columnTime] as int; + final owner = row[_uploadLocksTable.columnOwner] as String; + final duration = DateTime.now().millisecondsSinceEpoch - time; + return "Lock for $id acquired by $owner since ${Duration(milliseconds: duration)}"; + } + Future isLocked(String id, String owner) async { final db = await instance.database; final rows = await db.query( diff --git a/mobile/apps/photos/lib/generated/l10n.dart b/mobile/apps/photos/lib/generated/l10n.dart index 84cef71699..14b4c3aeea 100644 --- a/mobile/apps/photos/lib/generated/l10n.dart +++ b/mobile/apps/photos/lib/generated/l10n.dart @@ -12495,6 +12495,118 @@ class S { args: [], ); } + + /// `Undo` + String get undo { + return Intl.message( + 'Undo', + name: 'undo', + desc: '', + args: [], + ); + } + + /// `Redo` + String get redo { + return Intl.message( + 'Redo', + name: 'redo', + desc: '', + args: [], + ); + } + + /// `Filter` + String get filter { + return Intl.message( + 'Filter', + name: 'filter', + desc: '', + args: [], + ); + } + + /// `Adjust` + String get adjust { + return Intl.message( + 'Adjust', + name: 'adjust', + desc: '', + args: [], + ); + } + + /// `Draw` + String get draw { + return Intl.message( + 'Draw', + name: 'draw', + desc: '', + args: [], + ); + } + + /// `Sticker` + String get sticker { + return Intl.message( + 'Sticker', + name: 'sticker', + desc: '', + args: [], + ); + } + + /// `Brush Color` + String get brushColor { + return Intl.message( + 'Brush Color', + name: 'brushColor', + desc: '', + args: [], + ); + } + + /// `Font` + String get font { + return Intl.message( + 'Font', + name: 'font', + desc: '', + args: [], + ); + } + + /// `Background` + String get background { + return Intl.message( + 'Background', + name: 'background', + desc: '', + args: [], + ); + } + + /// `Align` + String get align { + return Intl.message( + 'Align', + name: 'align', + desc: '', + args: [], + ); + } + + /// `{count, plural, =1{Added successfully to 1 album} other{Added successfully to {count} albums}}` + String addedToAlbums(int count) { + return Intl.plural( + count, + one: 'Added successfully to 1 album', + other: 'Added successfully to $count albums', + name: 'addedToAlbums', + desc: 'Message shown when items are added to albums', + args: [count], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/mobile/apps/photos/lib/l10n/intl_en.arb b/mobile/apps/photos/lib/l10n/intl_en.arb index 21998814da..017637a8b8 100644 --- a/mobile/apps/photos/lib/l10n/intl_en.arb +++ b/mobile/apps/photos/lib/l10n/intl_en.arb @@ -1808,5 +1808,24 @@ "automaticallyAnalyzeAndSplitGrouping": "We will automatically analyze the grouping to determine if there are multiple people present, and separate them out again. This may take a few seconds.", "layout": "Layout", "day": "Day", - "peopleAutoAddDesc": "Select the people you want to automatically add to the album" + "peopleAutoAddDesc": "Select the people you want to automatically add to the album", + "undo": "Undo", + "redo": "Redo", + "filter": "Filter", + "adjust": "Adjust", + "draw": "Draw", + "sticker": "Sticker", + "brushColor": "Brush Color", + "font": "Font", + "background": "Background", + "align": "Align", + "addedToAlbums": "{count, plural, =1{Added successfully to 1 album} other{Added successfully to {count} albums}}", + "@addedToAlbums": { + "description": "Message shown when items are added to albums", + "placeholders": { + "count": { + "type": "int" + } + } + } } \ No newline at end of file diff --git a/mobile/apps/photos/lib/models/gallery/gallery_groups.dart b/mobile/apps/photos/lib/models/gallery/gallery_groups.dart index a42ba360e0..3bb9e70c1d 100644 --- a/mobile/apps/photos/lib/models/gallery/gallery_groups.dart +++ b/mobile/apps/photos/lib/models/gallery/gallery_groups.dart @@ -224,18 +224,20 @@ class GalleryGroups { int i = 0; while (!endOfListReached) { gridRowChildren.add( - GalleryFileWidget( + RepaintBoundary( key: ValueKey( tagPrefix + filesInGroup[firstIndexOfRowWrtFilesInGroup + i] .tag, ), - file: filesInGroup[firstIndexOfRowWrtFilesInGroup + i], - selectedFiles: selectedFiles, - limitSelectionToOne: limitSelectionToOne, - tag: tagPrefix, - photoGridSize: crossAxisCount, - currentUserID: currentUserID, + child: GalleryFileWidget( + file: filesInGroup[firstIndexOfRowWrtFilesInGroup + i], + selectedFiles: selectedFiles, + limitSelectionToOne: limitSelectionToOne, + tag: tagPrefix, + photoGridSize: crossAxisCount, + currentUserID: currentUserID, + ), ), ); @@ -247,18 +249,20 @@ class GalleryGroups { } else { for (int i = 0; i < crossAxisCount; i++) { gridRowChildren.add( - GalleryFileWidget( + RepaintBoundary( key: ValueKey( tagPrefix + filesInGroup[firstIndexOfRowWrtFilesInGroup + i] .tag, ), - file: filesInGroup[firstIndexOfRowWrtFilesInGroup + i], - selectedFiles: selectedFiles, - limitSelectionToOne: limitSelectionToOne, - tag: tagPrefix, - photoGridSize: crossAxisCount, - currentUserID: currentUserID, + child: GalleryFileWidget( + file: filesInGroup[firstIndexOfRowWrtFilesInGroup + i], + selectedFiles: selectedFiles, + limitSelectionToOne: limitSelectionToOne, + tag: tagPrefix, + photoGridSize: crossAxisCount, + currentUserID: currentUserID, + ), ), ); } diff --git a/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart b/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart index d049be0102..64fe799e46 100644 --- a/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart +++ b/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart @@ -310,9 +310,7 @@ class _CollectionActionSheetState extends State { if (result) { showShortToast( context, - "Added successfully to " + - _selectedCollections.length.toString() + - " albums", + S.of(context).addedToAlbums(_selectedCollections.length), ); widget.selectedFiles?.clearAll(); } diff --git a/mobile/apps/photos/lib/ui/tools/editor/filtered_image.dart b/mobile/apps/photos/lib/ui/tools/editor/filtered_image.dart deleted file mode 100644 index 31c74590ac..0000000000 --- a/mobile/apps/photos/lib/ui/tools/editor/filtered_image.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/widgets.dart'; -import 'package:image_editor/image_editor.dart'; - -class FilteredImage extends StatelessWidget { - const FilteredImage({ - required this.child, - this.brightness, - this.saturation, - this.hue, - super.key, - }); - - final double? brightness, saturation, hue; - final Widget child; - - @override - Widget build(BuildContext context) { - return ColorFiltered( - colorFilter: ColorFilter.matrix( - ColorFilterGenerator.brightnessAdjustMatrix( - value: brightness ?? 1, - ), - ), - child: ColorFiltered( - colorFilter: ColorFilter.matrix( - ColorFilterGenerator.saturationAdjustMatrix( - value: saturation ?? 1, - ), - ), - child: ColorFiltered( - colorFilter: ColorFilter.matrix( - ColorFilterGenerator.hueAdjustMatrix( - value: hue ?? 0, - ), - ), - child: child, - ), - ), - ); - } -} - -class ColorFilterGenerator { - static List hueAdjustMatrix({double value = 1}) { - value = value * pi; - - if (value == 0) { - return [ - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]; - } - final double cosVal = cos(value); - final double sinVal = sin(value); - const double lumR = 0.213; - const double lumG = 0.715; - const double lumB = 0.072; - - return List.from([ - (lumR + (cosVal * (1 - lumR))) + (sinVal * (-lumR)), - (lumG + (cosVal * (-lumG))) + (sinVal * (-lumG)), - (lumB + (cosVal * (-lumB))) + (sinVal * (1 - lumB)), - 0, - 0, - (lumR + (cosVal * (-lumR))) + (sinVal * 0.143), - (lumG + (cosVal * (1 - lumG))) + (sinVal * 0.14), - (lumB + (cosVal * (-lumB))) + (sinVal * (-0.283)), - 0, - 0, - (lumR + (cosVal * (-lumR))) + (sinVal * (-(1 - lumR))), - (lumG + (cosVal * (-lumG))) + (sinVal * lumG), - (lumB + (cosVal * (1 - lumB))) + (sinVal * lumB), - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]).map((i) => i.toDouble()).toList(); - } - - static List brightnessAdjustMatrix({double value = 1}) { - return ColorOption.brightness(value).matrix; - } - - static List saturationAdjustMatrix({double value = 1}) { - return ColorOption.saturation(value).matrix; - } -} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_app_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_app_bar.dart index 03e84cf75d..730feb5d3a 100644 --- a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_app_bar.dart +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_app_bar.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import "package:flutter_svg/svg.dart"; import "package:photos/ente_theme_data.dart"; -import "package:photos/theme/ente_theme.dart"; +import "package:photos/generated/l10n.dart"; + import "package:photos/theme/ente_theme.dart"; import "package:pro_image_editor/models/editor_configs/pro_image_editor_configs.dart"; class ImageEditorAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -43,7 +44,7 @@ class ImageEditorAppBar extends StatelessWidget implements PreferredSizeWidget { enableUndo ? close() : Navigator.of(context).pop(); }, child: Text( - 'Cancel', + S.of(context).cancel, style: getEnteTextTheme(context).body, ), ), @@ -52,7 +53,7 @@ class ImageEditorAppBar extends StatelessWidget implements PreferredSizeWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( - tooltip: 'Undo', + tooltip: S.of(context).undo, onPressed: () { undo != null ? undo!() : null; }, @@ -66,7 +67,7 @@ class ImageEditorAppBar extends StatelessWidget implements PreferredSizeWidget { ), const SizedBox(width: 12), IconButton( - tooltip: 'Redo', + tooltip: S.of(context).redo, onPressed: () { redo != null ? redo!() : null; }, @@ -88,7 +89,7 @@ class ImageEditorAppBar extends StatelessWidget implements PreferredSizeWidget { key: ValueKey(isMainEditor ? 'save_copy' : 'done'), onPressed: done, child: Text( - isMainEditor ? 'Save Copy' : 'Done', + isMainEditor ? S.of(context).saveCopy : S.of(context).done, style: getEnteTextTheme(context).body.copyWith( color: isMainEditor ? (enableUndo diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart index a222ed92e4..10333cd3ce 100644 --- a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import "package:flutter_svg/svg.dart"; +import "package:photos/generated/l10n.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/tools/editor/image_editor/circular_icon_button.dart"; import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; @@ -113,7 +114,7 @@ class _ImageEditorCropRotateBarState extends State children: [ CircularIconButton( svgPath: "assets/image-editor/image-editor-crop-rotate.svg", - label: "Rotate", + label: S.of(context).rotate, onTap: () { widget.editor.rotate(); }, @@ -121,7 +122,7 @@ class _ImageEditorCropRotateBarState extends State const SizedBox(width: 6), CircularIconButton( svgPath: "assets/image-editor/image-editor-flip.svg", - label: "Flip", + label: S.of(context).flip, onTap: () { widget.editor.flip(); }, diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart index 3321e645c1..32dc0a5ed8 100644 --- a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import "package:photos/generated/l10n.dart"; import "package:photos/ui/tools/editor/image_editor/circular_icon_button.dart"; import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; import "package:photos/ui/tools/editor/image_editor/image_editor_constants.dart"; @@ -90,7 +91,7 @@ class ImageEditorMainBottomBarState extends State children: [ CircularIconButton( svgPath: "assets/image-editor/image-editor-crop.svg", - label: "Crop", + label: S.of(context).crop, onTap: () { widget.editor.openCropRotateEditor(); }, @@ -98,21 +99,21 @@ class ImageEditorMainBottomBarState extends State CircularIconButton( svgPath: "assets/image-editor/image-editor-filter.svg", - label: "Filter", + label: S.of(context).filter, onTap: () { widget.editor.openFilterEditor(); }, ), CircularIconButton( svgPath: "assets/image-editor/image-editor-tune.svg", - label: "Adjust", + label: S.of(context).adjust, onTap: () { widget.editor.openTuneEditor(); }, ), CircularIconButton( svgPath: "assets/image-editor/image-editor-paint.svg", - label: "Draw", + label: S.of(context).draw, onTap: () { widget.editor.openPaintingEditor(); }, @@ -120,7 +121,7 @@ class ImageEditorMainBottomBarState extends State CircularIconButton( svgPath: "assets/image-editor/image-editor-sticker.svg", - label: "Sticker", + label: S.of(context).sticker, onTap: () { widget.editor.openEmojiEditor(); }, diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page.dart similarity index 99% rename from mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart rename to mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page.dart index f99799378e..e6bc90dbab 100644 --- a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page.dart @@ -37,12 +37,12 @@ import "package:photos/utils/navigation_util.dart"; import "package:pro_image_editor/models/editor_configs/main_editor_configs.dart"; import 'package:pro_image_editor/pro_image_editor.dart'; -class NewImageEditor extends StatefulWidget { +class ImageEditorPage extends StatefulWidget { final ente.EnteFile originalFile; final File file; final DetailPageConfiguration detailPageConfig; - const NewImageEditor({ + const ImageEditorPage({ super.key, required this.file, required this.originalFile, @@ -50,10 +50,10 @@ class NewImageEditor extends StatefulWidget { }); @override - State createState() => _NewImageEditorState(); + State createState() => _ImageEditorPageState(); } -class _NewImageEditorState extends State { +class _ImageEditorPageState extends State { final _mainEditorBarKey = GlobalKey(); final editorKey = GlobalKey(); final _logger = Logger("ImageEditor"); diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart index 0e4d1dd44f..5c7fe2c97d 100644 --- a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import "package:photos/generated/l10n.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/tools/editor/image_editor/image_editor_color_picker.dart"; import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; @@ -63,7 +64,7 @@ class _ImageEditorPaintBarState extends State Padding( padding: const EdgeInsets.only(left: 20.0), child: Text( - "Brush Color", + S.of(context).brushColor, style: getEnteTextTheme(context).body, ), ), diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart index 3115aa3933..d4327fb136 100644 --- a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import "package:flutter_svg/svg.dart"; +import "package:photos/generated/l10n.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/tools/editor/image_editor/circular_icon_button.dart"; import "package:photos/ui/tools/editor/image_editor/image_editor_color_picker.dart"; @@ -75,7 +76,7 @@ class _ImageEditorTextBarState extends State children: [ CircularIconButton( svgPath: "assets/image-editor/image-editor-text-color.svg", - label: "Color", + label: S.of(context).color, isSelected: selectedActionIndex == 0, onTap: () { _selectAction(0); @@ -83,7 +84,7 @@ class _ImageEditorTextBarState extends State ), CircularIconButton( svgPath: "assets/image-editor/image-editor-text-font.svg", - label: "Font", + label: S.of(context).font, isSelected: selectedActionIndex == 1, onTap: () { _selectAction(1); @@ -91,7 +92,7 @@ class _ImageEditorTextBarState extends State ), CircularIconButton( svgPath: "assets/image-editor/image-editor-text-background.svg", - label: "Background", + label: S.of(context).background, isSelected: selectedActionIndex == 2, onTap: () { setState(() { @@ -101,7 +102,7 @@ class _ImageEditorTextBarState extends State ), CircularIconButton( svgPath: "assets/image-editor/image-editor-text-align-left.svg", - label: "Align", + label: S.of(context).align, isSelected: selectedActionIndex == 3, onTap: () { setState(() { diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor_page.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor_page.dart deleted file mode 100644 index 99874d7c3a..0000000000 --- a/mobile/apps/photos/lib/ui/tools/editor/image_editor_page.dart +++ /dev/null @@ -1,553 +0,0 @@ -import "dart:async"; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; -import 'dart:ui' as ui show Image; - -import 'package:extended_image/extended_image.dart'; -import 'package:flutter/material.dart'; -import "package:flutter_image_compress/flutter_image_compress.dart"; -import 'package:image_editor/image_editor.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; -import 'package:photo_manager/photo_manager.dart'; -import 'package:photos/core/event_bus.dart'; -import 'package:photos/db/files_db.dart'; -import 'package:photos/events/local_photos_updated_event.dart'; -import "package:photos/generated/l10n.dart"; -import 'package:photos/models/file/file.dart' as ente; -import 'package:photos/models/location/location.dart'; -import 'package:photos/services/sync/sync_service.dart'; -import 'package:photos/ui/common/loading_widget.dart'; -import 'package:photos/ui/components/action_sheet_widget.dart'; -import 'package:photos/ui/components/buttons/button_widget.dart'; -import 'package:photos/ui/components/models/button_type.dart'; -import 'package:photos/ui/notification/toast.dart'; -import 'package:photos/ui/tools/editor/filtered_image.dart'; -import 'package:photos/ui/viewer/file/detail_page.dart'; -import 'package:photos/utils/dialog_util.dart'; -import 'package:photos/utils/navigation_util.dart'; -import 'package:syncfusion_flutter_core/theme.dart'; -import 'package:syncfusion_flutter_sliders/sliders.dart'; - -class ImageEditorPage extends StatefulWidget { - final ImageProvider imageProvider; - final DetailPageConfiguration detailPageConfig; - final ente.EnteFile originalFile; - - const ImageEditorPage( - this.imageProvider, - this.originalFile, - this.detailPageConfig, { - super.key, - }); - - @override - State createState() => _ImageEditorPageState(); -} - -class _ImageEditorPageState extends State { - static const double kBrightnessDefault = 1; - static const double kBrightnessMin = 0; - static const double kBrightnessMax = 2; - static const double kSaturationDefault = 1; - static const double kSaturationMin = 0; - static const double kSaturationMax = 2; - - final _logger = Logger("ImageEditor"); - final GlobalKey editorKey = - GlobalKey(); - - double? _brightness = kBrightnessDefault; - double? _saturation = kSaturationDefault; - bool _hasEdited = false; - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) async { - if (_hasBeenEdited()) { - await _showExitConfirmationDialog(context); - } else { - replacePage(context, DetailPage(widget.detailPageConfig)); - } - }, - child: Scaffold( - appBar: AppBar( - backgroundColor: const Color(0x00000000), - elevation: 0, - actions: _hasBeenEdited() - ? [ - IconButton( - padding: const EdgeInsets.only(right: 16, left: 16), - onPressed: () { - editorKey.currentState!.reset(); - setState(() { - _brightness = kBrightnessDefault; - _saturation = kSaturationDefault; - }); - }, - icon: const Icon(Icons.history), - ), - ] - : [], - ), - body: Column( - children: [ - Expanded(child: _buildImage()), - const Padding(padding: EdgeInsets.all(4)), - Column( - children: [ - _buildBrightness(), - _buildSat(), - ], - ), - const Padding(padding: EdgeInsets.all(8)), - SafeArea(child: _buildBottomBar()), - Padding(padding: EdgeInsets.all(Platform.isIOS ? 16 : 6)), - ], - ), - ), - ); - } - - bool _hasBeenEdited() { - return _hasEdited || - _saturation != kSaturationDefault || - _brightness != kBrightnessDefault; - } - - Widget _buildImage() { - return Hero( - tag: widget.detailPageConfig.tagPrefix + widget.originalFile.tag, - child: ExtendedImage( - image: widget.imageProvider, - extendedImageEditorKey: editorKey, - mode: ExtendedImageMode.editor, - fit: BoxFit.contain, - initEditorConfigHandler: (_) => EditorConfig( - maxScale: 8.0, - cropRectPadding: const EdgeInsets.all(20.0), - hitTestSize: 20.0, - cornerColor: const Color.fromRGBO(45, 150, 98, 1), - editActionDetailsIsChanged: (_) { - setState(() { - _hasEdited = true; - }); - }, - ), - loadStateChanged: (state) { - if (state.extendedImageLoadState == LoadState.completed) { - return FilteredImage( - brightness: _brightness, - saturation: _saturation, - child: state.completedWidget, - ); - } - return const EnteLoadingWidget(); - }, - ), - ); - } - - Widget _buildBottomBar() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildFlipButton(), - _buildRotateLeftButton(), - _buildRotateRightButton(), - _buildSaveButton(), - ], - ); - } - - Widget _buildFlipButton() { - final TextStyle subtitle2 = Theme.of(context).textTheme.titleSmall!; - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - flip(); - }, - child: SizedBox( - width: 80, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Icon( - Icons.flip, - color: Theme.of(context).iconTheme.color!.withOpacity(0.8), - size: 20, - ), - ), - const Padding(padding: EdgeInsets.all(2)), - Text( - S.of(context).flip, - style: subtitle2.copyWith( - color: subtitle2.color!.withOpacity(0.8), - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - Widget _buildRotateLeftButton() { - final TextStyle subtitle2 = Theme.of(context).textTheme.titleSmall!; - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - rotate(false); - }, - child: SizedBox( - width: 80, - child: Column( - children: [ - Icon( - Icons.rotate_left, - color: Theme.of(context).iconTheme.color!.withOpacity(0.8), - ), - const Padding(padding: EdgeInsets.all(2)), - Text( - S.of(context).rotateLeft, - style: subtitle2.copyWith( - color: subtitle2.color!.withOpacity(0.8), - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - Widget _buildRotateRightButton() { - final TextStyle subtitle2 = Theme.of(context).textTheme.titleSmall!; - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - rotate(true); - }, - child: SizedBox( - width: 80, - child: Column( - children: [ - Icon( - Icons.rotate_right, - color: Theme.of(context).iconTheme.color!.withOpacity(0.8), - ), - const Padding(padding: EdgeInsets.all(2)), - Text( - S.of(context).rotateRight, - style: subtitle2.copyWith( - color: subtitle2.color!.withOpacity(0.8), - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - Widget _buildSaveButton() { - final TextStyle subtitle2 = Theme.of(context).textTheme.titleSmall!; - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - _saveEdits(); - }, - child: SizedBox( - width: 80, - child: Column( - children: [ - Icon( - Icons.save_alt_outlined, - color: Theme.of(context).iconTheme.color!.withOpacity(0.8), - ), - const Padding(padding: EdgeInsets.all(2)), - Text( - S.of(context).saveCopy, - style: subtitle2.copyWith( - color: subtitle2.color!.withOpacity(0.8), - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - Future _saveEdits() async { - final dialog = createProgressDialog(context, S.of(context).saving); - await dialog.show(); - final ExtendedImageEditorState? state = editorKey.currentState; - if (state == null) { - return; - } - final Rect? rect = state.getCropRect(); - if (rect == null) { - return; - } - final EditActionDetails action = state.editAction!; - final double radian = action.rotateAngle; - - final bool flipHorizontal = action.flipY; - final bool flipVertical = action.flipX; - final Uint8List img = state.rawImageData; - - // ignore: unnecessary_null_comparison - if (img == null) { - _logger.severe("null rawImageData"); - showToast(context, S.of(context).somethingWentWrong); - return; - } - - final ImageEditorOption option = ImageEditorOption(); - - option.addOption(ClipOption.fromRect(rect)); - option.addOption( - FlipOption(horizontal: flipHorizontal, vertical: flipVertical), - ); - if (action.hasRotateAngle) { - option.addOption(RotateOption(radian.toInt())); - } - - option.addOption(ColorOption.saturation(_saturation!)); - option.addOption(ColorOption.brightness(_brightness!)); - - option.outputFormat = const OutputFormat.jpeg(100); - - final DateTime start = DateTime.now(); - Uint8List? result = await ImageEditor.editImage( - image: img, - imageEditorOption: option, - ); - if (result == null) { - _logger.severe("null result"); - showToast(context, S.of(context).somethingWentWrong); - return; - } - _logger.info('Size before compression = ${result.length}'); - - final ui.Image decodedResult = await decodeImageFromList(result); - result = await FlutterImageCompress.compressWithList( - result, - minWidth: decodedResult.width, - minHeight: decodedResult.height, - ); - _logger.info('Size after compression = ${result.length}'); - final Duration diff = DateTime.now().difference(start); - _logger.info('image_editor time : $diff'); - - try { - final fileName = - path.basenameWithoutExtension(widget.originalFile.title!) + - "_edited_" + - DateTime.now().microsecondsSinceEpoch.toString() + - ".JPEG"; - //Disabling notifications for assets changing to insert the file into - //files db before triggering a sync. - await PhotoManager.stopChangeNotify(); - final AssetEntity newAsset = - await (PhotoManager.editor.saveImage(result, filename: fileName)); - final newFile = await ente.EnteFile.fromAsset( - widget.originalFile.deviceFolder ?? '', - newAsset, - ); - - newFile.creationTime = widget.originalFile.creationTime; - newFile.collectionID = widget.originalFile.collectionID; - newFile.location = widget.originalFile.location; - if (!newFile.hasLocation && widget.originalFile.localID != null) { - final assetEntity = await widget.originalFile.getAsset; - if (assetEntity != null) { - final latLong = await assetEntity.latlngAsync(); - newFile.location = Location( - latitude: latLong.latitude, - longitude: latLong.longitude, - ); - } - } - newFile.generatedID = await FilesDB.instance.insertAndGetId(newFile); - Bus.instance.fire(LocalPhotosUpdatedEvent([newFile], source: "editSave")); - unawaited(SyncService.instance.sync()); - showShortToast(context, S.of(context).editsSaved); - _logger.info("Original file " + widget.originalFile.toString()); - _logger.info("Saved edits to file " + newFile.toString()); - final files = widget.detailPageConfig.files; - - // the index could be -1 if the files fetched doesn't contain the newly - // edited files - int selectionIndex = - files.indexWhere((file) => file.generatedID == newFile.generatedID); - if (selectionIndex == -1) { - files.add(newFile); - selectionIndex = files.length - 1; - } - await dialog.hide(); - replacePage( - context, - DetailPage( - widget.detailPageConfig.copyWith( - files: files, - selectedIndex: min(selectionIndex, files.length - 1), - ), - ), - ); - } catch (e, s) { - await dialog.hide(); - showToast(context, S.of(context).oopsCouldNotSaveEdits); - _logger.severe(e, s); - } finally { - await PhotoManager.startChangeNotify(); - } - } - - void flip() { - editorKey.currentState?.flip(); - } - - void rotate(bool right) { - editorKey.currentState?.rotate(right: right); - } - - Widget _buildSat() { - final TextStyle subtitle2 = Theme.of(context).textTheme.titleSmall!; - - return Container( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), - child: Row( - children: [ - SizedBox( - width: 42, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - S.of(context).color, - style: subtitle2.copyWith( - color: subtitle2.color!.withOpacity(0.8), - ), - ), - ), - ), - Expanded( - child: SfSliderTheme( - data: SfSliderThemeData( - activeTrackHeight: 4, - inactiveTrackHeight: 2, - inactiveTrackColor: Colors.grey[900], - activeTrackColor: const Color.fromRGBO(45, 150, 98, 1), - thumbColor: const Color.fromRGBO(45, 150, 98, 1), - thumbRadius: 10, - tooltipBackgroundColor: Colors.grey[900], - ), - child: SfSlider( - onChanged: (value) { - setState(() { - _saturation = value; - }); - }, - value: _saturation, - enableTooltip: true, - stepSize: 0.01, - min: kSaturationMin, - max: kSaturationMax, - ), - ), - ), - ], - ), - ); - } - - Widget _buildBrightness() { - final TextStyle subtitle2 = Theme.of(context).textTheme.titleSmall!; - - return Container( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), - child: Row( - children: [ - SizedBox( - width: 42, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - S.of(context).light, - style: subtitle2.copyWith( - color: subtitle2.color!.withOpacity(0.8), - ), - ), - ), - ), - Expanded( - child: SfSliderTheme( - data: SfSliderThemeData( - activeTrackHeight: 4, - inactiveTrackHeight: 2, - activeTrackColor: const Color.fromRGBO(45, 150, 98, 1), - inactiveTrackColor: Colors.grey[900], - thumbColor: const Color.fromRGBO(45, 150, 98, 1), - thumbRadius: 10, - tooltipBackgroundColor: Colors.grey[900], - ), - child: SfSlider( - onChanged: (value) { - setState(() { - _brightness = value; - }); - }, - value: _brightness, - enableTooltip: true, - stepSize: 0.01, - min: kBrightnessMin, - max: kBrightnessMax, - ), - ), - ), - ], - ), - ); - } - - Future _showExitConfirmationDialog(BuildContext context) async { - final actionResult = await showActionSheet( - context: context, - buttons: [ - ButtonWidget( - labelText: S.of(context).yesDiscardChanges, - buttonType: ButtonType.critical, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - buttonAction: ButtonAction.first, - isInAlert: true, - ), - ButtonWidget( - labelText: S.of(context).no, - buttonType: ButtonType.secondary, - buttonSize: ButtonSize.large, - buttonAction: ButtonAction.second, - shouldStickToDarkTheme: true, - isInAlert: true, - ), - ], - body: S.of(context).doYouWantToDiscardTheEditsYouHaveMade, - actionSheetType: ActionSheetType.defaultActionSheet, - ); - if (actionResult?.action != null && - actionResult!.action == ButtonAction.first) { - replacePage(context, DetailPage(widget.detailPageConfig)); - } - } -} diff --git a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart index 5cb0d8fcbe..18b50f6357 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart @@ -19,8 +19,7 @@ import "package:photos/services/local_authentication_service.dart"; import "package:photos/states/detail_page_state.dart"; import "package:photos/ui/common/fast_scroll_physics.dart"; import 'package:photos/ui/notification/toast.dart'; -import "package:photos/ui/tools/editor/image_editor/image_editor_page_new.dart"; -import 'package:photos/ui/tools/editor/image_editor_page.dart'; +import "package:photos/ui/tools/editor/image_editor/image_editor_page.dart"; import "package:photos/ui/tools/editor/video_editor_page.dart"; import "package:photos/ui/viewer/file/file_app_bar.dart"; import "package:photos/ui/viewer/file/file_bottom_bar.dart"; @@ -64,16 +63,30 @@ class DetailPageConfiguration { } } -class DetailPage extends StatefulWidget { +class DetailPage extends StatelessWidget { final DetailPageConfiguration config; const DetailPage(this.config, {super.key}); @override - State createState() => _DetailPageState(); + Widget build(BuildContext context) { + // Separating body to a different widget to avoid + // unnecessary reinitialization of the InheritedDetailPageState + // when the body is rebuilt, which can reset state stored in it. + return InheritedDetailPageState(child: _Body(config)); + } } -class _DetailPageState extends State { +class _Body extends StatefulWidget { + final DetailPageConfiguration config; + + const _Body(this.config); + + @override + State<_Body> createState() => _BodyState(); +} + +class _BodyState extends State<_Body> { final _logger = Logger("DetailPageState"); bool _shouldDisableScroll = false; List? _files; @@ -137,102 +150,100 @@ class _DetailPageState extends State { _files!.length.toString() + " files .", ); - return InheritedDetailPageState( - child: PopScope( - canPop: !isGuestView, - onPopInvokedWithResult: (didPop, _) async { - if (isGuestView) { - final authenticated = await _requestAuthentication(); - if (authenticated) { - Bus.instance.fire(GuestViewEvent(false, false)); - await localSettings.setOnGuestView(false); - } + return PopScope( + canPop: !isGuestView, + onPopInvokedWithResult: (didPop, _) async { + if (isGuestView) { + final authenticated = await _requestAuthentication(); + if (authenticated) { + Bus.instance.fire(GuestViewEvent(false, false)); + await localSettings.setOnGuestView(false); } - }, - child: Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(80), - child: ValueListenableBuilder( - builder: (BuildContext context, int selectedIndex, _) { - return FileAppBar( - _files![selectedIndex], - _onFileRemoved, - widget.config.mode == DetailPageMode.full, - enableFullScreenNotifier: InheritedDetailPageState.of(context) - .enableFullScreenNotifier, - ); - }, - valueListenable: _selectedIndexNotifier, - ), + } + }, + child: Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(80), + child: ValueListenableBuilder( + builder: (BuildContext context, int selectedIndex, _) { + return FileAppBar( + _files![selectedIndex], + _onFileRemoved, + widget.config.mode == DetailPageMode.full, + enableFullScreenNotifier: InheritedDetailPageState.of(context) + .enableFullScreenNotifier, + ); + }, + valueListenable: _selectedIndexNotifier, ), - extendBodyBehindAppBar: true, - resizeToAvoidBottomInset: false, - backgroundColor: Colors.black, - body: Center( - child: Stack( - children: [ - _buildPageView(), - ValueListenableBuilder( - builder: (BuildContext context, int selectedIndex, _) { - return FileBottomBar( - _files![selectedIndex], - _onNewImageEditor, - widget.config.mode == DetailPageMode.minimalistic && - !isGuestView, - onFileRemoved: _onFileRemoved, - userID: Configuration.instance.getUserID(), - enableFullScreenNotifier: - InheritedDetailPageState.of(context) - .enableFullScreenNotifier, - ); - }, - valueListenable: _selectedIndexNotifier, - ), - ValueListenableBuilder( - valueListenable: _selectedIndexNotifier, - builder: (BuildContext context, int selectedIndex, _) { - if (_files![selectedIndex].isPanorama() == true) { - return ValueListenableBuilder( - valueListenable: InheritedDetailPageState.of(context) + ), + extendBodyBehindAppBar: true, + resizeToAvoidBottomInset: false, + backgroundColor: Colors.black, + body: Center( + child: Stack( + children: [ + _buildPageView(), + ValueListenableBuilder( + builder: (BuildContext context, int selectedIndex, _) { + return FileBottomBar( + _files![selectedIndex], + _onEditFileRequested, + widget.config.mode == DetailPageMode.minimalistic && + !isGuestView, + onFileRemoved: _onFileRemoved, + userID: Configuration.instance.getUserID(), + enableFullScreenNotifier: + InheritedDetailPageState.of(context) .enableFullScreenNotifier, - builder: (context, value, child) { - return IgnorePointer( - ignoring: value, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: !value ? 1.0 : 0.0, - child: Align( - alignment: Alignment.center, - child: Tooltip( - message: S.of(context).panorama, - child: IconButton( - style: IconButton.styleFrom( - backgroundColor: const Color(0xAA252525), - fixedSize: const Size(44, 44), - ), - icon: const Icon( - Icons.threesixty, - color: Colors.white, - size: 26, - ), - onPressed: () async { - await openPanoramaViewerPage( - _files![selectedIndex], - ); - }, + ); + }, + valueListenable: _selectedIndexNotifier, + ), + ValueListenableBuilder( + valueListenable: _selectedIndexNotifier, + builder: (BuildContext context, int selectedIndex, _) { + if (_files![selectedIndex].isPanorama() == true) { + return ValueListenableBuilder( + valueListenable: InheritedDetailPageState.of(context) + .enableFullScreenNotifier, + builder: (context, value, child) { + return IgnorePointer( + ignoring: value, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: !value ? 1.0 : 0.0, + child: Align( + alignment: Alignment.center, + child: Tooltip( + message: S.of(context).panorama, + child: IconButton( + style: IconButton.styleFrom( + backgroundColor: const Color(0xAA252525), + fixedSize: const Size(44, 44), ), + icon: const Icon( + Icons.threesixty, + color: Colors.white, + size: 26, + ), + onPressed: () async { + await openPanoramaViewerPage( + _files![selectedIndex], + ); + }, ), ), ), - ); - }, - ); - } - return const SizedBox(); - }, - ), - ], - ), + ), + ); + }, + ); + } + return const SizedBox(); + }, + ), + ], ), ), ), @@ -358,68 +369,6 @@ class _DetailPageState extends State { } } - Future _onNewImageEditor(EnteFile file) async { - if (file.uploadedFileID != null && - file.ownerID != Configuration.instance.getUserID()) { - _logger.severe( - "Attempt to edit unowned file", - UnauthorizedEditError(), - StackTrace.current, - ); - // ignore: unawaited_futures - showErrorDialog( - context, - S.of(context).sorry, - S.of(context).weDontSupportEditingPhotosAndAlbumsThatYouDont, - ); - return; - } - final dialog = createProgressDialog(context, S.of(context).pleaseWait); - await dialog.show(); - - try { - final ioFile = await getFile(file); - if (ioFile == null) { - showShortToast(context, S.of(context).failedToFetchOriginalForEdit); - await dialog.hide(); - return; - } - if (file.fileType == FileType.video) { - await dialog.hide(); - replacePage( - context, - VideoEditorPage( - file: file, - ioFile: ioFile, - detailPageConfig: widget.config.copyWith( - files: _files, - selectedIndex: _selectedIndexNotifier.value, - ), - ), - ); - return; - } - final imageProvider = - ExtendedFileImageProvider(ioFile, cacheRawData: true); - await precacheImage(imageProvider, context); - await dialog.hide(); - replacePage( - context, - NewImageEditor( - originalFile: file, - file: ioFile, - detailPageConfig: widget.config.copyWith( - files: _files, - selectedIndex: _selectedIndexNotifier.value, - ), - ), - ); - } catch (e) { - await dialog.hide(); - _logger.warning("Failed to initiate edit", e); - } - } - Future _onEditFileRequested(EnteFile file) async { if (file.uploadedFileID != null && file.ownerID != Configuration.instance.getUserID()) { @@ -438,6 +387,7 @@ class _DetailPageState extends State { } final dialog = createProgressDialog(context, S.of(context).pleaseWait); await dialog.show(); + try { final ioFile = await getFile(file); if (ioFile == null) { @@ -467,9 +417,9 @@ class _DetailPageState extends State { replacePage( context, ImageEditorPage( - imageProvider, - file, - widget.config.copyWith( + originalFile: file, + file: ioFile, + detailPageConfig: widget.config.copyWith( files: _files, selectedIndex: _selectedIndexNotifier.value, ), diff --git a/mobile/apps/photos/lib/utils/file_uploader.dart b/mobile/apps/photos/lib/utils/file_uploader.dart index 9bb28eeee4..890b167fea 100644 --- a/mobile/apps/photos/lib/utils/file_uploader.dart +++ b/mobile/apps/photos/lib/utils/file_uploader.dart @@ -519,7 +519,8 @@ class FileUploader { DateTime.now().microsecondsSinceEpoch, ); } catch (e) { - _logger.warning("Lock was already taken for " + file.toString()); + final lockInfo = await _uploadLocks.getLockData(lockKey); + _logger.warning("Lock was already taken ($lockInfo) for " + file.tag); throw LockAlreadyAcquiredError(); } diff --git a/mobile/apps/photos/pubspec.lock b/mobile/apps/photos/pubspec.lock index 13f6212d27..84aef16996 100644 --- a/mobile/apps/photos/pubspec.lock +++ b/mobile/apps/photos/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,7 +21,7 @@ packages: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" adaptive_theme: dependency: "direct main" description: @@ -34,10 +34,10 @@ packages: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" android_intent_plus: dependency: "direct main" description: @@ -317,10 +317,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" computer: dependency: "direct main" description: @@ -1271,38 +1271,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" - image_editor: - dependency: "direct main" - description: - name: image_editor - sha256: "38070067264fd9fea4328ca630d2ff7bd65ebe6aa4ed375d983b732d2ae7146b" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - image_editor_common: - dependency: transitive - description: - name: image_editor_common - sha256: "93d2f5c8b636f862775dd62a9ec20d09c8272598daa02f935955a4640e1844ee" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - image_editor_ohos: - dependency: transitive - description: - name: image_editor_ohos - sha256: "06756859586d5acefec6e3b4f356f9b1ce05ef09213bcb9a0ce1680ecea2d054" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - image_editor_platform_interface: - dependency: transitive - description: - name: image_editor_platform_interface - sha256: "474517efc770464f7d99942472d8cfb369a3c378e95466ec17f74d2b80bd40de" - url: "https://pub.dev" - source: hosted - version: "1.1.0" in_app_purchase: dependency: "direct main" description: @@ -1424,18 +1392,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -1544,10 +1512,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" maps_launcher: dependency: "direct main" description: @@ -1586,24 +1554,24 @@ packages: description: path: media_kit ref: HEAD - resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80" + resolved-ref: c9617f570b8c0ba02857e721997f78c053a856c1 url: "https://github.com/media-kit/media-kit" source: git - version: "1.1.11" + version: "1.2.0" media_kit_libs_android_video: dependency: transitive description: name: media_kit_libs_android_video - sha256: "9dd8012572e4aff47516e55f2597998f0a378e3d588d0fad0ca1f11a53ae090c" + sha256: adff9b571b8ead0867f9f91070f8df39562078c0eb3371d88b9029a2d547d7b7 url: "https://pub.dev" source: hosted - version: "1.3.6" + version: "1.3.7" media_kit_libs_ios_video: dependency: "direct main" description: path: "libs/ios/media_kit_libs_ios_video" ref: HEAD - resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80" + resolved-ref: c9617f570b8c0ba02857e721997f78c053a856c1 url: "https://github.com/media-kit/media-kit" source: git version: "1.1.4" @@ -1611,10 +1579,10 @@ packages: dependency: transitive description: name: media_kit_libs_linux - sha256: e186891c31daa6bedab4d74dcdb4e8adfccc7d786bfed6ad81fe24a3b3010310 + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.2.1" media_kit_libs_macos_video: dependency: transitive description: @@ -1628,27 +1596,27 @@ packages: description: path: "libs/universal/media_kit_libs_video" ref: HEAD - resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80" + resolved-ref: c9617f570b8c0ba02857e721997f78c053a856c1 url: "https://github.com/media-kit/media-kit" source: git - version: "1.0.5" + version: "1.0.6" media_kit_libs_windows_video: dependency: transitive description: name: media_kit_libs_windows_video - sha256: "32654572167825c42c55466f5d08eee23ea11061c84aa91b09d0e0f69bdd0887" + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab url: "https://pub.dev" source: hosted - version: "1.0.10" + version: "1.0.11" media_kit_video: dependency: "direct main" description: path: media_kit_video ref: HEAD - resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80" + resolved-ref: c9617f570b8c0ba02857e721997f78c053a856c1 url: "https://github.com/media-kit/media-kit" source: git - version: "1.2.5" + version: "1.3.0" meta: dependency: transitive description: @@ -1712,7 +1680,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "7814e2c61ee1fa74cef73b946eb08519c35bdaa5" + resolved-ref: "64e47a446bf3b64f012f2076481cebea51ca27cf" url: "https://github.com/ente-io/motionphoto.git" source: git version: "0.0.1" @@ -2325,7 +2293,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: @@ -2450,10 +2418,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" step_progress_indicator: dependency: "direct main" description: @@ -2482,10 +2450,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" styled_text: dependency: "direct main" description: @@ -2546,26 +2514,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" url: "https://pub.dev" source: hosted - version: "1.25.7" + version: "1.25.8" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" test_core: dependency: transitive description: name: test_core - sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.5" thermal: dependency: "direct main" description: @@ -2845,10 +2813,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" volume_controller: dependency: transitive description: @@ -2909,10 +2877,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" webkit_inspection_protocol: dependency: transitive description: diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index 80dd4a978b..245f2f4e48 100644 --- a/mobile/apps/photos/pubspec.yaml +++ b/mobile/apps/photos/pubspec.yaml @@ -114,7 +114,6 @@ dependencies: html_unescape: ^2.0.0 http: ^1.1.0 image: ^4.0.17 - image_editor: ^1.6.0 in_app_purchase: ^3.0.7 intl: ^0.19.0 latlong2: ^0.9.0 diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 5e5daef7ed..87d720530a 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -5,6 +5,9 @@ import ( "database/sql" b64 "encoding/base64" "fmt" + "github.com/ente-io/museum/pkg/controller/collections" + publicCtrl "github.com/ente-io/museum/pkg/controller/public" + "github.com/ente-io/museum/pkg/repo/public" "net/http" "os" "os/signal" @@ -14,8 +17,6 @@ import ( "syscall" "time" - "github.com/ente-io/museum/pkg/controller/collections" - "github.com/ente-io/museum/ente/base" "github.com/ente-io/museum/pkg/controller/emergency" "github.com/ente-io/museum/pkg/controller/file_copy" @@ -97,6 +98,7 @@ func main() { } viper.SetDefault("apps.public-albums", "https://albums.ente.io") + viper.SetDefault("apps.public-locker", "https://locker.ente.io") viper.SetDefault("apps.accounts", "https://accounts.ente.io") viper.SetDefault("apps.cast", "https://cast.ente.io") viper.SetDefault("apps.family", "https://family.ente.io") @@ -174,11 +176,13 @@ func main() { fileRepo := &repo.FileRepository{DB: db, S3Config: s3Config, QueueRepo: queueRepo, ObjectRepo: objectRepo, ObjectCleanupRepo: objectCleanupRepo, ObjectCopiesRepo: objectCopiesRepo, UsageRepo: usageRepo} + fileLinkRepo := public.NewFileLinkRepo(db) fileDataRepo := &fileDataRepo.Repository{DB: db, ObjectCleanupRepo: objectCleanupRepo} familyRepo := &repo.FamilyRepository{DB: db} - trashRepo := &repo.TrashRepository{DB: db, ObjectRepo: objectRepo, FileRepo: fileRepo, QueueRepo: queueRepo} - publicCollectionRepo := repo.NewPublicCollectionRepository(db, viper.GetString("apps.public-albums")) - collectionRepo := &repo.CollectionRepository{DB: db, FileRepo: fileRepo, PublicCollectionRepo: publicCollectionRepo, + trashRepo := &repo.TrashRepository{DB: db, ObjectRepo: objectRepo, FileRepo: fileRepo, QueueRepo: queueRepo, FileLinkRepo: fileLinkRepo} + collectionLinkRepo := public.NewCollectionLinkRepository(db, viper.GetString("apps.public-albums")) + + collectionRepo := &repo.CollectionRepository{DB: db, FileRepo: fileRepo, CollectionLinkRepo: collectionLinkRepo, TrashRepo: trashRepo, SecretEncryptionKey: secretEncryptionKeyBytes, QueueRepo: queueRepo, LatencyLogger: latencyLogger} pushRepo := &repo.PushTokenRepository{DB: db} kexRepo := &kex.Repository{ @@ -300,26 +304,27 @@ func main() { UsageRepo: usageRepo, } - publicCollectionCtrl := &controller.PublicCollectionController{ + collectionLinkCtrl := &publicCtrl.CollectionLinkController{ FileController: fileController, EmailNotificationCtrl: emailNotificationCtrl, - PublicCollectionRepo: publicCollectionRepo, + CollectionLinkRepo: collectionLinkRepo, + FileLinkRepo: fileLinkRepo, CollectionRepo: collectionRepo, UserRepo: userRepo, JwtSecret: jwtSecretBytes, } collectionController := &collections.CollectionController{ - CollectionRepo: collectionRepo, - EmailCtrl: emailNotificationCtrl, - AccessCtrl: accessCtrl, - PublicCollectionCtrl: publicCollectionCtrl, - UserRepo: userRepo, - FileRepo: fileRepo, - CastRepo: &castDb, - BillingCtrl: billingController, - QueueRepo: queueRepo, - TaskRepo: taskLockingRepo, + CollectionRepo: collectionRepo, + EmailCtrl: emailNotificationCtrl, + AccessCtrl: accessCtrl, + CollectionLinkCtrl: collectionLinkCtrl, + UserRepo: userRepo, + FileRepo: fileRepo, + CastRepo: &castDb, + BillingCtrl: billingController, + QueueRepo: queueRepo, + TaskRepo: taskLockingRepo, } kexCtrl := &kexCtrl.Controller{ @@ -351,6 +356,12 @@ func main() { userCache, userCacheCtrl, ) + fileLinkCtrl := &publicCtrl.FileLinkController{ + FileController: fileController, + FileLinkRepo: fileLinkRepo, + FileRepo: fileRepo, + JwtSecret: jwtSecretBytes, + } passkeyCtrl := &controller.PasskeyController{ Repo: passkeysRepo, @@ -358,14 +369,21 @@ func main() { } authMiddleware := middleware.AuthMiddleware{UserAuthRepo: userAuthRepo, Cache: authCache, UserController: userController} - accessTokenMiddleware := middleware.AccessTokenMiddleware{ - PublicCollectionRepo: publicCollectionRepo, - PublicCollectionCtrl: publicCollectionCtrl, + collectionLinkMiddleware := middleware.CollectionLinkMiddleware{ + CollectionLinkRepo: collectionLinkRepo, + PublicCollectionCtrl: collectionLinkCtrl, CollectionRepo: collectionRepo, Cache: accessTokenCache, BillingCtrl: billingController, DiscordController: discordController, } + fileLinkMiddleware := &middleware.FileLinkMiddleware{ + FileLinkRepo: fileLinkRepo, + FileLinkCtrl: fileLinkCtrl, + Cache: accessTokenCache, + BillingCtrl: billingController, + DiscordController: discordController, + } if environment != "local" { gin.SetMode(gin.ReleaseMode) @@ -404,7 +422,9 @@ func main() { familiesJwtAuthAPI.Use(rateLimiter.GlobalRateLimiter(), authMiddleware.TokenAuthMiddleware(jwt.FAMILIES.Ptr()), rateLimiter.APIRateLimitForUserMiddleware(urlSanitizer)) publicCollectionAPI := server.Group("/public-collection") - publicCollectionAPI.Use(rateLimiter.GlobalRateLimiter(), accessTokenMiddleware.AccessTokenAuthMiddleware(urlSanitizer)) + publicCollectionAPI.Use(rateLimiter.GlobalRateLimiter(), collectionLinkMiddleware.Authenticate(urlSanitizer)) + fileLinkApi := server.GET("/file-link") + fileLinkApi.Use(rateLimiter.GlobalRateLimiter(), fileLinkMiddleware.Authenticate(urlSanitizer)) healthCheckHandler := &api.HealthCheckHandler{ DB: db, @@ -432,6 +452,7 @@ func main() { Controller: fileController, FileCopyCtrl: fileCopyCtrl, FileDataCtrl: fileDataCtrl, + FileUrlCtrl: fileLinkCtrl, } privateAPI.GET("/files/upload-urls", fileHandler.GetUploadURLs) privateAPI.GET("/files/multipart-upload-urls", fileHandler.GetMultipartUploadURLs) @@ -440,6 +461,11 @@ func main() { privateAPI.GET("/files/preview/:fileID", fileHandler.GetThumbnail) privateAPI.GET("/files/preview/v2/:fileID", fileHandler.GetThumbnail) + privateAPI.POST("/files/share-url", fileHandler.ShareUrl) + privateAPI.PUT("/files/share-url", fileHandler.UpdateFileURL) + privateAPI.DELETE("/files/share-url/:fileID", fileHandler.DisableUrl) + privateAPI.GET("/files/share-urls/", fileHandler.GetUrls) + privateAPI.PUT("/files/data", fileHandler.PutFileData) privateAPI.PUT("/files/video-data", fileHandler.PutVideoData) privateAPI.POST("/files/data/status-diff", fileHandler.FileDataStatusDiff) @@ -566,13 +592,19 @@ func main() { privateAPI.PUT("/collections/sharee-magic-metadata", collectionHandler.ShareeMagicMetadataUpdate) publicCollectionHandler := &api.PublicCollectionHandler{ - Controller: publicCollectionCtrl, + Controller: collectionLinkCtrl, FileCtrl: fileController, CollectionCtrl: collectionController, FileDataCtrl: fileDataCtrl, StorageBonusController: storageBonusCtrl, } + fileLinkApi.GET("/info", fileHandler.LinkInfo) + fileLinkApi.GET("/pass-info", fileHandler.PasswordInfo) + fileLinkApi.GET("/thumbnail", fileHandler.LinkThumbnail) + fileLinkApi.GET("/file", fileHandler.LinkFile) + fileLinkApi.POST("/verify-password", fileHandler.VerifyPassword) + publicCollectionAPI.GET("/files/preview/:fileID", publicCollectionHandler.GetThumbnail) publicCollectionAPI.GET("/files/download/:fileID", publicCollectionHandler.GetFile) publicCollectionAPI.GET("/files/data/fetch", publicCollectionHandler.GetFileData) @@ -770,7 +802,7 @@ func main() { setKnownAPIs(server.Routes()) setupAndStartBackgroundJobs(objectCleanupController, replicationController3, fileDataCtrl) setupAndStartCrons( - userAuthRepo, publicCollectionRepo, twoFactorRepo, passkeysRepo, fileController, taskLockingRepo, emailNotificationCtrl, + userAuthRepo, collectionLinkRepo, fileLinkRepo, twoFactorRepo, passkeysRepo, fileController, taskLockingRepo, emailNotificationCtrl, trashController, pushController, objectController, dataCleanupController, storageBonusCtrl, emergencyCtrl, embeddingController, healthCheckHandler, kexCtrl, castDb) @@ -899,7 +931,8 @@ func setupAndStartBackgroundJobs( objectCleanupController.StartClearingOrphanObjects() } -func setupAndStartCrons(userAuthRepo *repo.UserAuthRepository, publicCollectionRepo *repo.PublicCollectionRepository, +func setupAndStartCrons(userAuthRepo *repo.UserAuthRepository, collectionLinkRepo *public.CollectionLinkRepo, + fileLinkRepo *public.FileLinkRepository, twoFactorRepo *repo.TwoFactorRepository, passkeysRepo *passkey.Repository, fileController *controller.FileController, taskRepo *repo.TaskLockRepository, emailNotificationCtrl *email.EmailNotificationController, trashController *controller.TrashController, pushController *controller.PushController, @@ -925,7 +958,8 @@ func setupAndStartCrons(userAuthRepo *repo.UserAuthRepository, publicCollectionR schedule(c, "@every 24h", func() { _ = userAuthRepo.RemoveDeletedTokens(timeUtil.MicrosecondsBeforeDays(30)) _ = castDb.DeleteOldSessions(context.Background(), timeUtil.MicrosecondsBeforeDays(7)) - _ = publicCollectionRepo.CleanupAccessHistory(context.Background()) + _ = collectionLinkRepo.CleanupAccessHistory(context.Background()) + _ = fileLinkRepo.CleanupAccessHistory(context.Background()) }) schedule(c, "@every 1m", func() { diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index b6b1d567eb..a16f560e43 100644 --- a/server/configurations/local.yaml +++ b/server/configurations/local.yaml @@ -79,9 +79,14 @@ http: apps: # Default is https://albums.ente.io # - # If you're running a self hosted instance and wish to serve public links, + # If you're running a self hosted instance and wish to serve public links for photos, # set this to the URL where your albums web app is running. public-albums: + # Default is https://locker.ente.io + # + # If you're running a self-hosted instance and wish to serve public links for locker, + # set this to the URL where your albums web app is running. + public-locker: # Default is https://cast.ente.io cast: # Default is https://accounts.ente.io diff --git a/server/ente/errors.go b/server/ente/errors.go index 4a5a02fb47..2370ab7fe8 100644 --- a/server/ente/errors.go +++ b/server/ente/errors.go @@ -97,8 +97,8 @@ var ErrUserDeleted = errors.New("user account has been deleted") // ErrLockUnavailable is thrown when a lock could not be acquired var ErrLockUnavailable = errors.New("could not acquire lock") -// ErrActiveLinkAlreadyExists is thrown when the collection already has active public link -var ErrActiveLinkAlreadyExists = errors.New("Collection already has active public link") +// ErrActiveLinkAlreadyExists is thrown when an active link already exists for entity +var ErrActiveLinkAlreadyExists = errors.New("link already exists for this entity") // ErrNotImplemented indicates that the action that we tried to perform is not // available at this museum instance. e.g. this could be something that is not @@ -176,6 +176,11 @@ var ErrMaxPasskeysReached = ApiError{ Message: "Max passkeys limit reached", HttpStatusCode: http.StatusConflict, } +var ErrPassProtectedResource = ApiError{ + Code: "PASS_PROTECTED_RESOURCE", + Message: "This resource is password protected", + HttpStatusCode: http.StatusForbidden, +} var ErrCastPermissionDenied = ApiError{ Code: "CAST_PERMISSION_DENIED", diff --git a/server/ente/file_link.go b/server/ente/file_link.go new file mode 100644 index 0000000000..e817e35da0 --- /dev/null +++ b/server/ente/file_link.go @@ -0,0 +1,94 @@ +package ente + +import ( + "fmt" + "github.com/ente-io/museum/pkg/utils/time" +) + +// CreateFileUrl represents an encrypted file in the system +type CreateFileUrl struct { + FileID int64 `json:"fileID" binding:"required"` + App App `json:"app" binding:"required"` +} + +// UpdateFileUrl .. +type UpdateFileUrl struct { + LinkID string `json:"linkID" binding:"required"` + FileID int64 `json:"fileID" binding:"required"` + ValidTill *int64 `json:"validTill"` + DeviceLimit *int `json:"deviceLimit"` + PassHash *string + Nonce *string + MemLimit *int64 + OpsLimit *int64 + EnableDownload *bool `json:"enableDownload"` + DisablePassword *bool `json:"disablePassword"` +} + +func (ut *UpdateFileUrl) Validate() error { + if ut.DeviceLimit == nil && ut.ValidTill == nil && ut.DisablePassword == nil && + ut.Nonce == nil && ut.PassHash == nil && ut.EnableDownload == nil { + return NewBadRequestWithMessage("all parameters are missing") + } + + if ut.DeviceLimit != nil && (*ut.DeviceLimit < 0 || *ut.DeviceLimit > 50) { + return NewBadRequestWithMessage(fmt.Sprintf("device limit: %d out of range [0-50]", *ut.DeviceLimit)) + } + + if ut.ValidTill != nil && *ut.ValidTill != 0 && *ut.ValidTill < time.Microseconds() { + return NewBadRequestWithMessage("valid till should be greater than current timestamp") + } + + var allPassParamsMissing = ut.Nonce == nil && ut.PassHash == nil && ut.MemLimit == nil && ut.OpsLimit == nil + var allPassParamsPresent = ut.Nonce != nil && ut.PassHash != nil && ut.MemLimit != nil && ut.OpsLimit != nil + + if !(allPassParamsMissing || allPassParamsPresent) { + return NewBadRequestWithMessage("all password params should be either present or missing") + } + + if allPassParamsPresent && ut.DisablePassword != nil && *ut.DisablePassword { + return NewBadRequestWithMessage("can not set and disable password in same request") + } + return nil +} + +type FileLinkRow struct { + LinkID string + OwnerID int64 + FileID int64 + Token string + DeviceLimit int + ValidTill int64 + IsDisabled bool + PassHash *string + Nonce *string + MemLimit *int64 + OpsLimit *int64 + EnableDownload bool + CreatedAt int64 + UpdatedAt int64 +} + +type FileUrl struct { + LinkID string `json:"linkID" binding:"required"` + URL string `json:"url" binding:"required"` + OwnerID int64 `json:"ownerID" binding:"required"` + FileID int64 `json:"fileID" binding:"required"` + ValidTill int64 `json:"validTill"` + DeviceLimit int `json:"deviceLimit"` + PasswordEnabled bool `json:"passwordEnabled"` + // Nonce contains the nonce value for the password if the link is password protected. + Nonce *string `json:"nonce,omitempty"` + MemLimit *int64 `json:"memLimit,omitempty"` + OpsLimit *int64 `json:"opsLimit,omitempty"` + EnableDownload bool `json:"enableDownload"` + CreatedAt int64 `json:"createdAt"` +} + +type FileLinkAccessContext struct { + LinkID string + IP string + UserAgent string + FileID int64 + OwnerID int64 +} diff --git a/server/ente/jwt/jwt.go b/server/ente/jwt/jwt.go index 94cfa995f2..c4d210b66c 100644 --- a/server/ente/jwt/jwt.go +++ b/server/ente/jwt/jwt.go @@ -40,13 +40,13 @@ func (w WebCommonJWTClaim) Valid() error { return nil } -// PublicAlbumPasswordClaim refer to token granted post public album password verification -type PublicAlbumPasswordClaim struct { +// LinkPasswordClaim refer to token granted post link password verification +type LinkPasswordClaim struct { PassHash string `json:"passKey"` ExpiryTime int64 `json:"expiryTime"` } -func (c PublicAlbumPasswordClaim) Valid() error { +func (c LinkPasswordClaim) Valid() error { if c.ExpiryTime < time.Microseconds() { return errors.New("token expired") } diff --git a/server/ente/public_collection.go b/server/ente/public_collection.go index eb1bd8c385..5f3867e6d0 100644 --- a/server/ente/public_collection.go +++ b/server/ente/public_collection.go @@ -3,7 +3,7 @@ package ente import ( "database/sql/driver" "encoding/json" - + "fmt" "github.com/ente-io/museum/pkg/utils/time" "github.com/ente-io/stacktrace" ) @@ -32,6 +32,33 @@ type UpdatePublicAccessTokenRequest struct { EnableJoin *bool `json:"enableJoin"` } +func (ut *UpdatePublicAccessTokenRequest) Validate() error { + if ut.DeviceLimit == nil && ut.ValidTill == nil && ut.DisablePassword == nil && + ut.Nonce == nil && ut.PassHash == nil && ut.EnableDownload == nil && ut.EnableCollect == nil { + return NewBadRequestWithMessage("all parameters are missing") + } + + if ut.DeviceLimit != nil && (*ut.DeviceLimit < 0 || *ut.DeviceLimit > 50) { + return NewBadRequestWithMessage(fmt.Sprintf("device limit: %d out of range [0-50]", *ut.DeviceLimit)) + } + + if ut.ValidTill != nil && *ut.ValidTill != 0 && *ut.ValidTill < time.Microseconds() { + return NewBadRequestWithMessage("valid till should be greater than current timestamp") + } + + var allPassParamsMissing = ut.Nonce == nil && ut.PassHash == nil && ut.MemLimit == nil && ut.OpsLimit == nil + var allPassParamsPresent = ut.Nonce != nil && ut.PassHash != nil && ut.MemLimit != nil && ut.OpsLimit != nil + + if !(allPassParamsMissing || allPassParamsPresent) { + return NewBadRequestWithMessage("all password params should be either present or missing") + } + + if allPassParamsPresent && ut.DisablePassword != nil && *ut.DisablePassword { + return NewBadRequestWithMessage("can not set and disable password in same request") + } + return nil +} + type VerifyPasswordRequest struct { PassHash string `json:"passHash" binding:"required"` } @@ -40,8 +67,8 @@ type VerifyPasswordResponse struct { JWTToken string `json:"jwtToken"` } -// PublicCollectionToken represents row entity for public_collection_token table -type PublicCollectionToken struct { +// CollectionLinkRow represents row entity for public_collection_token table +type CollectionLinkRow struct { ID int64 CollectionID int64 Token string @@ -57,7 +84,7 @@ type PublicCollectionToken struct { EnableJoin bool } -func (p PublicCollectionToken) CanJoin() error { +func (p CollectionLinkRow) CanJoin() error { if p.IsDisabled { return NewBadRequestWithMessage("link disabled") } diff --git a/server/migrations/103_single_file_url.down.sql b/server/migrations/103_single_file_url.down.sql new file mode 100644 index 0000000000..2efd3e0053 --- /dev/null +++ b/server/migrations/103_single_file_url.down.sql @@ -0,0 +1,3 @@ + +DROP TABLE IF EXISTS public_file_tokens_access_history; +DROP TABLE IF EXISTS public_file_tokens; diff --git a/server/migrations/103_single_file_url.up.sql b/server/migrations/103_single_file_url.up.sql new file mode 100644 index 0000000000..3145e46aad --- /dev/null +++ b/server/migrations/103_single_file_url.up.sql @@ -0,0 +1,46 @@ + + +CREATE TABLE IF NOT EXISTS public_file_tokens +( + id text primary key, + file_id bigint NOT NULL, + owner_id bigint NOT NULL, + app text NOT NULL, + access_token text not null, + valid_till bigint not null DEFAULT 0, + device_limit int not null DEFAULT 0, + is_disabled bool not null DEFAULT FALSE, + enable_download bool not null DEFAULT TRUE, + pw_hash TEXT, + pw_nonce TEXT, + mem_limit BIGINT, + ops_limit BIGINT, + created_at bigint NOT NULL DEFAULT now_utc_micro_seconds(), + updated_at bigint NOT NULL DEFAULT now_utc_micro_seconds() +); + + +CREATE OR REPLACE TRIGGER update_public_file_tokens_updated_at + BEFORE UPDATE + ON public_file_tokens + FOR EACH ROW +EXECUTE PROCEDURE + trigger_updated_at_microseconds_column(); + + +CREATE TABLE IF NOT EXISTS public_file_tokens_access_history +( + id text NOT NULL, + ip text not null, + user_agent text not null, + created_at bigint NOT NULL DEFAULT now_utc_micro_seconds(), + CONSTRAINT unique_access_id_ip_ua UNIQUE (id, ip, user_agent), + CONSTRAINT fk_public_file_history_token_id + FOREIGN KEY (id) + REFERENCES public_file_tokens (id) + ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS public_file_token_unique_idx ON public_file_tokens (access_token) WHERE is_disabled = FALSE; +CREATE INDEX IF NOT EXISTS public_file_tokens_owner_id_updated_at_idx ON public_file_tokens (owner_id, updated_at); +CREATE UNIQUE INDEX IF NOT EXISTS public_active_file_link_unique_idx ON public_file_tokens (file_id, is_disabled) WHERE is_disabled = FALSE; diff --git a/server/pkg/api/collection.go b/server/pkg/api/collection.go index 9318f5c329..6c198652c7 100644 --- a/server/pkg/api/collection.go +++ b/server/pkg/api/collection.go @@ -10,7 +10,6 @@ import ( log "github.com/sirupsen/logrus" "github.com/ente-io/museum/ente" - "github.com/ente-io/museum/pkg/controller" "github.com/ente-io/museum/pkg/utils/auth" "github.com/ente-io/museum/pkg/utils/handler" "github.com/ente-io/museum/pkg/utils/time" @@ -172,35 +171,6 @@ func (h *CollectionHandler) UpdateShareURL(c *gin.Context) { handler.Error(c, stacktrace.Propagate(err, "")) return } - if req.DeviceLimit == nil && req.ValidTill == nil && req.DisablePassword == nil && - req.Nonce == nil && req.PassHash == nil && req.EnableDownload == nil && req.EnableCollect == nil { - handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "all parameters are missing")) - return - } - - if req.DeviceLimit != nil && (*req.DeviceLimit < 0 || *req.DeviceLimit > controller.DeviceLimitThreshold) { - handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("device limit: %d out of range", *req.DeviceLimit))) - return - } - - if req.ValidTill != nil && *req.ValidTill != 0 && *req.ValidTill < time.Microseconds() { - handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "valid till should be greater than current timestamp")) - return - } - - var allPassParamsMissing = req.Nonce == nil && req.PassHash == nil && req.MemLimit == nil && req.OpsLimit == nil - var allPassParamsPresent = req.Nonce != nil && req.PassHash != nil && req.MemLimit != nil && req.OpsLimit != nil - - if !(allPassParamsMissing || allPassParamsPresent) { - handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "all password params should be either present or missing")) - return - } - - if allPassParamsPresent && req.DisablePassword != nil && *req.DisablePassword { - handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "can not set and disable password in same request")) - return - } - response, err := h.Controller.UpdateShareURL(c, auth.GetUserID(c.Request.Header), req) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) diff --git a/server/pkg/api/file.go b/server/pkg/api/file.go index 2e15ade325..4ec205d1bb 100644 --- a/server/pkg/api/file.go +++ b/server/pkg/api/file.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/ente-io/museum/pkg/controller/file_copy" "github.com/ente-io/museum/pkg/controller/filedata" + "github.com/ente-io/museum/pkg/controller/public" "net/http" "os" "strconv" @@ -24,6 +25,7 @@ import ( // FileHandler exposes request handlers for all encrypted file related requests type FileHandler struct { Controller *controller.FileController + FileUrlCtrl *public.FileLinkController FileCopyCtrl *file_copy.FileCopyController FileDataCtrl *filedata.Controller } diff --git a/server/pkg/api/file_link.go b/server/pkg/api/file_link.go new file mode 100644 index 0000000000..d243a87532 --- /dev/null +++ b/server/pkg/api/file_link.go @@ -0,0 +1,141 @@ +package api + +import ( + "github.com/ente-io/museum/ente" + "github.com/ente-io/museum/pkg/utils/auth" + "github.com/ente-io/museum/pkg/utils/handler" + "github.com/ente-io/stacktrace" + "github.com/gin-gonic/gin" + "net/http" + "strconv" +) + +// ShareUrl a sharable url for the file +func (h *FileHandler) ShareUrl(c *gin.Context) { + var file ente.CreateFileUrl + if err := c.ShouldBindJSON(&file); err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + + response, err := h.FileUrlCtrl.CreateLink(c, file) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, response) +} + +func (h *FileHandler) LinkInfo(c *gin.Context) { + resp, err := h.FileUrlCtrl.Info(c) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{ + "file": resp, + }) +} + +func (h *FileHandler) PasswordInfo(c *gin.Context) { + resp, err := h.FileUrlCtrl.PassInfo(c) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{ + "nonce": resp.Nonce, + "opsLimit": resp.OpsLimit, + "memLimit": resp.MemLimit, + }) +} + +func (h *FileHandler) LinkThumbnail(c *gin.Context) { + linkCtx := auth.MustGetFileLinkAccessContext(c) + url, err := h.Controller.GetThumbnailURL(c, linkCtx.OwnerID, linkCtx.FileID) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.Redirect(http.StatusTemporaryRedirect, url) +} + +func (h *FileHandler) LinkFile(c *gin.Context) { + linkCtx := auth.MustGetFileLinkAccessContext(c) + url, err := h.Controller.GetFileURL(c, linkCtx.OwnerID, linkCtx.FileID) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.Redirect(http.StatusTemporaryRedirect, url) +} + +func (h *FileHandler) DisableUrl(c *gin.Context) { + cID, err := strconv.ParseInt(c.Param("fileID"), 10, 64) + if err != nil { + handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "")) + return + } + err = h.FileUrlCtrl.Disable(c, cID) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{}) +} + +func (h *FileHandler) GetUrls(c *gin.Context) { + sinceTime, err := strconv.ParseInt(c.Query("sinceTime"), 10, 64) + if err != nil { + handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "sinceTime parsing failed")) + return + } + limit := 500 + if c.Query("limit") != "" { + limit, err = strconv.Atoi(c.Query("limit")) + if err != nil || limit < 1 { + handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "")) + return + } + } + response, err := h.FileUrlCtrl.GetUrls(c, sinceTime, int64(limit)) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{ + "diff": response, + }) +} + +// VerifyPassword verifies the password for given link access token and return signed jwt token if it's valid +func (h *FileHandler) VerifyPassword(c *gin.Context) { + var req ente.VerifyPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + resp, err := h.FileUrlCtrl.VerifyPassword(c, req) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, resp) +} + +// UpdateFileURL updates the share URL for a file +func (h *FileHandler) UpdateFileURL(c *gin.Context) { + var req ente.UpdateFileUrl + if err := c.ShouldBindJSON(&req); err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + response, err := h.FileUrlCtrl.UpdateSharedUrl(c, req) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{ + "result": response, + }) +} diff --git a/server/pkg/api/public_collection.go b/server/pkg/api/public_collection.go index 9f61ba788e..81e1836f90 100644 --- a/server/pkg/api/public_collection.go +++ b/server/pkg/api/public_collection.go @@ -5,6 +5,7 @@ import ( fileData "github.com/ente-io/museum/ente/filedata" "github.com/ente-io/museum/pkg/controller/collections" "github.com/ente-io/museum/pkg/controller/filedata" + "github.com/ente-io/museum/pkg/controller/public" "net/http" "strconv" @@ -20,7 +21,7 @@ import ( // PublicCollectionHandler exposes request handlers for publicly accessible collections type PublicCollectionHandler struct { - Controller *controller.PublicCollectionController + Controller *public.CollectionLinkController FileCtrl *controller.FileController CollectionCtrl *collections.CollectionController FileDataCtrl *filedata.Controller diff --git a/server/pkg/controller/collections/collection.go b/server/pkg/controller/collections/collection.go index 5f096bc133..5b86d38d0a 100644 --- a/server/pkg/controller/collections/collection.go +++ b/server/pkg/controller/collections/collection.go @@ -6,6 +6,7 @@ import ( "github.com/ente-io/museum/pkg/controller" "github.com/ente-io/museum/pkg/controller/access" "github.com/ente-io/museum/pkg/controller/email" + "github.com/ente-io/museum/pkg/controller/public" "github.com/ente-io/museum/pkg/repo/cast" "github.com/ente-io/museum/pkg/utils/array" "github.com/ente-io/museum/pkg/utils/auth" @@ -24,16 +25,16 @@ const ( // CollectionController encapsulates logic that deals with collections type CollectionController struct { - PublicCollectionCtrl *controller.PublicCollectionController - EmailCtrl *email.EmailNotificationController - AccessCtrl access.Controller - BillingCtrl *controller.BillingController - CollectionRepo *repo.CollectionRepository - UserRepo *repo.UserRepository - FileRepo *repo.FileRepository - QueueRepo *repo.QueueRepository - CastRepo *cast.Repository - TaskRepo *repo.TaskLockRepository + CollectionLinkCtrl *public.CollectionLinkController + EmailCtrl *email.EmailNotificationController + AccessCtrl access.Controller + BillingCtrl *controller.BillingController + CollectionRepo *repo.CollectionRepository + UserRepo *repo.UserRepository + FileRepo *repo.FileRepository + QueueRepo *repo.QueueRepository + CastRepo *cast.Repository + TaskRepo *repo.TaskLockRepository } // Create creates a collection @@ -148,7 +149,7 @@ func (c *CollectionController) TrashV3(ctx *gin.Context, req ente.TrashCollectio } } - err = c.PublicCollectionCtrl.Disable(ctx, cID) + err = c.CollectionLinkCtrl.Disable(ctx, cID) if err != nil { return stacktrace.Propagate(err, "failed to disabled public share url") } @@ -209,7 +210,7 @@ func (c *CollectionController) HandleAccountDeletion(ctx context.Context, userID if err != nil { return stacktrace.Propagate(err, "failed to revoke cast token for user") } - err = c.PublicCollectionCtrl.HandleAccountDeletion(ctx, userID, logger) + err = c.CollectionLinkCtrl.HandleAccountDeletion(ctx, userID, logger) return stacktrace.Propagate(err, "") } diff --git a/server/pkg/controller/collections/share.go b/server/pkg/controller/collections/share.go index ced64f0fdf..6002c7b493 100644 --- a/server/pkg/controller/collections/share.go +++ b/server/pkg/controller/collections/share.go @@ -70,21 +70,21 @@ func (c *CollectionController) JoinViaLink(ctx *gin.Context, req ente.JoinCollec if !collection.AllowSharing() { return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("joining %s is not allowed", collection.Type)) } - publicCollectionToken, err := c.PublicCollectionCtrl.GetActivePublicCollectionToken(ctx, req.CollectionID) + collectionLinkToken, err := c.CollectionLinkCtrl.GetActiveCollectionLinkToken(ctx, req.CollectionID) if err != nil { return stacktrace.Propagate(err, "") } - if canJoin := publicCollectionToken.CanJoin(); canJoin != nil { + if canJoin := collectionLinkToken.CanJoin(); canJoin != nil { return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("can not join collection: %s", canJoin.Error())) } accessToken := auth.GetAccessToken(ctx) - if publicCollectionToken.Token != accessToken { + if collectionLinkToken.Token != accessToken { return stacktrace.Propagate(ente.ErrPermissionDenied, "token doesn't match collection") } - if publicCollectionToken.PassHash != nil && *publicCollectionToken.PassHash != "" { + if collectionLinkToken.PassHash != nil && *collectionLinkToken.PassHash != "" { accessTokenJWT := auth.GetAccessTokenJWT(ctx) - if passCheckErr := c.PublicCollectionCtrl.ValidateJWTToken(ctx, accessTokenJWT, *publicCollectionToken.PassHash); passCheckErr != nil { + if passCheckErr := c.CollectionLinkCtrl.ValidateJWTToken(ctx, accessTokenJWT, *collectionLinkToken.PassHash); passCheckErr != nil { return stacktrace.Propagate(passCheckErr, "") } } @@ -93,7 +93,7 @@ func (c *CollectionController) JoinViaLink(ctx *gin.Context, req ente.JoinCollec return stacktrace.Propagate(err, "") } role := ente.VIEWER - if publicCollectionToken.EnableCollect { + if collectionLinkToken.EnableCollect { role = ente.COLLABORATOR } joinErr := c.CollectionRepo.Share(req.CollectionID, collection.Owner.ID, userID, req.EncryptedKey, role, time.Microseconds()) @@ -197,7 +197,7 @@ func (c *CollectionController) ShareURL(ctx context.Context, userID int64, req e if err != nil { return ente.PublicURL{}, stacktrace.Propagate(err, "") } - response, err := c.PublicCollectionCtrl.CreateAccessToken(ctx, req) + response, err := c.CollectionLinkCtrl.CreateLink(ctx, req) if err != nil { return ente.PublicURL{}, stacktrace.Propagate(err, "") } @@ -205,20 +205,26 @@ func (c *CollectionController) ShareURL(ctx context.Context, userID int64, req e } // UpdateShareURL updates the shared url configuration -func (c *CollectionController) UpdateShareURL(ctx context.Context, userID int64, req ente.UpdatePublicAccessTokenRequest) ( - ente.PublicURL, error) { +func (c *CollectionController) UpdateShareURL( + ctx context.Context, + userID int64, + req ente.UpdatePublicAccessTokenRequest, +) (*ente.PublicURL, error) { + if err := req.Validate(); err != nil { + return nil, stacktrace.Propagate(err, "") + } if err := c.verifyOwnership(req.CollectionID, userID); err != nil { - return ente.PublicURL{}, stacktrace.Propagate(err, "") + return nil, stacktrace.Propagate(err, "") } err := c.BillingCtrl.HasActiveSelfOrFamilySubscription(userID, true) if err != nil { - return ente.PublicURL{}, stacktrace.Propagate(err, "") + return nil, stacktrace.Propagate(err, "") } - response, err := c.PublicCollectionCtrl.UpdateSharedUrl(ctx, req) + response, err := c.CollectionLinkCtrl.UpdateSharedUrl(ctx, req) if err != nil { - return ente.PublicURL{}, stacktrace.Propagate(err, "") + return nil, stacktrace.Propagate(err, "") } - return response, nil + return &response, nil } // DisableSharedURL disable a public auth-token for the given collectionID @@ -226,7 +232,7 @@ func (c *CollectionController) DisableSharedURL(ctx context.Context, userID int6 if err := c.verifyOwnership(cID, userID); err != nil { return stacktrace.Propagate(err, "") } - err := c.PublicCollectionCtrl.Disable(ctx, cID) + err := c.CollectionLinkCtrl.Disable(ctx, cID) return stacktrace.Propagate(err, "") } diff --git a/server/pkg/controller/public_collection.go b/server/pkg/controller/public/collection_link.go similarity index 69% rename from server/pkg/controller/public_collection.go rename to server/pkg/controller/public/collection_link.go index 022a08812e..ead744bfbf 100644 --- a/server/pkg/controller/public_collection.go +++ b/server/pkg/controller/public/collection_link.go @@ -1,12 +1,13 @@ -package controller +package public import ( "context" "errors" "fmt" + "github.com/ente-io/museum/pkg/controller" + "github.com/ente-io/museum/pkg/repo/public" "github.com/ente-io/museum/ente" - enteJWT "github.com/ente-io/museum/ente/jwt" emailCtrl "github.com/ente-io/museum/pkg/controller/email" "github.com/ente-io/museum/pkg/repo" "github.com/ente-io/museum/pkg/utils/auth" @@ -14,7 +15,6 @@ import ( "github.com/ente-io/museum/pkg/utils/time" "github.com/ente-io/stacktrace" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt" "github.com/lithammer/shortuuid/v3" "github.com/sirupsen/logrus" ) @@ -49,23 +49,24 @@ const ( AbuseLimitExceededTemplate = "report_limit_exceeded_alert.html" ) -// PublicCollectionController controls share collection operations -type PublicCollectionController struct { - FileController *FileController +// CollectionLinkController controls share collection operations +type CollectionLinkController struct { + FileController *controller.FileController EmailNotificationCtrl *emailCtrl.EmailNotificationController - PublicCollectionRepo *repo.PublicCollectionRepository + CollectionLinkRepo *public.CollectionLinkRepo + FileLinkRepo *public.FileLinkRepository CollectionRepo *repo.CollectionRepository UserRepo *repo.UserRepository JwtSecret []byte } -func (c *PublicCollectionController) CreateAccessToken(ctx context.Context, req ente.CreatePublicAccessTokenRequest) (ente.PublicURL, error) { +func (c *CollectionLinkController) CreateLink(ctx context.Context, req ente.CreatePublicAccessTokenRequest) (ente.PublicURL, error) { accessToken := shortuuid.New()[0:AccessTokenLength] - err := c.PublicCollectionRepo. + err := c.CollectionLinkRepo. Insert(ctx, req.CollectionID, accessToken, req.ValidTill, req.DeviceLimit, req.EnableCollect, req.EnableJoin) if err != nil { if errors.Is(err, ente.ErrActiveLinkAlreadyExists) { - collectionToPubUrlMap, err2 := c.PublicCollectionRepo.GetCollectionToActivePublicURLMap(ctx, []int64{req.CollectionID}) + collectionToPubUrlMap, err2 := c.CollectionLinkRepo.GetCollectionToActivePublicURLMap(ctx, []int64{req.CollectionID}) if err2 != nil { return ente.PublicURL{}, stacktrace.Propagate(err2, "") } @@ -81,7 +82,7 @@ func (c *PublicCollectionController) CreateAccessToken(ctx context.Context, req } } response := ente.PublicURL{ - URL: c.PublicCollectionRepo.GetAlbumUrl(accessToken), + URL: c.CollectionLinkRepo.GetAlbumUrl(accessToken), ValidTill: req.ValidTill, DeviceLimit: req.DeviceLimit, EnableDownload: true, @@ -91,11 +92,11 @@ func (c *PublicCollectionController) CreateAccessToken(ctx context.Context, req return response, nil } -func (c *PublicCollectionController) GetActivePublicCollectionToken(ctx context.Context, collectionID int64) (ente.PublicCollectionToken, error) { - return c.PublicCollectionRepo.GetActivePublicCollectionToken(ctx, collectionID) +func (c *CollectionLinkController) GetActiveCollectionLinkToken(ctx context.Context, collectionID int64) (ente.CollectionLinkRow, error) { + return c.CollectionLinkRepo.GetActiveCollectionLinkRow(ctx, collectionID) } -func (c *PublicCollectionController) CreateFile(ctx *gin.Context, file ente.File, app ente.App) (ente.File, error) { +func (c *CollectionLinkController) CreateFile(ctx *gin.Context, file ente.File, app ente.App) (ente.File, error) { collection, err := c.GetPublicCollection(ctx, true) if err != nil { return ente.File{}, stacktrace.Propagate(err, "") @@ -118,13 +119,13 @@ func (c *PublicCollectionController) CreateFile(ctx *gin.Context, file ente.File } // Disable all public accessTokens generated for the given cID till date. -func (c *PublicCollectionController) Disable(ctx context.Context, cID int64) error { - err := c.PublicCollectionRepo.DisableSharing(ctx, cID) +func (c *CollectionLinkController) Disable(ctx context.Context, cID int64) error { + err := c.CollectionLinkRepo.DisableSharing(ctx, cID) return stacktrace.Propagate(err, "") } -func (c *PublicCollectionController) UpdateSharedUrl(ctx context.Context, req ente.UpdatePublicAccessTokenRequest) (ente.PublicURL, error) { - publicCollectionToken, err := c.PublicCollectionRepo.GetActivePublicCollectionToken(ctx, req.CollectionID) +func (c *CollectionLinkController) UpdateSharedUrl(ctx context.Context, req ente.UpdatePublicAccessTokenRequest) (ente.PublicURL, error) { + publicCollectionToken, err := c.CollectionLinkRepo.GetActiveCollectionLinkRow(ctx, req.CollectionID) if err != nil { return ente.PublicURL{}, err } @@ -154,12 +155,12 @@ func (c *PublicCollectionController) UpdateSharedUrl(ctx context.Context, req en if req.EnableJoin != nil { publicCollectionToken.EnableJoin = *req.EnableJoin } - err = c.PublicCollectionRepo.UpdatePublicCollectionToken(ctx, publicCollectionToken) + err = c.CollectionLinkRepo.UpdatePublicCollectionToken(ctx, publicCollectionToken) if err != nil { return ente.PublicURL{}, stacktrace.Propagate(err, "") } return ente.PublicURL{ - URL: c.PublicCollectionRepo.GetAlbumUrl(publicCollectionToken.Token), + URL: c.CollectionLinkRepo.GetAlbumUrl(publicCollectionToken.Token), DeviceLimit: publicCollectionToken.DeviceLimit, ValidTill: publicCollectionToken.ValidTill, EnableDownload: publicCollectionToken.EnableDownload, @@ -176,58 +177,23 @@ func (c *PublicCollectionController) UpdateSharedUrl(ctx context.Context, req en // used by the client to pass in other requests for public collection. // Having a separate endpoint for password validation allows us to easily rate-limit the attempts for brute-force // attack for guessing password. -func (c *PublicCollectionController) VerifyPassword(ctx *gin.Context, req ente.VerifyPasswordRequest) (*ente.VerifyPasswordResponse, error) { +func (c *CollectionLinkController) VerifyPassword(ctx *gin.Context, req ente.VerifyPasswordRequest) (*ente.VerifyPasswordResponse, error) { accessContext := auth.MustGetPublicAccessContext(ctx) - publicCollectionToken, err := c.PublicCollectionRepo.GetActivePublicCollectionToken(ctx, accessContext.CollectionID) + collectionLinkRow, err := c.CollectionLinkRepo.GetActiveCollectionLinkRow(ctx, accessContext.CollectionID) if err != nil { return nil, stacktrace.Propagate(err, "failed to get public collection info") } - if publicCollectionToken.PassHash == nil || *publicCollectionToken.PassHash == "" { - return nil, stacktrace.Propagate(ente.ErrBadRequest, "password is not configured for the link") - } - if req.PassHash != *publicCollectionToken.PassHash { - return nil, stacktrace.Propagate(ente.ErrInvalidPassword, "incorrect password for link") - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, &enteJWT.PublicAlbumPasswordClaim{ - PassHash: req.PassHash, - ExpiryTime: time.NDaysFromNow(365), - }) - // Sign and get the complete encoded token as a string using the secret - tokenString, err := token.SignedString(c.JwtSecret) - - if err != nil { - return nil, stacktrace.Propagate(err, "") - } - return &ente.VerifyPasswordResponse{ - JWTToken: tokenString, - }, nil + return verifyPassword(c.JwtSecret, collectionLinkRow.PassHash, req) } -func (c *PublicCollectionController) ValidateJWTToken(ctx *gin.Context, jwtToken string, passwordHash string) error { - token, err := jwt.ParseWithClaims(jwtToken, &enteJWT.PublicAlbumPasswordClaim{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return stacktrace.Propagate(fmt.Errorf("unexpected signing method: %v", token.Header["alg"]), ""), nil - } - return c.JwtSecret, nil - }) - if err != nil { - return stacktrace.Propagate(err, "JWT parsed failed") - } - claims, ok := token.Claims.(*enteJWT.PublicAlbumPasswordClaim) - - if !ok { - return stacktrace.Propagate(errors.New("no claim in jwt token"), "") - } - if token.Valid && claims.PassHash == passwordHash { - return nil - } - return ente.ErrInvalidPassword +func (c *CollectionLinkController) ValidateJWTToken(ctx *gin.Context, jwtToken string, passwordHash string) error { + return validateJWTToken(c.JwtSecret, jwtToken, passwordHash) } // ReportAbuse captures abuse report for a publicly shared collection. // It will also disable the accessToken for the collection if total abuse reports for the said collection // reaches AutoDisableAbuseThreshold -func (c *PublicCollectionController) ReportAbuse(ctx *gin.Context, req ente.AbuseReportRequest) error { +func (c *CollectionLinkController) ReportAbuse(ctx *gin.Context, req ente.AbuseReportRequest) error { accessContext := auth.MustGetPublicAccessContext(ctx) readableReason, found := AllowedReasons[req.Reason] if !found { @@ -235,11 +201,11 @@ func (c *PublicCollectionController) ReportAbuse(ctx *gin.Context, req ente.Abus } logrus.WithField("collectionID", accessContext.CollectionID).Error("CRITICAL: received abuse report") - err := c.PublicCollectionRepo.RecordAbuseReport(ctx, accessContext, req.URL, req.Reason, req.Details) + err := c.CollectionLinkRepo.RecordAbuseReport(ctx, accessContext, req.URL, req.Reason, req.Details) if err != nil { return stacktrace.Propagate(err, "") } - count, err := c.PublicCollectionRepo.GetAbuseReportCount(ctx, accessContext) + count, err := c.CollectionLinkRepo.GetAbuseReportCount(ctx, accessContext) if err != nil { return stacktrace.Propagate(err, "") } @@ -253,7 +219,7 @@ func (c *PublicCollectionController) ReportAbuse(ctx *gin.Context, req ente.Abus return nil } -func (c *PublicCollectionController) onAbuseReportReceived(collectionID int64, report ente.AbuseReportRequest, readableReason string, abuseCount int64) { +func (c *CollectionLinkController) onAbuseReportReceived(collectionID int64, report ente.AbuseReportRequest, readableReason string, abuseCount int64) { collection, err := c.CollectionRepo.Get(collectionID) if err != nil { logrus.Error("Could not get collection for abuse report") @@ -292,9 +258,9 @@ func (c *PublicCollectionController) onAbuseReportReceived(collectionID int64, r } } -func (c *PublicCollectionController) HandleAccountDeletion(ctx context.Context, userID int64, logger *logrus.Entry) error { +func (c *CollectionLinkController) HandleAccountDeletion(ctx context.Context, userID int64, logger *logrus.Entry) error { logger.Info("updating public collection on account deletion") - collectionIDs, err := c.PublicCollectionRepo.GetActivePublicTokenForUser(ctx, userID) + collectionIDs, err := c.CollectionLinkRepo.GetActivePublicTokenForUser(ctx, userID) if err != nil { return stacktrace.Propagate(err, "") } @@ -305,12 +271,12 @@ func (c *PublicCollectionController) HandleAccountDeletion(ctx context.Context, return stacktrace.Propagate(err, "") } } - return nil + return c.FileLinkRepo.DisableLinksForUser(ctx, userID) } // GetPublicCollection will return collection info for a public url. // is mustAllowCollect is set to true but the underlying collection doesn't allow uploading -func (c *PublicCollectionController) GetPublicCollection(ctx *gin.Context, mustAllowCollect bool) (ente.Collection, error) { +func (c *CollectionLinkController) GetPublicCollection(ctx *gin.Context, mustAllowCollect bool) (ente.Collection, error) { accessContext := auth.MustGetPublicAccessContext(ctx) collection, err := c.CollectionRepo.Get(accessContext.CollectionID) if err != nil { diff --git a/server/pkg/controller/public/file_link.go b/server/pkg/controller/public/file_link.go new file mode 100644 index 0000000000..be015bc719 --- /dev/null +++ b/server/pkg/controller/public/file_link.go @@ -0,0 +1,162 @@ +package public + +import ( + "github.com/ente-io/museum/ente" + "github.com/ente-io/museum/pkg/controller" + "github.com/ente-io/museum/pkg/repo" + "github.com/ente-io/museum/pkg/repo/public" + "github.com/ente-io/museum/pkg/utils/auth" + "github.com/ente-io/stacktrace" + "github.com/gin-gonic/gin" + "github.com/lithammer/shortuuid/v3" +) + +// FileLinkController controls share collection operations +type FileLinkController struct { + FileController *controller.FileController + FileLinkRepo *public.FileLinkRepository + FileRepo *repo.FileRepository + JwtSecret []byte +} + +func (c *FileLinkController) CreateLink(ctx *gin.Context, req ente.CreateFileUrl) (*ente.FileUrl, error) { + actorUserID := auth.GetUserID(ctx.Request.Header) + app := auth.GetApp(ctx) + if req.App != app { + return nil, stacktrace.Propagate(ente.NewBadRequestWithMessage("app mismatch"), "app mismatch") + } + file, err := c.FileRepo.GetFileAttributes(req.FileID) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to get file attributes") + } + if actorUserID != file.OwnerID { + return nil, stacktrace.Propagate(ente.NewPermissionDeniedError("not file owner"), "") + } + accessToken := shortuuid.New()[0:AccessTokenLength] + _, err = c.FileLinkRepo.Insert(ctx, req.FileID, actorUserID, accessToken, app) + if err == nil || err == ente.ErrActiveLinkAlreadyExists { + row, rowErr := c.FileLinkRepo.GetFileUrlRowByFileID(ctx, req.FileID) + if rowErr != nil { + return nil, stacktrace.Propagate(rowErr, "failed to get active file url token") + } + return c.mapRowToFileUrl(ctx, row), nil + } + return nil, stacktrace.Propagate(err, "failed to create public file link") +} + +// Disable all public accessTokens generated for the given fileID till date. +func (c *FileLinkController) Disable(ctx *gin.Context, fileID int64) error { + userID := auth.GetUserID(ctx.Request.Header) + file, err := c.FileRepo.GetFileAttributes(fileID) + if err != nil { + return stacktrace.Propagate(err, "failed to get file attributes") + } + if userID != file.OwnerID { + return stacktrace.Propagate(ente.NewPermissionDeniedError("not file owner"), "") + } + return c.FileLinkRepo.DisableLinkForFiles(ctx, []int64{fileID}) +} + +func (c *FileLinkController) GetUrls(ctx *gin.Context, sinceTime int64, limit int64) ([]*ente.FileUrl, error) { + userID := auth.GetUserID(ctx.Request.Header) + app := auth.GetApp(ctx) + fileLinks, err := c.FileLinkRepo.GetFileUrls(ctx, userID, sinceTime, limit, app) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to get file urls") + } + var fileUrls []*ente.FileUrl + for _, row := range fileLinks { + fileUrls = append(fileUrls, c.mapRowToFileUrl(ctx, row)) + } + return fileUrls, nil +} + +func (c *FileLinkController) UpdateSharedUrl(ctx *gin.Context, req ente.UpdateFileUrl) (*ente.FileUrl, error) { + if err := req.Validate(); err != nil { + return nil, stacktrace.Propagate(err, "invalid request") + } + fileLinkRow, err := c.FileLinkRepo.GetActiveFileUrlToken(ctx, req.FileID) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to get file link info") + } + if fileLinkRow.OwnerID != auth.GetUserID(ctx.Request.Header) { + return nil, stacktrace.Propagate(ente.NewPermissionDeniedError("not file owner"), "") + } + if req.ValidTill != nil { + fileLinkRow.ValidTill = *req.ValidTill + } + if req.DeviceLimit != nil { + fileLinkRow.DeviceLimit = *req.DeviceLimit + } + if req.PassHash != nil && req.Nonce != nil && req.OpsLimit != nil && req.MemLimit != nil { + fileLinkRow.PassHash = req.PassHash + fileLinkRow.Nonce = req.Nonce + fileLinkRow.OpsLimit = req.OpsLimit + fileLinkRow.MemLimit = req.MemLimit + } else if req.DisablePassword != nil && *req.DisablePassword { + fileLinkRow.PassHash = nil + fileLinkRow.Nonce = nil + fileLinkRow.OpsLimit = nil + fileLinkRow.MemLimit = nil + } + if req.EnableDownload != nil { + fileLinkRow.EnableDownload = *req.EnableDownload + } + + err = c.FileLinkRepo.UpdateLink(ctx, *fileLinkRow) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + return c.mapRowToFileUrl(ctx, fileLinkRow), nil +} + +func (c *FileLinkController) Info(ctx *gin.Context) (*ente.File, error) { + accessContext := auth.MustGetFileLinkAccessContext(ctx) + return c.FileRepo.GetFileAttributes(accessContext.FileID) +} + +func (c *FileLinkController) PassInfo(ctx *gin.Context) (*ente.FileLinkRow, error) { + accessContext := auth.MustGetFileLinkAccessContext(ctx) + return c.FileLinkRepo.GetFileUrlRowByFileID(ctx, accessContext.FileID) +} + +// VerifyPassword verifies if the user has provided correct pw hash. If yes, it returns a signed jwt token which can be +// used by the client to pass in other requests for public collection. +// Having a separate endpoint for password validation allows us to easily rate-limit the attempts for brute-force +// attack for guessing password. +func (c *FileLinkController) VerifyPassword(ctx *gin.Context, req ente.VerifyPasswordRequest) (*ente.VerifyPasswordResponse, error) { + accessContext := auth.MustGetFileLinkAccessContext(ctx) + collectionLinkRow, err := c.FileLinkRepo.GetActiveFileUrlToken(ctx, accessContext.FileID) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to get public collection info") + } + return verifyPassword(c.JwtSecret, collectionLinkRow.PassHash, req) +} + +func (c *FileLinkController) ValidateJWTToken(ctx *gin.Context, jwtToken string, passwordHash string) error { + return validateJWTToken(c.JwtSecret, jwtToken, passwordHash) +} + +func (c *FileLinkController) mapRowToFileUrl(ctx *gin.Context, row *ente.FileLinkRow) *ente.FileUrl { + app := auth.GetApp(ctx) + var url string + if app == ente.Locker { + url = c.FileLinkRepo.LockerFileLink(row.Token) + } else { + url = c.FileLinkRepo.PhotoLink(row.Token) + } + return &ente.FileUrl{ + LinkID: row.LinkID, + FileID: row.FileID, + URL: url, + OwnerID: row.OwnerID, + ValidTill: row.ValidTill, + DeviceLimit: row.DeviceLimit, + PasswordEnabled: row.PassHash != nil, + Nonce: row.Nonce, + OpsLimit: row.OpsLimit, + MemLimit: row.MemLimit, + EnableDownload: row.EnableDownload, + CreatedAt: row.CreatedAt, + } +} diff --git a/server/pkg/controller/public/link_common.go b/server/pkg/controller/public/link_common.go new file mode 100644 index 0000000000..9a56334a0e --- /dev/null +++ b/server/pkg/controller/public/link_common.go @@ -0,0 +1,54 @@ +package public + +import ( + "errors" + "fmt" + "github.com/ente-io/museum/ente" + enteJWT "github.com/ente-io/museum/ente/jwt" + "github.com/ente-io/museum/pkg/utils/time" + "github.com/ente-io/stacktrace" + "github.com/golang-jwt/jwt" +) + +func validateJWTToken(secret []byte, jwtToken string, passwordHash string) error { + token, err := jwt.ParseWithClaims(jwtToken, &enteJWT.LinkPasswordClaim{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return stacktrace.Propagate(fmt.Errorf("unexpected signing method: %v", token.Header["alg"]), ""), nil + } + return secret, nil + }) + if err != nil { + return stacktrace.Propagate(err, "JWT parsed failed") + } + claims, ok := token.Claims.(*enteJWT.LinkPasswordClaim) + + if !ok { + return stacktrace.Propagate(errors.New("no claim in jwt token"), "") + } + if token.Valid && claims.PassHash == passwordHash { + return nil + } + return ente.ErrInvalidPassword +} + +func verifyPassword(secret []byte, expectedPassHash *string, req ente.VerifyPasswordRequest) (*ente.VerifyPasswordResponse, error) { + if expectedPassHash == nil || *expectedPassHash == "" { + return nil, stacktrace.Propagate(ente.ErrBadRequest, "password is not configured for the link") + } + if req.PassHash != *expectedPassHash { + return nil, stacktrace.Propagate(ente.ErrInvalidPassword, "incorrect password for link") + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &enteJWT.LinkPasswordClaim{ + PassHash: req.PassHash, + ExpiryTime: time.NDaysFromNow(365), + }) + // Sign and get the complete encoded token as a string using the secret + tokenString, err := token.SignedString(secret) + + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + return &ente.VerifyPasswordResponse{ + JWTToken: tokenString, + }, nil +} diff --git a/server/pkg/middleware/access_token.go b/server/pkg/middleware/collection_link.go similarity index 81% rename from server/pkg/middleware/access_token.go rename to server/pkg/middleware/collection_link.go index 702af77db8..5b11a5ec07 100644 --- a/server/pkg/middleware/access_token.go +++ b/server/pkg/middleware/collection_link.go @@ -5,6 +5,8 @@ import ( "context" "crypto/sha256" "fmt" + public2 "github.com/ente-io/museum/pkg/controller/public" + "github.com/ente-io/museum/pkg/repo/public" "net/http" "github.com/ente-io/museum/ente" @@ -24,20 +26,20 @@ import ( var passwordWhiteListedURLs = []string{"/public-collection/info", "/public-collection/report-abuse", "/public-collection/verify-password"} var whitelistedCollectionShareIDs = []int64{111} -// AccessTokenMiddleware intercepts and authenticates incoming requests -type AccessTokenMiddleware struct { - PublicCollectionRepo *repo.PublicCollectionRepository - PublicCollectionCtrl *controller.PublicCollectionController +// CollectionLinkMiddleware intercepts and authenticates incoming requests +type CollectionLinkMiddleware struct { + CollectionLinkRepo *public.CollectionLinkRepo + PublicCollectionCtrl *public2.CollectionLinkController CollectionRepo *repo.CollectionRepository Cache *cache.Cache BillingCtrl *controller.BillingController DiscordController *discord.DiscordController } -// AccessTokenAuthMiddleware returns a middle ware that extracts the `X-Auth-Access-Token` +// Authenticate returns a middle ware that extracts the `X-Auth-Access-Token` // within the header of a request and uses it to validate the access token and set the // ente.PublicAccessContext with auth.PublicAccessKey as key -func (m *AccessTokenMiddleware) AccessTokenAuthMiddleware(urlSanitizer func(_ *gin.Context) string) gin.HandlerFunc { +func (m *CollectionLinkMiddleware) Authenticate(urlSanitizer func(_ *gin.Context) string) gin.HandlerFunc { return func(c *gin.Context) { accessToken := auth.GetAccessToken(c) if accessToken == "" { @@ -52,7 +54,7 @@ func (m *AccessTokenMiddleware) AccessTokenAuthMiddleware(urlSanitizer func(_ *g cacheKey := computeHashKeyForList([]string{accessToken, clientIP, userAgent}, ":") cachedValue, cacheHit := m.Cache.Get(cacheKey) if !cacheHit { - publicCollectionSummary, err = m.PublicCollectionRepo.GetCollectionSummaryByToken(c, accessToken) + publicCollectionSummary, err = m.CollectionLinkRepo.GetCollectionSummaryByToken(c, accessToken) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return @@ -112,7 +114,7 @@ func (m *AccessTokenMiddleware) AccessTokenAuthMiddleware(urlSanitizer func(_ *g c.Next() } } -func (m *AccessTokenMiddleware) validateOwnersSubscription(cID int64) error { +func (m *CollectionLinkMiddleware) validateOwnersSubscription(cID int64) error { userID, err := m.CollectionRepo.GetOwnerID(cID) if err != nil { return stacktrace.Propagate(err, "") @@ -120,7 +122,7 @@ func (m *AccessTokenMiddleware) validateOwnersSubscription(cID int64) error { return m.BillingCtrl.HasActiveSelfOrFamilySubscription(userID, false) } -func (m *AccessTokenMiddleware) isDeviceLimitReached(ctx context.Context, +func (m *CollectionLinkMiddleware) isDeviceLimitReached(ctx context.Context, collectionSummary ente.PublicCollectionSummary, ip string, ua string) (bool, error) { // skip deviceLimit check & record keeping for requests via CF worker if network.IsCFWorkerIP(ip) { @@ -128,7 +130,7 @@ func (m *AccessTokenMiddleware) isDeviceLimitReached(ctx context.Context, } sharedID := collectionSummary.ID - hasAccessedInPast, err := m.PublicCollectionRepo.AccessedInPast(ctx, sharedID, ip, ua) + hasAccessedInPast, err := m.CollectionLinkRepo.AccessedInPast(ctx, sharedID, ip, ua) if err != nil { return false, stacktrace.Propagate(err, "") } @@ -136,17 +138,17 @@ func (m *AccessTokenMiddleware) isDeviceLimitReached(ctx context.Context, if hasAccessedInPast { return false, nil } - count, err := m.PublicCollectionRepo.GetUniqueAccessCount(ctx, sharedID) + count, err := m.CollectionLinkRepo.GetUniqueAccessCount(ctx, sharedID) if err != nil { return false, stacktrace.Propagate(err, "failed to get unique access count") } deviceLimit := int64(collectionSummary.DeviceLimit) - if deviceLimit == controller.DeviceLimitThreshold { - deviceLimit = controller.DeviceLimitThresholdMultiplier * controller.DeviceLimitThreshold + if deviceLimit == public2.DeviceLimitThreshold { + deviceLimit = public2.DeviceLimitThresholdMultiplier * public2.DeviceLimitThreshold } - if count >= controller.DeviceLimitWarningThreshold { + if count >= public2.DeviceLimitWarningThreshold { if !array.Int64InList(sharedID, whitelistedCollectionShareIDs) { m.DiscordController.NotifyPotentialAbuse( fmt.Sprintf("Album exceeds warning threshold: {CollectionID: %d, ShareID: %d}", @@ -157,12 +159,12 @@ func (m *AccessTokenMiddleware) isDeviceLimitReached(ctx context.Context, if deviceLimit > 0 && count >= deviceLimit { return true, nil } - err = m.PublicCollectionRepo.RecordAccessHistory(ctx, sharedID, ip, ua) + err = m.CollectionLinkRepo.RecordAccessHistory(ctx, sharedID, ip, ua) return false, stacktrace.Propagate(err, "failed to record access history") } // validatePassword will verify if the user is provided correct password for the public album -func (m *AccessTokenMiddleware) validatePassword(c *gin.Context, reqPath string, +func (m *CollectionLinkMiddleware) validatePassword(c *gin.Context, reqPath string, collectionSummary ente.PublicCollectionSummary) error { if array.StringInList(reqPath, passwordWhiteListedURLs) { return nil diff --git a/server/pkg/middleware/file_link.go b/server/pkg/middleware/file_link.go new file mode 100644 index 0000000000..72b095bbc0 --- /dev/null +++ b/server/pkg/middleware/file_link.go @@ -0,0 +1,168 @@ +package middleware + +import ( + "context" + "fmt" + publicCtrl "github.com/ente-io/museum/pkg/controller/public" + "github.com/ente-io/museum/pkg/repo/public" + "github.com/ente-io/museum/pkg/utils/array" + "net/http" + + "github.com/ente-io/museum/ente" + "github.com/ente-io/museum/pkg/controller" + "github.com/ente-io/museum/pkg/controller/discord" + "github.com/ente-io/museum/pkg/utils/auth" + "github.com/ente-io/museum/pkg/utils/network" + "github.com/ente-io/museum/pkg/utils/time" + "github.com/ente-io/stacktrace" + "github.com/gin-gonic/gin" + "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" +) + +var filePasswordWhiteListedURLs = []string{"/file-link/pass-info", "/file-link/verify-password"} + +// FileLinkMiddleware intercepts and authenticates incoming requests +type FileLinkMiddleware struct { + FileLinkRepo *public.FileLinkRepository + FileLinkCtrl *publicCtrl.FileLinkController + Cache *cache.Cache + BillingCtrl *controller.BillingController + DiscordController *discord.DiscordController +} + +// Authenticate returns a middle ware that extracts the `X-Auth-Access-Token` +// within the header of a request and uses it to validate the access token and set the +// ente.PublicAccessContext with auth.PublicAccessKey as key +func (m *FileLinkMiddleware) Authenticate(urlSanitizer func(_ *gin.Context) string) gin.HandlerFunc { + return func(c *gin.Context) { + accessToken := auth.GetAccessToken(c) + if accessToken == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing accessToken"}) + return + } + clientIP := network.GetClientIP(c) + userAgent := c.GetHeader("User-Agent") + + cacheKey := computeHashKeyForList([]string{accessToken, clientIP, userAgent}, ":") + cachedValue, cacheHit := m.Cache.Get(cacheKey) + var fileLinkRow *ente.FileLinkRow + var err error + if !cacheHit { + fileLinkRow, err = m.FileLinkRepo.GetFileUrlRowByToken(c, accessToken) + if err != nil { + logrus.WithError(err).Info("failed to get file link row by token") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + if fileLinkRow.IsDisabled { + c.AbortWithStatusJSON(http.StatusGone, gin.H{"error": "disabled token"}) + return + } + // validate if user still has active paid subscription + if err = m.BillingCtrl.HasActiveSelfOrFamilySubscription(fileLinkRow.OwnerID, true); err != nil { + logrus.WithError(err).Info("failed to verify active paid subscription") + c.AbortWithStatusJSON(http.StatusGone, gin.H{"error": "no active subscription"}) + return + } + + // validate device limit + reached, limitErr := m.isDeviceLimitReached(c, fileLinkRow, clientIP, userAgent) + if limitErr != nil { + logrus.WithError(limitErr).Error("failed to check device limit") + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "something went wrong"}) + return + } + if reached { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "reached device limit"}) + return + } + } else { + fileLinkRow = cachedValue.(*ente.FileLinkRow) + } + + if fileLinkRow.ValidTill > 0 && // expiry time is defined, 0 indicates no expiry + fileLinkRow.ValidTill < time.Microseconds() { + c.AbortWithStatusJSON(http.StatusGone, gin.H{"error": "expired token"}) + return + } + + // checks password protected public collection + if fileLinkRow.PassHash != nil && *fileLinkRow.PassHash != "" { + reqPath := urlSanitizer(c) + if err = m.validatePassword(c, reqPath, fileLinkRow); err != nil { + logrus.WithError(err).Warn("password validation failed") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err}) + return + } + } + + if !cacheHit { + m.Cache.Set(cacheKey, fileLinkRow, cache.DefaultExpiration) + } + + c.Set(auth.FileLinkAccessKey, &ente.FileLinkAccessContext{ + LinkID: fileLinkRow.LinkID, + IP: clientIP, + UserAgent: userAgent, + FileID: fileLinkRow.FileID, + OwnerID: fileLinkRow.OwnerID, + }) + c.Next() + } +} + +func (m *FileLinkMiddleware) isDeviceLimitReached(ctx context.Context, + collectionSummary *ente.FileLinkRow, ip string, ua string) (bool, error) { + // skip deviceLimit check & record keeping for requests via CF worker + if network.IsCFWorkerIP(ip) { + return false, nil + } + + sharedID := collectionSummary.LinkID + hasAccessedInPast, err := m.FileLinkRepo.AccessedInPast(ctx, sharedID, ip, ua) + if err != nil { + return false, stacktrace.Propagate(err, "") + } + // if the device has accessed the url in the past, let it access it now as well, irrespective of device limit. + if hasAccessedInPast { + return false, nil + } + count, err := m.FileLinkRepo.GetUniqueAccessCount(ctx, sharedID) + if err != nil { + return false, stacktrace.Propagate(err, "failed to get unique access count") + } + + deviceLimit := int64(collectionSummary.DeviceLimit) + if deviceLimit == publicCtrl.DeviceLimitThreshold { + deviceLimit = publicCtrl.DeviceLimitThresholdMultiplier * publicCtrl.DeviceLimitThreshold + } + + if count >= publicCtrl.DeviceLimitWarningThreshold { + m.DiscordController.NotifyPotentialAbuse( + fmt.Sprintf("FileLink exceeds warning threshold: {FileID: %d, ShareID: %s}", + collectionSummary.FileID, collectionSummary.LinkID)) + } + + if deviceLimit > 0 && count >= deviceLimit { + return true, nil + } + err = m.FileLinkRepo.RecordAccessHistory(ctx, sharedID, ip, ua) + return false, stacktrace.Propagate(err, "failed to record access history") +} + +// validatePassword will verify if the user is provided correct password for the public album +func (m *FileLinkMiddleware) validatePassword( + c *gin.Context, + reqPath string, + fileLinkRow *ente.FileLinkRow, +) error { + accessTokenJWT := auth.GetAccessTokenJWT(c) + if accessTokenJWT == "" { + if array.StringInList(reqPath, filePasswordWhiteListedURLs) { + return nil + } + return &ente.ErrPassProtectedResource + } + return m.FileLinkCtrl.ValidateJWTToken(c, accessTokenJWT, *fileLinkRow.PassHash) +} diff --git a/server/pkg/middleware/rate_limit.go b/server/pkg/middleware/rate_limit.go index 14d3c92a00..bf4c403cfe 100644 --- a/server/pkg/middleware/rate_limit.go +++ b/server/pkg/middleware/rate_limit.go @@ -140,6 +140,7 @@ func (r *RateLimitMiddleware) getLimiter(reqPath string, reqMethod string) *limi reqPath == "/users/verify-email" || reqPath == "/user/change-email" || reqPath == "/public-collection/verify-password" || + reqPath == "/file-link/verify-password" || reqPath == "/family/accept-invite" || reqPath == "/users/srp/attributes" || (reqPath == "/cast/device-info" && reqMethod == "POST") || diff --git a/server/pkg/repo/collection.go b/server/pkg/repo/collection.go index 3f9af70268..c76b40da50 100644 --- a/server/pkg/repo/collection.go +++ b/server/pkg/repo/collection.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "github.com/ente-io/museum/pkg/repo/public" "strconv" t "time" @@ -22,13 +23,13 @@ import ( // CollectionRepository defines the methods for inserting, updating and // retrieving collection entities from the underlying repository type CollectionRepository struct { - DB *sql.DB - FileRepo *FileRepository - PublicCollectionRepo *PublicCollectionRepository - TrashRepo *TrashRepository - SecretEncryptionKey []byte - QueueRepo *QueueRepository - LatencyLogger *prometheus.HistogramVec + DB *sql.DB + FileRepo *FileRepository + CollectionLinkRepo *public.CollectionLinkRepo + TrashRepo *TrashRepository + SecretEncryptionKey []byte + QueueRepo *QueueRepository + LatencyLogger *prometheus.HistogramVec } type SharedCollection struct { @@ -74,7 +75,7 @@ func (repo *CollectionRepository) Get(collectionID int64) (ente.Collection, erro c.EncryptedName = encryptedName.String c.NameDecryptionNonce = nameDecryptionNonce.String } - urlMap, err := repo.PublicCollectionRepo.GetCollectionToActivePublicURLMap(context.Background(), []int64{collectionID}) + urlMap, err := repo.CollectionLinkRepo.GetCollectionToActivePublicURLMap(context.Background(), []int64{collectionID}) if err != nil { return ente.Collection{}, stacktrace.Propagate(err, "failed to get publicURL info") } @@ -174,7 +175,7 @@ pct.access_token, pct.valid_till, pct.device_limit, pct.created_at, pct.updated_ if _, ok := addPublicUrlMap[pctToken.String]; !ok { addPublicUrlMap[pctToken.String] = true url := ente.PublicURL{ - URL: repo.PublicCollectionRepo.GetAlbumUrl(pctToken.String), + URL: repo.CollectionLinkRepo.GetAlbumUrl(pctToken.String), DeviceLimit: int(pctDeviceLimit.Int32), ValidTill: pctValidTill.Int64, EnableDownload: pctEnableDownload.Bool, diff --git a/server/pkg/repo/file.go b/server/pkg/repo/file.go index 2ae4eafdca..50945cf6b1 100644 --- a/server/pkg/repo/file.go +++ b/server/pkg/repo/file.go @@ -638,6 +638,16 @@ func (repo *FileRepository) GetFileAttributesForCopy(fileIDs []int64) ([]ente.Fi return result, nil } +func (repo *FileRepository) GetFileAttributes(fileID int64) (*ente.File, error) { + rows := repo.DB.QueryRow(`SELECT file_id, owner_id, file_decryption_header, thumbnail_decryption_header, metadata_decryption_header, encrypted_metadata, pub_magic_metadata FROM files WHERE file_id = $1`, fileID) + var file ente.File + err := rows.Scan(&file.ID, &file.OwnerID, &file.File.DecryptionHeader, &file.Thumbnail.DecryptionHeader, &file.Metadata.DecryptionHeader, &file.Metadata.EncryptedData, &file.PubicMagicMetadata) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + return &file, nil +} + // GetUsage gets the Storage usage of a user // Deprecated: GetUsage is deprecated, use UsageRepository.GetUsage func (repo *FileRepository) GetUsage(userID int64) (int64, error) { diff --git a/server/pkg/repo/public_collection.go b/server/pkg/repo/public/collection_link.go similarity index 78% rename from server/pkg/repo/public_collection.go rename to server/pkg/repo/public/collection_link.go index f5ae8f2d72..fafcd4cb11 100644 --- a/server/pkg/repo/public_collection.go +++ b/server/pkg/repo/public/collection_link.go @@ -1,4 +1,4 @@ -package repo +package public import ( "context" @@ -13,29 +13,29 @@ import ( const BaseShareURL = "https://albums.ente.io/?t=%s" -// PublicCollectionRepository defines the methods for inserting, updating and +// CollectionLinkRepo defines the methods for inserting, updating and // retrieving entities related to public collections -type PublicCollectionRepository struct { +type CollectionLinkRepo struct { DB *sql.DB albumHost string } -// NewPublicCollectionRepository .. -func NewPublicCollectionRepository(db *sql.DB, albumHost string) *PublicCollectionRepository { +// NewCollectionLinkRepository .. +func NewCollectionLinkRepository(db *sql.DB, albumHost string) *CollectionLinkRepo { if albumHost == "" { albumHost = "https://albums.ente.io" } - return &PublicCollectionRepository{ + return &CollectionLinkRepo{ DB: db, albumHost: albumHost, } } -func (pcr *PublicCollectionRepository) GetAlbumUrl(token string) string { +func (pcr *CollectionLinkRepo) GetAlbumUrl(token string) string { return fmt.Sprintf("%s/?t=%s", pcr.albumHost, token) } -func (pcr *PublicCollectionRepository) Insert(ctx context.Context, +func (pcr *CollectionLinkRepo) Insert(ctx context.Context, cID int64, token string, validTill int64, deviceLimit int, enableCollect bool, enableJoin *bool) error { // default value for enableJoin is true join := true @@ -51,7 +51,7 @@ func (pcr *PublicCollectionRepository) Insert(ctx context.Context, return stacktrace.Propagate(err, "failed to insert") } -func (pcr *PublicCollectionRepository) DisableSharing(ctx context.Context, cID int64) error { +func (pcr *CollectionLinkRepo) DisableSharing(ctx context.Context, cID int64) error { _, err := pcr.DB.ExecContext(ctx, `UPDATE public_collection_tokens SET is_disabled = true where collection_id = $1 and is_disabled = false`, cID) return stacktrace.Propagate(err, "failed to disable sharing") @@ -59,7 +59,7 @@ func (pcr *PublicCollectionRepository) DisableSharing(ctx context.Context, cID i // GetCollectionToActivePublicURLMap will return map of collectionID to PublicURLs which are not disabled yet. // Note: The url could be expired or deviceLimit is already reached -func (pcr *PublicCollectionRepository) GetCollectionToActivePublicURLMap(ctx context.Context, collectionIDs []int64) (map[int64][]ente.PublicURL, error) { +func (pcr *CollectionLinkRepo) GetCollectionToActivePublicURLMap(ctx context.Context, collectionIDs []int64) (map[int64][]ente.PublicURL, error) { rows, err := pcr.DB.QueryContext(ctx, `SELECT collection_id, access_token, valid_till, device_limit, enable_download, enable_collect, enable_join, pw_nonce, mem_limit, ops_limit FROM public_collection_tokens WHERE collection_id = ANY($1) and is_disabled = FALSE`, pq.Array(collectionIDs)) @@ -92,26 +92,26 @@ func (pcr *PublicCollectionRepository) GetCollectionToActivePublicURLMap(ctx con return result, nil } -// GetActivePublicCollectionToken will return ente.PublicCollectionToken for given collection ID +// GetActiveCollectionLinkRow will return ente.CollectionLinkRow for given collection ID // Note: The token could be expired or deviceLimit is already reached -func (pcr *PublicCollectionRepository) GetActivePublicCollectionToken(ctx context.Context, collectionID int64) (ente.PublicCollectionToken, error) { +func (pcr *CollectionLinkRepo) GetActiveCollectionLinkRow(ctx context.Context, collectionID int64) (ente.CollectionLinkRow, error) { row := pcr.DB.QueryRowContext(ctx, `SELECT id, collection_id, access_token, valid_till, device_limit, is_disabled, pw_hash, pw_nonce, mem_limit, ops_limit, enable_download, enable_collect, enable_join FROM public_collection_tokens WHERE collection_id = $1 and is_disabled = FALSE`, collectionID) //defer rows.Close() - ret := ente.PublicCollectionToken{} + ret := ente.CollectionLinkRow{} err := row.Scan(&ret.ID, &ret.CollectionID, &ret.Token, &ret.ValidTill, &ret.DeviceLimit, &ret.IsDisabled, &ret.PassHash, &ret.Nonce, &ret.MemLimit, &ret.OpsLimit, &ret.EnableDownload, &ret.EnableCollect, &ret.EnableJoin) if err != nil { - return ente.PublicCollectionToken{}, stacktrace.Propagate(err, "") + return ente.CollectionLinkRow{}, stacktrace.Propagate(err, "") } return ret, nil } // UpdatePublicCollectionToken will update the row for corresponding public collection token -func (pcr *PublicCollectionRepository) UpdatePublicCollectionToken(ctx context.Context, pct ente.PublicCollectionToken) error { +func (pcr *CollectionLinkRepo) UpdatePublicCollectionToken(ctx context.Context, pct ente.CollectionLinkRow) error { _, err := pcr.DB.ExecContext(ctx, `UPDATE public_collection_tokens SET valid_till = $1, device_limit = $2, pw_hash = $3, pw_nonce = $4, mem_limit = $5, ops_limit = $6, enable_download = $7, enable_collect = $8, enable_join = $9 where id = $10`, @@ -119,7 +119,7 @@ func (pcr *PublicCollectionRepository) UpdatePublicCollectionToken(ctx context.C return stacktrace.Propagate(err, "failed to update public collection token") } -func (pcr *PublicCollectionRepository) RecordAbuseReport(ctx context.Context, accessCtx ente.PublicAccessContext, +func (pcr *CollectionLinkRepo) RecordAbuseReport(ctx context.Context, accessCtx ente.PublicAccessContext, url string, reason string, details ente.AbuseReportDetails) error { _, err := pcr.DB.ExecContext(ctx, `INSERT INTO public_abuse_report (share_id, ip, user_agent, url, reason, details) VALUES ($1, $2, $3, $4, $5, $6) @@ -128,7 +128,7 @@ func (pcr *PublicCollectionRepository) RecordAbuseReport(ctx context.Context, ac return stacktrace.Propagate(err, "failed to record abuse report") } -func (pcr *PublicCollectionRepository) GetAbuseReportCount(ctx context.Context, accessCtx ente.PublicAccessContext) (int64, error) { +func (pcr *CollectionLinkRepo) GetAbuseReportCount(ctx context.Context, accessCtx ente.PublicAccessContext) (int64, error) { row := pcr.DB.QueryRowContext(ctx, `SELECT count(*) FROM public_abuse_report WHERE share_id = $1`, accessCtx.ID) var count int64 = 0 err := row.Scan(&count) @@ -138,7 +138,7 @@ func (pcr *PublicCollectionRepository) GetAbuseReportCount(ctx context.Context, return count, nil } -func (pcr *PublicCollectionRepository) GetUniqueAccessCount(ctx context.Context, shareId int64) (int64, error) { +func (pcr *CollectionLinkRepo) GetUniqueAccessCount(ctx context.Context, shareId int64) (int64, error) { row := pcr.DB.QueryRowContext(ctx, `SELECT count(*) FROM public_collection_access_history WHERE share_id = $1`, shareId) var count int64 = 0 err := row.Scan(&count) @@ -148,7 +148,7 @@ func (pcr *PublicCollectionRepository) GetUniqueAccessCount(ctx context.Context, return count, nil } -func (pcr *PublicCollectionRepository) RecordAccessHistory(ctx context.Context, shareID int64, ip string, ua string) error { +func (pcr *CollectionLinkRepo) RecordAccessHistory(ctx context.Context, shareID int64, ip string, ua string) error { _, err := pcr.DB.ExecContext(ctx, `INSERT INTO public_collection_access_history (share_id, ip, user_agent) VALUES ($1, $2, $3) ON CONFLICT ON CONSTRAINT unique_access_sid_ip_ua DO NOTHING;`, @@ -157,7 +157,7 @@ func (pcr *PublicCollectionRepository) RecordAccessHistory(ctx context.Context, } // AccessedInPast returns true if the given ip, ua agent combination has accessed the url in the past -func (pcr *PublicCollectionRepository) AccessedInPast(ctx context.Context, shareID int64, ip string, ua string) (bool, error) { +func (pcr *CollectionLinkRepo) AccessedInPast(ctx context.Context, shareID int64, ip string, ua string) (bool, error) { row := pcr.DB.QueryRowContext(ctx, `select share_id from public_collection_access_history where share_id =$1 and ip = $2 and user_agent = $3`, shareID, ip, ua) var tempID int64 @@ -168,7 +168,7 @@ func (pcr *PublicCollectionRepository) AccessedInPast(ctx context.Context, share return true, stacktrace.Propagate(err, "failed to record access history") } -func (pcr *PublicCollectionRepository) GetCollectionSummaryByToken(ctx context.Context, accessToken string) (ente.PublicCollectionSummary, error) { +func (pcr *CollectionLinkRepo) GetCollectionSummaryByToken(ctx context.Context, accessToken string) (ente.PublicCollectionSummary, error) { row := pcr.DB.QueryRowContext(ctx, `SELECT sct.id, sct.collection_id, sct.is_disabled, sct.valid_till, sct.device_limit, sct.pw_hash, sct.created_at, sct.updated_at, count(ah.share_id) @@ -185,7 +185,7 @@ func (pcr *PublicCollectionRepository) GetCollectionSummaryByToken(ctx context.C return result, nil } -func (pcr *PublicCollectionRepository) GetActivePublicTokenForUser(ctx context.Context, userID int64) ([]int64, error) { +func (pcr *CollectionLinkRepo) GetActivePublicTokenForUser(ctx context.Context, userID int64) ([]int64, error) { rows, err := pcr.DB.QueryContext(ctx, `select pt.collection_id from public_collection_tokens pt left join collections c on pt.collection_id = c.collection_id where pt.is_disabled = FALSE and c.owner_id= $1;`, userID) if err != nil { return nil, stacktrace.Propagate(err, "") @@ -204,7 +204,7 @@ func (pcr *PublicCollectionRepository) GetActivePublicTokenForUser(ctx context.C } // CleanupAccessHistory public_collection_access_history where public_collection_tokens is disabled and the last updated time is older than 30 days -func (pcr *PublicCollectionRepository) CleanupAccessHistory(ctx context.Context) error { +func (pcr *CollectionLinkRepo) CleanupAccessHistory(ctx context.Context) error { _, err := pcr.DB.ExecContext(ctx, `DELETE FROM public_collection_access_history WHERE share_id IN (SELECT id FROM public_collection_tokens WHERE is_disabled = TRUE AND updated_at < (now_utc_micro_seconds() - (24::BIGINT * 30 * 60 * 60 * 1000 * 1000)))`) if err != nil { return stacktrace.Propagate(err, "failed to clean up public collection access history") diff --git a/server/pkg/repo/public/file_link.go b/server/pkg/repo/public/file_link.go new file mode 100644 index 0000000000..fbb2f4e072 --- /dev/null +++ b/server/pkg/repo/public/file_link.go @@ -0,0 +1,218 @@ +package public + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/ente-io/museum/ente/base" + "github.com/lib/pq" + "github.com/spf13/viper" + + "github.com/ente-io/museum/ente" + "github.com/ente-io/stacktrace" +) + +// FileLinkRepository defines the methods for inserting, updating and +// retrieving entities related to public file +type FileLinkRepository struct { + DB *sql.DB + photoHost string + lockerHost string +} + +// NewFileLinkRepo .. +func NewFileLinkRepo(db *sql.DB) *FileLinkRepository { + albumHost := viper.GetString("apps.public-albums") + if albumHost == "" { + albumHost = "https://albums.ente.io" + } + lockerHost := viper.GetString("apps.public-locker") + if lockerHost == "" { + lockerHost = "https://locker.ente.io" + } + return &FileLinkRepository{ + DB: db, + photoHost: albumHost, + lockerHost: lockerHost, + } +} + +func (pcr *FileLinkRepository) PhotoLink(token string) string { + return fmt.Sprintf("%s/?t=%s", pcr.photoHost, token) +} + +func (pcr *FileLinkRepository) LockerFileLink(token string) string { + return fmt.Sprintf("%s/?t=%s", pcr.lockerHost, token) +} + +func (pcr *FileLinkRepository) Insert( + ctx context.Context, + fileID int64, + ownerID int64, + token string, + app ente.App, +) (*string, error) { + id, err := base.NewID("pft") + if err != nil { + return nil, stacktrace.Propagate(err, "failed to generate new ID for public file token") + } + _, err = pcr.DB.ExecContext(ctx, `INSERT INTO public_file_tokens + (id, file_id, owner_id, access_token, app) VALUES ($1, $2, $3, $4, $5)`, + id, fileID, ownerID, token, string(app)) + if err != nil { + if err.Error() == "pq: duplicate key value violates unique constraint \"public_active_file_link_unique_idx\"" { + return nil, ente.ErrActiveLinkAlreadyExists + } + return nil, stacktrace.Propagate(err, "failed to insert") + } + return id, nil +} + +// GetActiveFileUrlToken will return ente.CollectionLinkRow for given collection ID +// Note: The token could be expired or deviceLimit is already reached +func (pcr *FileLinkRepository) GetActiveFileUrlToken(ctx context.Context, fileID int64) (*ente.FileLinkRow, error) { + row := pcr.DB.QueryRowContext(ctx, `SELECT id, file_id, owner_id, access_token, valid_till, device_limit, + is_disabled, pw_hash, pw_nonce, mem_limit, ops_limit, enable_download FROM + public_file_tokens WHERE file_id = $1 and is_disabled = FALSE`, + fileID) + + ret := ente.FileLinkRow{} + err := row.Scan(&ret.LinkID, &ret.FileID, ret.OwnerID, &ret.Token, &ret.ValidTill, &ret.DeviceLimit, + &ret.IsDisabled, &ret.PassHash, &ret.Nonce, &ret.MemLimit, &ret.OpsLimit, &ret.EnableDownload) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + return &ret, nil +} +func (pcr *FileLinkRepository) GetFileUrls(ctx context.Context, userID int64, sinceTime int64, limit int64, app ente.App) ([]*ente.FileLinkRow, error) { + if limit <= 0 { + limit = 500 + } + query := `SELECT id, file_id, owner_id, is_disabled, valid_till, device_limit, enable_download, pw_hash, pw_nonce, mem_limit, ops_limit, + created_at, updated_at FROM public_file_tokens + WHERE owner_id = $1 AND created_at > $2 AND app = $3 ORDER BY updated_at DESC LIMIT $4` + rows, err := pcr.DB.QueryContext(ctx, query, userID, sinceTime, string(app), limit) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to get public file urls") + } + defer rows.Close() + + var result []*ente.FileLinkRow + for rows.Next() { + var row ente.FileLinkRow + err = rows.Scan(&row.LinkID, &row.FileID, &row.OwnerID, &row.IsDisabled, + &row.ValidTill, &row.DeviceLimit, &row.EnableDownload, + &row.PassHash, &row.Nonce, &row.MemLimit, + &row.OpsLimit, &row.CreatedAt, &row.UpdatedAt) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to scan public file url row") + } + result = append(result, &row) + } + return result, nil +} + +func (pcr *FileLinkRepository) DisableLinkForFiles(ctx context.Context, fileIDs []int64) error { + if len(fileIDs) == 0 { + return nil + } + query := `UPDATE public_file_tokens SET is_disabled = TRUE WHERE file_id = ANY($1)` + _, err := pcr.DB.ExecContext(ctx, query, pq.Array(fileIDs)) + if err != nil { + return stacktrace.Propagate(err, "failed to disable public file links") + } + return nil +} + +// DisableLinksForUser will disable all public file links for the given user +func (pcr *FileLinkRepository) DisableLinksForUser(ctx context.Context, userID int64) error { + _, err := pcr.DB.ExecContext(ctx, `UPDATE public_file_tokens SET is_disabled = TRUE WHERE owner_id = $1`, userID) + if err != nil { + return stacktrace.Propagate(err, "failed to disable public file link") + } + return nil +} + +func (pcr *FileLinkRepository) GetFileUrlRowByToken(ctx context.Context, accessToken string) (*ente.FileLinkRow, error) { + row := pcr.DB.QueryRowContext(ctx, + `SELECT id, file_id, owner_id, is_disabled, valid_till, device_limit, enable_download, pw_hash, pw_nonce, mem_limit, ops_limit + created_at, updated_at + from public_file_tokens + where access_token = $1 +`, accessToken) + var result = ente.FileLinkRow{} + err := row.Scan(&result.LinkID, &result.FileID, &result.OwnerID, &result.IsDisabled, &result.EnableDownload, &result.ValidTill, &result.DeviceLimit, &result.PassHash, &result.Nonce, &result.MemLimit, &result.OpsLimit, &result.CreatedAt, &result.UpdatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, ente.ErrNotFound + } + return nil, stacktrace.Propagate(err, "failed to get public file url summary by token") + } + return &result, nil +} + +func (pcr *FileLinkRepository) GetFileUrlRowByFileID(ctx context.Context, fileID int64) (*ente.FileLinkRow, error) { + row := pcr.DB.QueryRowContext(ctx, + `SELECT id, file_id, access_token, owner_id, is_disabled, enable_download, valid_till, device_limit, pw_hash, pw_nonce, mem_limit, ops_limit, + created_at, updated_at + from public_file_tokens + where file_id = $1 and is_disabled = FALSE`, fileID) + var result = ente.FileLinkRow{} + err := row.Scan(&result.LinkID, &result.FileID, &result.Token, &result.OwnerID, &result.IsDisabled, &result.EnableDownload, &result.ValidTill, &result.DeviceLimit, &result.PassHash, &result.Nonce, &result.MemLimit, &result.OpsLimit, &result.CreatedAt, &result.UpdatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, ente.ErrNotFound + } + return nil, stacktrace.Propagate(err, "failed to get public file url summary by file ID") + } + return &result, nil +} + +// UpdateLink will update the row for corresponding public file token +func (pcr *FileLinkRepository) UpdateLink(ctx context.Context, pct ente.FileLinkRow) error { + _, err := pcr.DB.ExecContext(ctx, `UPDATE public_file_tokens SET valid_till = $1, device_limit = $2, + pw_hash = $3, pw_nonce = $4, mem_limit = $5, ops_limit = $6, enable_download = $7 + where id = $8`, + pct.ValidTill, pct.DeviceLimit, pct.PassHash, pct.Nonce, pct.MemLimit, pct.OpsLimit, pct.EnableDownload, pct.LinkID) + return stacktrace.Propagate(err, "failed to update public file token") +} + +func (pcr *FileLinkRepository) GetUniqueAccessCount(ctx context.Context, linkId string) (int64, error) { + row := pcr.DB.QueryRowContext(ctx, `SELECT count(*) FROM public_file_tokens_access_history WHERE id = $1`, linkId) + var count int64 = 0 + err := row.Scan(&count) + if err != nil { + return -1, stacktrace.Propagate(err, "") + } + return count, nil +} + +func (pcr *FileLinkRepository) RecordAccessHistory(ctx context.Context, shareID string, ip string, ua string) error { + _, err := pcr.DB.ExecContext(ctx, `INSERT INTO public_file_tokens_access_history + (id, ip, user_agent) VALUES ($1, $2, $3) + ON CONFLICT ON CONSTRAINT unique_access_id_ip_ua DO NOTHING;`, + shareID, ip, ua) + return stacktrace.Propagate(err, "failed to record access history") +} + +// AccessedInPast returns true if the given ip, ua agent combination has accessed the url in the past +func (pcr *FileLinkRepository) AccessedInPast(ctx context.Context, shareID string, ip string, ua string) (bool, error) { + row := pcr.DB.QueryRowContext(ctx, `select id from public_file_tokens_access_history where id =$1 and ip = $2 and user_agent = $3`, + shareID, ip, ua) + var tempID int64 + err := row.Scan(&tempID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return true, stacktrace.Propagate(err, "failed to record access history") +} + +// CleanupAccessHistory public_file_tokens_access_history where public_collection_tokens is disabled and the last updated time is older than 30 days +func (pcr *FileLinkRepository) CleanupAccessHistory(ctx context.Context) error { + _, err := pcr.DB.ExecContext(ctx, `DELETE FROM public_file_tokens_access_history WHERE id IN (SELECT id FROM public_file_tokens WHERE is_disabled = TRUE AND updated_at < (now_utc_micro_seconds() - (24::BIGINT * 30 * 60 * 60 * 1000 * 1000)))`) + if err != nil { + return stacktrace.Propagate(err, "failed to clean up public file access history") + } + return nil +} diff --git a/server/pkg/repo/trash.go b/server/pkg/repo/trash.go index f1ab3c2289..3781e0716e 100644 --- a/server/pkg/repo/trash.go +++ b/server/pkg/repo/trash.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "github.com/ente-io/museum/pkg/repo/public" "strings" "github.com/ente-io/museum/ente" @@ -32,10 +33,11 @@ type FileWithUpdatedAt struct { } type TrashRepository struct { - DB *sql.DB - ObjectRepo *ObjectRepository - FileRepo *FileRepository - QueueRepo *QueueRepository + DB *sql.DB + ObjectRepo *ObjectRepository + FileRepo *FileRepository + QueueRepo *QueueRepository + FileLinkRepo *public.FileLinkRepository } func (t *TrashRepository) InsertItems(ctx context.Context, tx *sql.Tx, userID int64, items []ente.TrashItemRequest) error { @@ -156,6 +158,13 @@ func (t *TrashRepository) TrashFiles(fileIDs []int64, userID int64, trash ente.T return stacktrace.Propagate(err, "") } err = tx.Commit() + + if err == nil { + removeLinkErr := t.FileLinkRepo.DisableLinkForFiles(ctx, fileIDs) + if removeLinkErr != nil { + return stacktrace.Propagate(removeLinkErr, "failed to disable file links for files being trashed") + } + } return stacktrace.Propagate(err, "") } diff --git a/server/pkg/utils/auth/auth.go b/server/pkg/utils/auth/auth.go index 6f8091998b..8b52808e36 100644 --- a/server/pkg/utils/auth/auth.go +++ b/server/pkg/utils/auth/auth.go @@ -17,8 +17,9 @@ import ( ) const ( - PublicAccessKey = "X-Public-Access-ID" - CastContext = "X-Cast-Context" + PublicAccessKey = "X-Public-Access-ID" + FileLinkAccessKey = "X-Public-FileLink-Access-ID" + CastContext = "X-Cast-Context" ) // GenerateRandomBytes returns securely generated random bytes. @@ -120,6 +121,8 @@ func GetCastToken(c *gin.Context) string { return token } +// GetAccessTokenJWT fetches the JWT access token from the request header or query parameters. +// This token is issued by server on password verification of links that are protected by password. func GetAccessTokenJWT(c *gin.Context) string { token := c.GetHeader("X-Auth-Access-Token-JWT") if token == "" { @@ -132,6 +135,10 @@ func MustGetPublicAccessContext(c *gin.Context) ente.PublicAccessContext { return c.MustGet(PublicAccessKey).(ente.PublicAccessContext) } +func MustGetFileLinkAccessContext(c *gin.Context) *ente.FileLinkAccessContext { + return c.MustGet(FileLinkAccessKey).(*ente.FileLinkAccessContext) +} + func GetCastCtx(c *gin.Context) cast.AuthContext { return c.MustGet(CastContext).(cast.AuthContext) }