import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:xml/xml.dart' as xml; import '../models/nextcloud_item.dart'; extension XmlElementHelper on xml.XmlNode { Iterable findLocalChildren(String localName) { return children.whereType().where( (e) => e.name.local.toLowerCase() == localName.toLowerCase(), ); } Iterable findLocalDescendants(String localName) { if (this is xml.XmlDocument) { return (this as xml.XmlDocument).descendants .whereType() .where((e) => e.name.local.toLowerCase() == localName.toLowerCase()); } else if (this is xml.XmlElement) { return (this as xml.XmlElement).descendants .whereType() .where((e) => e.name.local.toLowerCase() == localName.toLowerCase()); } return []; } } /// WebDAV date props (`getlastmodified`, `creationdate`) come back as RFC 1123 /// dates (e.g. "Mon, 01 Jan 2024 00:00:00 GMT"), which [DateTime.tryParse] /// can't read since it only understands ISO 8601. Fall back to [HttpDate]. DateTime? _parseDavDate(String? raw) { if (raw == null || raw.isEmpty) return null; final iso = DateTime.tryParse(raw); if (iso != null) return iso; try { return HttpDate.parse(raw); } catch (_) { return null; } } /// Normalizes a WebDAV `href` (which servers may return as either a bare /// path or a full absolute URL) down to just its path, trailing slash /// stripped, so hrefs from either form can be compared directly. String _davPath(String value) { var path = Uri.parse(value.trim()).path; if (path.endsWith('/') && path.length > 1) { path = path.substring(0, path.length - 1); } return path; } class NextcloudService { final String serverUrl; final String username; final String password; NextcloudService({ required this.serverUrl, required this.username, required this.password, }); String get _cleanServerUrl { var url = serverUrl.trim(); if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'https://$url'; } if (url.endsWith('/')) { url = url.substring(0, url.length - 1); } return url; } Map get _headers { final credentials = base64Encode(utf8.encode('$username:$password')); return { 'Authorization': 'Basic $credentials', 'OCS-APIRequest': 'true', 'Accept': 'application/json, application/xml', 'User-Agent': 'Nextcloud-Flutter-Client/1.0', }; } /// Auth headers usable directly by widgets that fetch content themselves /// (e.g. `Image.network(url, headers: service.authHeaders)`). Map get authHeaders => _headers; /// The direct WebDAV download URL for a file at [itemPath]. String fileUrl(String itemPath) { var cleanPath = itemPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; return '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; } /// Downloads the file at [itemPath] to [savePath], reporting progress. Future downloadToFile( String itemPath, String savePath, { void Function(int received, int total)? onProgress, }) async { final dio = Dio(); await dio.download( fileUrl(itemPath), savePath, options: Options(headers: _headers), onReceiveProgress: onProgress, ); } /// Fetches the raw bytes of a file (used for in-app text/PDF previews). Future> fetchBytes(String itemPath) async { final response = await http.get( Uri.parse(fileUrl(itemPath)), headers: _headers, ); if (response.statusCode != 200) { throw Exception('Failed to download file. HTTP ${response.statusCode}'); } return response.bodyBytes; } Future testConnection() async { try { final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/'; debugPrint('[Nextcloud Test] Connecting to WebDAV endpoint: $davPath'); final request = http.Request('PROPFIND', Uri.parse(davPath)) ..headers.addAll({ ..._headers, 'Depth': '0', 'Content-Type': 'application/xml', }) ..body = ''' '''; final streamed = await http.Client().send(request); debugPrint( '[Nextcloud Test] Connection response HTTP status: ${streamed.statusCode}', ); if (streamed.statusCode == 207 || streamed.statusCode == 200) { return true; } // Try fallback endpoint /remote.php/webdav/ final legacyPath = '$_cleanServerUrl/remote.php/webdav/'; debugPrint( '[Nextcloud Test] Testing legacy WebDAV endpoint: $legacyPath', ); final req2 = http.Request('PROPFIND', Uri.parse(legacyPath)) ..headers.addAll({ ..._headers, 'Depth': '0', 'Content-Type': 'application/xml', }); final st2 = await http.Client().send(req2); debugPrint( '[Nextcloud Test] Legacy WebDAV response HTTP status: ${st2.statusCode}', ); if (st2.statusCode == 207 || st2.statusCode == 200) { return true; } if (streamed.statusCode == 401 || st2.statusCode == 401) { throw Exception('Invalid username or password (401 Unauthorized)'); } else if (streamed.statusCode == 404 && st2.statusCode == 404) { throw Exception('Nextcloud WebDAV endpoint not found at $davPath'); } else { throw Exception('Server returned status code ${streamed.statusCode}'); } } catch (e) { debugPrint('[Nextcloud Test] Error: $e'); final str = e.toString(); if (str.contains('XMLHttpRequest') || str.contains('ClientException') || str.contains('Failed to fetch')) { throw Exception( 'Browser CORS Policy Blocked: The web browser blocked the connection preflight request to $_cleanServerUrl. ' 'To bypass browser CORS, run the application as a native Windows app (flutter run -d windows) ' 'or configure CORS headers on your Nextcloud server.', ); } if (e is Exception) rethrow; throw Exception('Could not connect to Nextcloud host: $e'); } } Future> fetchDirectory(String folderPath) async { var cleanPath = folderPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; debugPrint('[Nextcloud DAV] Requesting PROPFIND for $url'); final request = http.Request('PROPFIND', Uri.parse(url)) ..headers.addAll({ ..._headers, 'Depth': '1', 'Content-Type': 'application/xml', }); const body = ''' '''; request.body = body; var streamedResponse = await http.Client().send(request); var responseBody = await streamedResponse.stream.bytesToString(); debugPrint( '[Nextcloud DAV] PROPFIND primary URL status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}', ); if (streamedResponse.statusCode != 207 && streamedResponse.statusCode != 200) { final fallbackUrl = '$_cleanServerUrl/remote.php/webdav$cleanPath'; debugPrint( '[Nextcloud DAV] Primary URL failed (${streamedResponse.statusCode}). Trying fallback: $fallbackUrl', ); final req2 = http.Request('PROPFIND', Uri.parse(fallbackUrl)) ..headers.addAll({ ..._headers, 'Depth': '1', 'Content-Type': 'application/xml', }) ..body = body; final res2 = await http.Client().send(req2); final body2 = await res2.stream.bytesToString(); debugPrint( '[Nextcloud DAV] Fallback URL response status: ${res2.statusCode}, bytes: ${body2.length}', ); if (res2.statusCode == 207 || res2.statusCode == 200) { streamedResponse = res2; responseBody = body2; } else { throw Exception( 'Failed to load directory $cleanPath. HTTP ${streamedResponse.statusCode}', ); } } final document = xml.XmlDocument.parse(responseBody); final responses = document.findLocalDescendants('response'); debugPrint( '[Nextcloud DAV] Found ${responses.length} response nodes in XML', ); List items = []; for (var res in responses) { final hrefNode = res.findLocalChildren('href').firstOrNull ?? res.findLocalDescendants('href').firstOrNull; final href = hrefNode?.innerText ?? ''; // Servers may return `href` as either a server-relative path or a full // absolute URL; compare on path only so both forms line up. final hrefPath = _davPath(Uri.decodeFull(href)); final targetDavPath = _davPath( '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath', ); final targetLegacyPath = _davPath( '$_cleanServerUrl/remote.php/webdav$cleanPath', ); // Skip current root directory entry itself if (hrefPath == targetDavPath || hrefPath == targetLegacyPath || hrefPath.endsWith('/remote.php/dav/files/$username') || hrefPath.endsWith('/remote.php/webdav')) { continue; } final props = res.findLocalDescendants('prop'); if (props.isEmpty) continue; bool isCollection = false; String? sizeStr; String? lastModStr; String? createdStr; String? mimeType; bool isFav = false; String? fileId; String? mountType; for (var prop in props) { if (!isCollection) { final resTypeNode = prop .findLocalChildren('resourcetype') .firstOrNull; if (resTypeNode != null && resTypeNode.findLocalChildren('collection').isNotEmpty) { isCollection = true; } } sizeStr ??= prop.findLocalChildren('size').firstOrNull?.innerText ?? prop.findLocalChildren('getcontentlength').firstOrNull?.innerText; lastModStr ??= prop .findLocalChildren('getlastmodified') .firstOrNull ?.innerText; createdStr ??= prop .findLocalChildren('creationdate') .firstOrNull ?.innerText; mimeType ??= prop .findLocalChildren('getcontenttype') .firstOrNull ?.innerText; if (!isFav) { isFav = prop.findLocalChildren('favorite').firstOrNull?.innerText == '1'; } fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText; mountType ??= prop .findLocalChildren('mount-type') .firstOrNull ?.innerText; } final name = Uri.decodeFull( href.split('/').where((s) => s.isNotEmpty).last, ); if (name.isEmpty) continue; final size = int.tryParse(sizeStr ?? '0') ?? 0; final lastMod = _parseDavDate(lastModStr) ?? DateTime.now(); final created = _parseDavDate(createdStr); final itemType = NextcloudItem.deduceType(name, isCollection, mimeType); final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name; items.add( NextcloudItem( id: validId, name: name, path: '$cleanPath$name', type: itemType, size: size, lastModified: lastMod, dateCreated: created, isFavorite: isFav, mimeType: mimeType, previewUrl: '$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500', mountType: mountType, ), ); } debugPrint( '[Nextcloud DAV] Successfully parsed ${items.length} items from directory $cleanPath', ); return items; } /// Searches the whole file tree by name using the WebDAV SEARCH-REPORT /// extension (supported by Nextcloud/ownCloud), rather than just the /// currently open folder. Future> searchFiles(String query) async { final term = query.trim(); if (term.isEmpty) return []; final url = '$_cleanServerUrl/remote.php/dav/'; debugPrint('[Nextcloud DAV] SEARCH "$term" from $url'); final escaped = term .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>'); final body = ''' /files/$username infinity %$escaped% '''; final request = http.Request('SEARCH', Uri.parse(url)) ..headers.addAll({..._headers, 'Content-Type': 'text/xml'}) ..body = body; final streamed = await http.Client().send(request); final responseBody = await streamed.stream.bytesToString(); debugPrint( '[Nextcloud DAV] SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}', ); if (streamed.statusCode != 207 && streamed.statusCode != 200) { throw Exception('Search failed. HTTP ${streamed.statusCode}'); } final document = xml.XmlDocument.parse(responseBody); final responses = document.findLocalDescendants('response'); final results = []; for (var res in responses) { final hrefNode = res.findLocalChildren('href').firstOrNull ?? res.findLocalDescendants('href').firstOrNull; final href = hrefNode?.innerText ?? ''; if (href.isEmpty) continue; final props = res.findLocalDescendants('prop'); if (props.isEmpty) continue; bool isCollection = false; String? sizeStr; String? lastModStr; String? mimeType; bool isFav = false; String? fileId; for (var prop in props) { if (!isCollection) { final resTypeNode = prop .findLocalChildren('resourcetype') .firstOrNull; if (resTypeNode != null && resTypeNode.findLocalChildren('collection').isNotEmpty) { isCollection = true; } } sizeStr ??= prop .findLocalChildren('getcontentlength') .firstOrNull ?.innerText; lastModStr ??= prop .findLocalChildren('getlastmodified') .firstOrNull ?.innerText; mimeType ??= prop .findLocalChildren('getcontenttype') .firstOrNull ?.innerText; if (!isFav) { isFav = prop.findLocalChildren('favorite').firstOrNull?.innerText == '1'; } fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText; } var decodedHref = Uri.decodeFull(href); if (decodedHref.endsWith('/') && decodedHref.length > 1) { decodedHref = decodedHref.substring(0, decodedHref.length - 1); } final name = decodedHref.split('/').where((s) => s.isNotEmpty).last; if (name.isEmpty) continue; // Strip the DAV root prefix so `path` matches what fetchDirectory produces. final marker = '/files/$username'; final markerIndex = decodedHref.indexOf(marker); final itemPath = markerIndex >= 0 ? decodedHref.substring(markerIndex + marker.length) : decodedHref; if (itemPath.isEmpty) continue; final size = int.tryParse(sizeStr ?? '0') ?? 0; final lastMod = _parseDavDate(lastModStr) ?? DateTime.now(); final itemType = NextcloudItem.deduceType(name, isCollection, mimeType); final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name; results.add( NextcloudItem( id: validId, name: name, path: itemPath, type: itemType, size: size, lastModified: lastMod, isFavorite: isFav, mimeType: mimeType, previewUrl: '$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500', ), ); } debugPrint( '[Nextcloud DAV] Search returned ${results.length} results for "$term"', ); return results; } Future fetchUserQuota() async { final url = '$_cleanServerUrl/ocs/v1.php/cloud/user?format=json'; debugPrint('[Nextcloud OCS] Fetching user quota from $url'); final response = await http.get(Uri.parse(url), headers: _headers); debugPrint( '[Nextcloud OCS] User quota response status: ${response.statusCode}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final ocsData = data['ocs']?['data'] ?? {}; final quota = ocsData['quota'] ?? {}; final used = (quota['used'] as num?)?.toInt() ?? 0; final totalRaw = (quota['total'] as num?)?.toInt() ?? 0; final total = totalRaw < 0 ? -1 : totalRaw; final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0; final display = ocsData['displayname'] ?? username; final email = ocsData['email'] ?? '$username@$serverUrl'; return NextcloudUserQuota( usedBytes: used, totalBytes: total, usagePercentage: pct, userName: display, email: email, serverVersion: 'Nextcloud Server', ); } else { throw Exception( 'Failed to fetch user quota. HTTP ${response.statusCode}', ); } } Future> fetchActivities() async { try { final url = '$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity?format=json'; debugPrint('[Nextcloud OCS] Fetching activity feed from $url'); final response = await http.get(Uri.parse(url), headers: _headers); debugPrint( '[Nextcloud OCS] Activity response status: ${response.statusCode}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final rawData = data['ocs']?['data']; List list = []; if (rawData is List) { list = rawData; } else if (rawData is Map && rawData['activity'] is List) { list = rawData['activity']; } return list.map((a) { return NextcloudActivity( id: (a['activity_id'] ?? '').toString(), title: a['subject'] ?? 'Server Activity', subject: a['message'] ?? (a['subject'] ?? ''), timestamp: DateTime.fromMillisecondsSinceEpoch( (a['timestamp'] as int? ?? 0) * 1000, ), icon: Icons.cloud_outlined, author: a['user'] ?? username, ); }).toList(); } } catch (e) { debugPrint('[Nextcloud OCS Activity] Error: $e'); } return []; } /// Uploads a file from disk, streaming it so large files don't need to be /// buffered fully in memory, with progress reporting. Future uploadFileFromPath( String folderPath, String fileName, String localFilePath, { void Function(int sent, int total)? onProgress, }) async { var cleanPath = folderPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName'; debugPrint('[Nextcloud DAV] Streaming upload to $url'); final file = File(localFilePath); final length = await file.length(); final dio = Dio(); final response = await dio.put( url, data: file.openRead(), options: Options( headers: {..._headers, Headers.contentLengthHeader: length}, contentType: 'application/octet-stream', ), onSendProgress: onProgress, ); debugPrint( '[Nextcloud DAV] Streaming upload status: ${response.statusCode}', ); return response.statusCode == 201 || response.statusCode == 204 || response.statusCode == 200; } Future deleteItem(String itemPath) async { var cleanPath = itemPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; debugPrint('[Nextcloud DAV] Deleting item at $url'); final response = await http.delete(Uri.parse(url), headers: _headers); debugPrint('[Nextcloud DAV] Delete status: ${response.statusCode}'); return response.statusCode == 204 || response.statusCode == 200; } Future createFolder(String parentPath, String folderName) async { var cleanPath = parentPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$folderName'; debugPrint('[Nextcloud DAV] Creating folder at $url'); final request = http.Request('MKCOL', Uri.parse(url)) ..headers.addAll(_headers); final response = await http.Client().send(request); debugPrint('[Nextcloud DAV] Create folder status: ${response.statusCode}'); return response.statusCode == 201; } Future toggleFavorite( String itemPath, bool currentFavoriteState, ) async { var cleanPath = itemPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; final newFavVal = currentFavoriteState ? '0' : '1'; debugPrint('[Nextcloud DAV] Toggling favorite ($newFavVal) for $url'); final body = ''' $newFavVal '''; final request = http.Request('PROPPATCH', Uri.parse(url)) ..headers.addAll({..._headers, 'Content-Type': 'application/xml'}) ..body = body; final response = await http.Client().send(request); debugPrint( '[Nextcloud DAV] Toggle favorite status: ${response.statusCode}', ); return response.statusCode == 207 || response.statusCode == 200; } }