// Helper functions working with remote Nextcloud server import 'dart:convert'; import 'package:clock/clock.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/exception.dart'; import 'package:np_api/np_api.dart' hide NcAlbumItem; import 'package:to_string/to_string.dart'; part 'api_util.g.dart'; /// Characters that are not allowed in filename const reservedFilenameChars = "<>:\"/\\|?*"; /// Return the preview image URL for [file]. See [getFilePreviewUrlRelative] String getFilePreviewUrl( Account account, FileDescriptor file, { required int width, required int height, String? mode, required bool isKeepAspectRatio, }) { return "${account.url}/" "${getFilePreviewUrlRelative(account, file, width: width, height: height, mode: mode, isKeepAspectRatio: isKeepAspectRatio)}"; } /// Return the relative preview image URL for [file] /// /// If [isKeepAspectRatio] == true, the preview will maintain the original /// aspect ratio, otherwise it will be cropped String getFilePreviewUrlRelative( Account account, FileDescriptor file, { required int width, required int height, String? mode, required bool isKeepAspectRatio, }) { String url; if (file_util.isNcAlbumFile(account, file)) { // We can't use the generic file preview url because collaborative albums do // not create a file share for photos not belonging to you, that means you // can only access the file view the Photos API url = "index.php/apps/photos/api/v1/preview/${file.fdId}?x=$width&y=$height"; } else { if (file_util.isTrash(account, file)) { // trashbin does not support preview.png endpoint url = "index.php/apps/files_trashbin/preview?fileId=${file.fdId}"; } else { url = "index.php/core/preview?fileId=${file.fdId}"; } url = "$url&x=$width&y=$height"; if (mode != null) { url = "$url&mode=$mode"; } // keep this here to use cache from older version url = "$url&a=${isKeepAspectRatio ? 1 : 0}"; } return url; } /// Return the preview image URL for [fileId]. See [getFilePreviewUrlRelative] String getFilePreviewUrlByFileId( Account account, int fileId, { required int width, required int height, String? mode, required bool isKeepAspectRatio, }) { String url = "${account.url}/index.php/core/preview?fileId=$fileId"; url = "$url&x=$width&y=$height"; if (mode != null) { url = "$url&mode=$mode"; } url = "$url&a=${isKeepAspectRatio ? 1 : 0}"; return url; } /// Return the preview image URL for [fileId], using the new Photos API in /// Nextcloud 25 String getPhotosApiFilePreviewUrlByFileId( Account account, int fileId, { required int width, required int height, }) => "${account.url}/index.php/apps/photos/api/v1/preview/$fileId?x=$width&y=$height"; String getFileUrl(Account account, FileDescriptor file) { return "${account.url}/${getFileUrlRelative(file)}"; } String getFileUrlRelative(FileDescriptor file) { return file.fdPath; } String getWebdavRootUrlRelative(Account account) => "remote.php/dav/files/${account.userId}"; String getTrashbinPath(Account account) => "remote.php/dav/trashbin/${account.userId}/trash"; /// Return the face image URL. See [getFacePreviewUrlRelative] String getFacePreviewUrl( Account account, int faceId, { required int size, }) { return "${account.url}/" "${getFacePreviewUrlRelative(account, faceId, size: size)}"; } /// Return the relative URL of the face image String getFacePreviewUrlRelative( Account account, int faceId, { required int size, }) { return "index.php/apps/facerecognition/face/$faceId/thumb/$size"; } String getAccountAvatarUrl(Account account, int size) => "${account.url}/${getAccountAvatarUrlRelative(account, size)}"; String getAccountAvatarUrlRelative(Account account, int size) => "avatar/${account.userId}/$size"; /// Initiate a login with Nextcloud login flow v2: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 Future initiateLogin(Uri uri) async { final response = await Api.fromBaseUrl(uri).request( "POST", "index.php/login/v2", header: { "User-Agent": "nc-photos", }, ); if (response.isGood) { return InitiateLoginResponse.fromJsonString(response.body); } else { _log.severe( "[initiateLogin] Failed while requesting app password: $response"); throw ApiException( response: response, message: "Server responded with an error: HTTP ${response.statusCode}"); } } /// Retrieve App Password after successful initiation of login with Nextcloud login flow v2: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 Future _getAppPassword( InitiateLoginPollOptions options) async { Uri baseUrl; if (options.endpoint.scheme == "http") { baseUrl = Uri.http(options.endpoint.authority); } else { baseUrl = Uri.https(options.endpoint.authority); } final response = await Api.fromBaseUrl(baseUrl).request( "POST", options.endpoint.pathSegments.join("/"), header: { "User-Agent": "nc-photos", "Content-Type": "application/x-www-form-urlencoded" }, body: "token=${options.token}"); if (response.statusCode == 200) { return AppPasswordSuccess.fromJsonString(response.body); } else if (response.statusCode == 404) { return AppPasswordPending(); } else { _log.severe( "[getAppPassword] Failed while requesting app password: $response"); throw ApiException( response: response, message: "Server responded with an error: HTTP ${response.statusCode}"); } } /// Polls the app password endpoint every 5 seconds as lang as the token is valid (currently fixed to 20 min) /// See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 Stream> pollAppPassword( InitiateLoginPollOptions options) { return Stream.periodic( const Duration(seconds: 5), (_) => _getAppPassword(options)) .takeWhile((_) => options.isTokenValid()); } @toString class InitiateLoginResponse { const InitiateLoginResponse({ required this.poll, required this.login, }); factory InitiateLoginResponse.fromJsonString(String jsonString) { final json = jsonDecode(jsonString); return InitiateLoginResponse( poll: InitiateLoginPollOptions( json['poll']['token'], json['poll']['endpoint']), login: json['login'], ); } @override String toString() => _$toString(); final InitiateLoginPollOptions poll; final String login; } @toString class InitiateLoginPollOptions { InitiateLoginPollOptions(this.token, String endpoint) : endpoint = Uri.parse(endpoint), _validUntil = clock.now().add(const Duration(minutes: 20)); @override String toString() => _$toString(); bool isTokenValid() { return clock.now().isBefore(_validUntil); } @Format(r"${kDebugMode ? $? : '***'}") final String token; final Uri endpoint; final DateTime _validUntil; } abstract class AppPasswordResponse {} @toString class AppPasswordSuccess implements AppPasswordResponse { const AppPasswordSuccess({ required this.server, required this.loginName, required this.appPassword, }); factory AppPasswordSuccess.fromJsonString(String jsonString) { final json = jsonDecode(jsonString); return AppPasswordSuccess( server: Uri.parse(json['server']), loginName: json['loginName'], appPassword: json['appPassword'], ); } @override String toString() => _$toString(); final Uri server; final String loginName; @Format(r"${kDebugMode ? appPassword : '***'}") final String appPassword; } class AppPasswordPending implements AppPasswordResponse {} final _log = Logger("api.api_util");