132 lines
4.4 KiB
Dart
132 lines
4.4 KiB
Dart
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<http.Response> _send(
|
|
Future<http.Response> Function(Uri uri, Map<String, String> 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<void> 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<void> 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<CloudUserInfo> 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<String, dynamic>;
|
|
final data =
|
|
(root['ocs'] as Map<String, dynamic>)['data']
|
|
as Map<String, dynamic>;
|
|
return CloudUserInfo(
|
|
userId: data['id'] as String,
|
|
displayName: (data['displayname'] as String?) ?? '',
|
|
);
|
|
} catch (e) {
|
|
throw ParseException(
|
|
technicalDetails: 'Cloud $uri user info parse: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|