import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; import '../../errors/auth_exception.dart'; import '../../errors/network_exception.dart'; import '../../errors/not_found_exception.dart'; import '../../errors/parse_exception.dart'; import '../../errors/server_exception.dart'; import '../nextcloud_ocs.dart'; /// Mix of two Nextcloud surfaces: /// - User info comes from the OCS provisioning API /// (`/ocs/v2.php/cloud/users/{userId}`). /// - The own-avatar upload/delete uses the *core* AvatarController at /// `/avatar/` — the OCS provisioning route has no POST (it answers 405). /// This is the same controller the read path (`/avatar/{id}/{size}` in /// [UserAvatar]) already talks to. CSRF is bypassed because we use Basic /// Auth without a session cookie. /// Core AvatarController endpoint for the logged-in user (POST sets, DELETE /// removes). Built against the bare Nextcloud base (domain + optional path), /// not the OCS wrapper. Uri _coreAvatarUri() { final endpoint = EndpointData().nextcloud(); return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); } Uri _userInfoUri() => NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); Future _send( Future Function(Uri uri, Map headers) perform, Uri uri, ) async { final headers = NextcloudOcs.headers(); final http.Response response; try { response = await perform(uri, headers); } on SocketException catch (e) { throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}'); } on TimeoutException catch (e) { throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e'); } on http.ClientException catch (e) { throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}'); } final status = response.statusCode; if (status >= 200 && status < 300) return response; final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim(); final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body; final detail = body.isEmpty ? 'Cloud $uri -> HTTP $status' : 'Cloud $uri -> HTTP $status body=$preview'; log(detail); if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); if (status == 403) throw AuthException.forbidden(technicalDetails: detail); if (status == 404) throw NotFoundException(technicalDetails: detail); throw ServerException(statusCode: status, technicalDetails: detail); } class SetUserAvatar { final Uint8List bytes; final String filename; SetUserAvatar(this.bytes, {this.filename = 'avatar.jpg'}); Future run() async { await _send((uri, headers) async { // Core AvatarController reads $_FILES['files']['error'][0] — the field // must be `files[]` so PHP exposes it as an array, matching the web UI. final req = http.MultipartRequest('POST', uri) ..headers.addAll(headers) ..files.add( http.MultipartFile.fromBytes('files[]', bytes, filename: filename), ); final streamed = await req.send(); return http.Response.fromStream(streamed); }, _coreAvatarUri()); } } class DeleteUserAvatar { Future run() async { await _send( (uri, headers) => http.delete(uri, headers: headers), _coreAvatarUri(), ); } } class CloudUserInfo { final String userId; final String displayName; const CloudUserInfo({required this.userId, required this.displayName}); } /// Reads the current user's provisioning record. The OCS wrapper looks like: /// `{ "ocs": { "meta": {...}, "data": { "id": "...", "displayname": "...", ... } } }`. /// We only need displayname; everything else is discarded. class GetUserInfo { Future run() async { final uri = _userInfoUri(); final response = await _send( (u, headers) => http.get(u, headers: headers), uri, ); try { final root = jsonDecode(response.body) as Map; final data = (root['ocs'] as Map)['data'] as Map; return CloudUserInfo( userId: data['id'] as String, displayName: (data['displayname'] as String?) ?? '', ); } catch (e) { throw ParseException( technicalDetails: 'Cloud $uri user info parse: $e', ); } } }