implemented a comprehensive Nextcloud file sharing system with support for user, group, and public link shares with gating based on server-side permissions; added sharing management interfaces including a share sheet; updated the file list with visual badges for incoming shares and improved OCS API response handling.
This commit is contained in:
@@ -3,25 +3,39 @@ import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'autocomplete_response.dart';
|
||||
|
||||
class AutocompleteApi {
|
||||
Future<AutocompleteResponse> find(String query) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'core/autocomplete/get',
|
||||
queryParameters: {
|
||||
/// Searches sharees (users by default). Pass [shareTypes] to widen the search
|
||||
/// — e.g. `[0, 1]` for both users and groups (0 = user, 1 = group).
|
||||
Future<AutocompleteResponse> find(
|
||||
String query, {
|
||||
List<int> shareTypes = const [0],
|
||||
}) async {
|
||||
// NextcloudOcs.uri serialises every query value via `toString()`, which
|
||||
// would turn the `shareTypes[]` list into `"[0, 1]"`. Build the Uri here so
|
||||
// Dart encodes the list as repeated `shareTypes[]=0&shareTypes[]=1` params.
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
final uri = Uri.https(
|
||||
endpoint.domain,
|
||||
'${endpoint.path}/ocs/v2.php/core/autocomplete/get',
|
||||
{
|
||||
'format': 'json',
|
||||
'search': query,
|
||||
'itemType': ' ',
|
||||
'itemId': ' ',
|
||||
'shareTypes[]': ['0'],
|
||||
'shareTypes[]': shareTypes.map((t) => t.toString()).toList(),
|
||||
'limit': '10',
|
||||
},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
final response = await http.get(uri, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../files_sharing/queries/share/share.dart';
|
||||
|
||||
part 'autocomplete_response.g.dart';
|
||||
|
||||
/// Maps an autocomplete result's `source` to the matching Nextcloud share type.
|
||||
/// Groups become [kShareTypeGroup]; everything else (users, and any unknown
|
||||
/// source) defaults to [kShareTypeUser].
|
||||
int shareTypeFromSource(String? source) {
|
||||
if (source != null && source.startsWith('groups')) return kShareTypeGroup;
|
||||
return kShareTypeUser;
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class AutocompleteResponse {
|
||||
List<AutocompleteResponseObject> data;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'nextcloud_sharing_capabilities.dart';
|
||||
|
||||
/// Fetches the current user's Nextcloud capabilities via OCS
|
||||
/// `GET cloud/capabilities` and extracts the `files_sharing` block. This is the
|
||||
/// per-user, group-aware source of truth the sharing UI gates on — no custom
|
||||
/// backend involved.
|
||||
class GetNextcloudCapabilities {
|
||||
Future<NextcloudSharingCapabilities> run() async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'cloud/capabilities',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'cloud/capabilities: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data =
|
||||
(decoded['ocs'] as Map<String, dynamic>?)?['data']
|
||||
as Map<String, dynamic>?;
|
||||
final capabilities = data?['capabilities'] as Map<String, dynamic>?;
|
||||
final filesSharing = capabilities?['files_sharing'];
|
||||
if (filesSharing is! Map<String, dynamic>) {
|
||||
// Server doesn't advertise files_sharing (app disabled) — treat as no
|
||||
// sharing capability rather than failing the whole load.
|
||||
return const NextcloudSharingCapabilities();
|
||||
}
|
||||
final passwordPolicy = capabilities?['password_policy'];
|
||||
return NextcloudSharingCapabilities.fromFilesSharing(
|
||||
filesSharing,
|
||||
passwordPolicy: passwordPolicy is Map<String, dynamic>
|
||||
? passwordPolicy
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/// Subset of Nextcloud's `files_sharing` capabilities block that the mobile
|
||||
/// sharing UI gates on. Nextcloud reports these per authenticated user, so a
|
||||
/// group that an admin excluded from creating public links sees
|
||||
/// `public.enabled == false` here — exactly how the web UI hides those buttons.
|
||||
///
|
||||
/// The block is deeply nested and varies between server versions, so this is
|
||||
/// parsed by hand from the raw OCS map with safe fallbacks rather than via
|
||||
/// code generation. Missing fields default to the most restrictive value so a
|
||||
/// newer/older server never accidentally unlocks a capability.
|
||||
class NextcloudSharingCapabilities {
|
||||
/// `files_sharing.api_enabled` — master switch. When false the user may not
|
||||
/// create any share (user, group or link).
|
||||
final bool apiEnabled;
|
||||
|
||||
/// `files_sharing.public.enabled` — public link shares allowed.
|
||||
final bool publicEnabled;
|
||||
|
||||
/// `files_sharing.public.multiple_links` — more than one link per file.
|
||||
final bool publicMultipleLinks;
|
||||
|
||||
/// `files_sharing.public.upload` — public upload / file-drop folders.
|
||||
final bool publicUploadEnabled;
|
||||
|
||||
/// `files_sharing.public.password.enforced` — a password is mandatory on
|
||||
/// public links, so the create flow must collect one upfront.
|
||||
final bool publicPasswordEnforced;
|
||||
|
||||
/// `files_sharing.public.expire_date.enabled`.
|
||||
final bool publicExpireEnabled;
|
||||
|
||||
/// `files_sharing.public.expire_date.days` — default/maximum lifetime.
|
||||
final int? publicExpireDays;
|
||||
|
||||
/// `files_sharing.public.expire_date.enforced` — expiry cannot be removed.
|
||||
final bool publicExpireEnforced;
|
||||
|
||||
/// `files_sharing.group.enabled` (falls back to the older `group_sharing`).
|
||||
final bool groupEnabled;
|
||||
|
||||
/// `files_sharing.resharing` — recipients may reshare.
|
||||
final bool resharing;
|
||||
|
||||
// --- password_policy (a sibling capability of files_sharing) ---
|
||||
// These let the link-password UI state the rules up front instead of only
|
||||
// surfacing them after the server rejects a weak password. The
|
||||
// "non-common password" (breach) check can only be enforced server-side.
|
||||
|
||||
/// `password_policy.minLength`.
|
||||
final int? passwordMinLength;
|
||||
|
||||
/// `password_policy.enforceUpperLowerCase`.
|
||||
final bool passwordEnforceUpperLower;
|
||||
|
||||
/// `password_policy.enforceNumericCharacters`.
|
||||
final bool passwordEnforceNumeric;
|
||||
|
||||
/// `password_policy.enforceSpecialCharacters`.
|
||||
final bool passwordEnforceSpecial;
|
||||
|
||||
const NextcloudSharingCapabilities({
|
||||
this.apiEnabled = false,
|
||||
this.publicEnabled = false,
|
||||
this.publicMultipleLinks = false,
|
||||
this.publicUploadEnabled = false,
|
||||
this.publicPasswordEnforced = false,
|
||||
this.publicExpireEnabled = false,
|
||||
this.publicExpireDays,
|
||||
this.publicExpireEnforced = false,
|
||||
this.groupEnabled = false,
|
||||
this.resharing = false,
|
||||
this.passwordMinLength,
|
||||
this.passwordEnforceUpperLower = false,
|
||||
this.passwordEnforceNumeric = false,
|
||||
this.passwordEnforceSpecial = false,
|
||||
});
|
||||
|
||||
/// Parses the `files_sharing` sub-map of an OCS `cloud/capabilities`
|
||||
/// response, plus the optional sibling `password_policy` map. Tolerates
|
||||
/// missing intermediate maps and type drift.
|
||||
factory NextcloudSharingCapabilities.fromFilesSharing(
|
||||
Map<String, dynamic> filesSharing, {
|
||||
Map<String, dynamic>? passwordPolicy,
|
||||
}) {
|
||||
Map<String, dynamic>? sub(Map<String, dynamic>? m, String key) {
|
||||
final value = m?[key];
|
||||
return value is Map<String, dynamic> ? value : null;
|
||||
}
|
||||
|
||||
bool boolAt(Map<String, dynamic>? m, String key) => m?[key] == true;
|
||||
int? intAt(Map<String, dynamic>? m, String key) {
|
||||
final v = m?[key];
|
||||
if (v is int) return v;
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
final public = sub(filesSharing, 'public');
|
||||
final password = sub(public, 'password');
|
||||
final expire = sub(public, 'expire_date');
|
||||
final group = sub(filesSharing, 'group');
|
||||
|
||||
return NextcloudSharingCapabilities(
|
||||
apiEnabled: boolAt(filesSharing, 'api_enabled'),
|
||||
publicEnabled: boolAt(public, 'enabled'),
|
||||
publicMultipleLinks: boolAt(public, 'multiple_links'),
|
||||
publicUploadEnabled: boolAt(public, 'upload'),
|
||||
publicPasswordEnforced: boolAt(password, 'enforced'),
|
||||
publicExpireEnabled: boolAt(expire, 'enabled'),
|
||||
publicExpireDays: intAt(expire, 'days'),
|
||||
publicExpireEnforced: boolAt(expire, 'enforced'),
|
||||
// Newer servers nest it under `group.enabled`; older ones expose a flat
|
||||
// `group_sharing` boolean.
|
||||
groupEnabled:
|
||||
boolAt(group, 'enabled') || boolAt(filesSharing, 'group_sharing'),
|
||||
resharing: boolAt(filesSharing, 'resharing'),
|
||||
passwordMinLength: intAt(passwordPolicy, 'minLength'),
|
||||
passwordEnforceUpperLower: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceUpperLowerCase',
|
||||
),
|
||||
passwordEnforceNumeric: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceNumericCharacters',
|
||||
),
|
||||
passwordEnforceSpecial: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceSpecialCharacters',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,144 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'file_sharing_api_params.dart';
|
||||
import 'queries/share/share.dart';
|
||||
import 'queries/share/share_update_params.dart';
|
||||
|
||||
/// OCS `files_sharing` API (Nextcloud Sharing API v1). Per the official docs:
|
||||
/// list/delete pass parameters via the URL (query / path), create and update
|
||||
/// pass them in the request **body** (form-urlencoded), and every call adds
|
||||
/// `format=json` so the server replies with JSON instead of XML.
|
||||
///
|
||||
/// All calls surface failures as [ServerException] so they map to friendly
|
||||
/// messages via `errorToUserMessage`.
|
||||
class FileSharingApi {
|
||||
Future<void> share(FileSharingApiParams query) async {
|
||||
static const String _base = 'apps/files_sharing/api/v1/shares';
|
||||
|
||||
/// Creates a share. Returns the created [Share] (callers that don't need it —
|
||||
/// e.g. Talk file sharing — can ignore the result).
|
||||
Future<Share> share(FileSharingApiParams query) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'apps/files_sharing/api/v1/shares',
|
||||
queryParameters: query.toJson(),
|
||||
_base,
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.post(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||
final response = await http.post(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
body: _stringForm(query.toJson()),
|
||||
);
|
||||
return _decodeShare(response, action: 'Freigabe erstellen');
|
||||
}
|
||||
|
||||
/// Lists shares for the given OCS [path] (see `ocs_path.dart`). [reshares]
|
||||
/// includes shares the current user received and re-shared.
|
||||
Future<List<Share>> listForPath(String path, {bool reshares = false}) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
_base,
|
||||
queryParameters: {
|
||||
'format': 'json',
|
||||
'path': path,
|
||||
'reshares': reshares.toString(),
|
||||
'subfiles': 'false',
|
||||
},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
final data = _decodeData(response, action: 'Freigaben laden');
|
||||
if (data is! List) return const [];
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Share.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Updates an existing share. Returns the updated [Share].
|
||||
Future<Share> update(int shareId, ShareUpdateParams params) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'$_base/$shareId',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.put(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
body: params.toQuery(),
|
||||
);
|
||||
return _decodeShare(response, action: 'Freigabe ändern');
|
||||
}
|
||||
|
||||
/// Deletes (revokes) a share.
|
||||
Future<void> remove(int shareId) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'$_base/$shareId',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.delete(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
);
|
||||
_decodeData(response, action: 'Freigabe löschen');
|
||||
}
|
||||
|
||||
/// Stringifies a json map into form fields (the OCS body is
|
||||
/// `application/x-www-form-urlencoded`). Null values are already dropped by
|
||||
/// the params' `includeIfNull: false`.
|
||||
Map<String, String> _stringForm(Map<String, dynamic> json) =>
|
||||
json.map((key, value) => MapEntry(key, value.toString()));
|
||||
|
||||
/// Decodes a single-share response (create/update). Throws if the payload is
|
||||
/// not a share object.
|
||||
Share _decodeShare(http.Response response, {required String action}) {
|
||||
final data = _decodeData(response, action: action);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'Unerwartete Antwort für "$action": ${response.body}',
|
||||
);
|
||||
}
|
||||
return Share.fromJson(data);
|
||||
}
|
||||
|
||||
/// Validates the HTTP/OCS envelope and returns the `ocs.data` payload, or
|
||||
/// throws a [ServerException] carrying the server's OCS message when present.
|
||||
///
|
||||
/// Every access is type-checked rather than cast: PHP's `json_encode` turns
|
||||
/// an empty associative array into a JSON array (`[]`), and `statuscode` can
|
||||
/// arrive as a string — a hard `as` cast on either would throw a raw
|
||||
/// TypeError that surfaces as the generic "something went wrong" message.
|
||||
Object? _decodeData(http.Response response, {required String action}) {
|
||||
dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} catch (_) {
|
||||
decoded = null;
|
||||
}
|
||||
final ocs = decoded is Map<String, dynamic> ? decoded['ocs'] : null;
|
||||
final ocsMap = ocs is Map<String, dynamic> ? ocs : null;
|
||||
final meta = ocsMap?['meta'];
|
||||
final metaMap = meta is Map<String, dynamic> ? meta : null;
|
||||
|
||||
final rawStatus = metaMap?['statuscode'];
|
||||
final ocsStatus = rawStatus is int
|
||||
? rawStatus
|
||||
: (rawStatus is String ? int.tryParse(rawStatus) : null);
|
||||
final rawMessage = metaMap?['message'];
|
||||
final ocsMessage = rawMessage is String ? rawMessage : null;
|
||||
|
||||
// OCS v2 mirrors the HTTP status; accept any 2xx. Success OCS statuscodes
|
||||
// are 100 (v1 carry-over) or 200.
|
||||
final httpOk = response.statusCode >= 200 && response.statusCode < 300;
|
||||
final ocsOk = ocsStatus == null || ocsStatus == 100 || ocsStatus == 200;
|
||||
if (!httpOk || !ocsOk) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
userMessage: ocsMessage != null && ocsMessage.isNotEmpty
|
||||
? '$action fehlgeschlagen: $ocsMessage'
|
||||
: null,
|
||||
technicalDetails: '$action: ${response.statusCode} ${response.body}',
|
||||
);
|
||||
}
|
||||
return ocsMap?['data'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'file_sharing_api_params.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
// includeIfNull:false so the optional sharing fields below are only sent when
|
||||
// set — otherwise the OCS query would carry `permissions=null` etc. and be
|
||||
// rejected.
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
class FileSharingApiParams {
|
||||
int shareType;
|
||||
String shareWith;
|
||||
@@ -10,12 +13,24 @@ class FileSharingApiParams {
|
||||
String? referenceId;
|
||||
String? talkMetaData;
|
||||
|
||||
/// Permission bitmask (see `share_permissions.dart`).
|
||||
int? permissions;
|
||||
|
||||
/// Public link password.
|
||||
String? password;
|
||||
|
||||
/// Expiry as `YYYY-MM-DD`.
|
||||
String? expireDate;
|
||||
|
||||
FileSharingApiParams({
|
||||
required this.shareType,
|
||||
required this.shareWith,
|
||||
required this.path,
|
||||
this.referenceId,
|
||||
this.talkMetaData,
|
||||
this.permissions,
|
||||
this.password,
|
||||
this.expireDate,
|
||||
});
|
||||
|
||||
factory FileSharingApiParams.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -14,6 +14,9 @@ FileSharingApiParams _$FileSharingApiParamsFromJson(
|
||||
path: json['path'] as String,
|
||||
referenceId: json['referenceId'] as String?,
|
||||
talkMetaData: json['talkMetaData'] as String?,
|
||||
permissions: (json['permissions'] as num?)?.toInt(),
|
||||
password: json['password'] as String?,
|
||||
expireDate: json['expireDate'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||
@@ -22,6 +25,9 @@ Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||
'shareType': instance.shareType,
|
||||
'shareWith': instance.shareWith,
|
||||
'path': instance.path,
|
||||
'referenceId': instance.referenceId,
|
||||
'talkMetaData': instance.talkMetaData,
|
||||
'referenceId': ?instance.referenceId,
|
||||
'talkMetaData': ?instance.talkMetaData,
|
||||
'permissions': ?instance.permissions,
|
||||
'password': ?instance.password,
|
||||
'expireDate': ?instance.expireDate,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../webdav/queries/list_files/cacheable_file.dart';
|
||||
|
||||
/// Converts a [CacheableFile.path] (relative to the WebDAV files root, folders
|
||||
/// ending in `/`) into the path the OCS `files_sharing` API expects: rooted
|
||||
/// with a single leading slash and without a trailing slash on folders.
|
||||
///
|
||||
/// Examples:
|
||||
/// '' -> '/' (files root)
|
||||
/// 'Documents/x.pdf' -> '/Documents/x.pdf'
|
||||
/// 'Documents/' -> '/Documents'
|
||||
/// '/Shared/a/' -> '/Shared/a'
|
||||
String ocsPathFor(String webdavPath) {
|
||||
final trimmed = webdavPath.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return trimmed.isEmpty ? '/' : '/$trimmed';
|
||||
}
|
||||
|
||||
/// Convenience wrapper for a [CacheableFile].
|
||||
String ocsPathOf(CacheableFile file) => ocsPathFor(file.path);
|
||||
@@ -0,0 +1,108 @@
|
||||
/// Nextcloud share types (subset the app uses).
|
||||
const int kShareTypeUser = 0;
|
||||
const int kShareTypeGroup = 1;
|
||||
const int kShareTypePublicLink = 3;
|
||||
const int kShareTypeEmail = 4;
|
||||
|
||||
/// A Talk conversation ("room") the file is linked into.
|
||||
const int kShareTypeRoom = 10;
|
||||
|
||||
/// A single share as returned by the OCS `files_sharing` API.
|
||||
///
|
||||
/// Parsed by hand rather than via code generation: OCS is inconsistent about
|
||||
/// types across versions (e.g. `id`/`share_type` may arrive as either strings
|
||||
/// or numbers) and omits optional fields entirely, so defensive parsing is
|
||||
/// safer than generated `as int` casts.
|
||||
class Share {
|
||||
final int id;
|
||||
final int shareType;
|
||||
final int permissions;
|
||||
|
||||
/// Server path of the shared item (e.g. `/Documents/x.pdf`).
|
||||
final String? path;
|
||||
|
||||
/// `'file'` or `'folder'`.
|
||||
final String? itemType;
|
||||
|
||||
/// Recipient id (user/group id); empty for public links.
|
||||
final String? shareWith;
|
||||
final String? shareWithDisplayname;
|
||||
|
||||
/// Public link URL (only set for [kShareTypePublicLink]).
|
||||
final String? url;
|
||||
|
||||
/// Raw expiration as `"YYYY-MM-DD HH:MM:SS"` (or null when none).
|
||||
final String? expiration;
|
||||
final String? label;
|
||||
|
||||
/// Redacted password marker: the server returns `null` when no password is
|
||||
/// set and a placeholder (`"redacted"`) when one is — never the real value.
|
||||
final String? password;
|
||||
|
||||
const Share({
|
||||
required this.id,
|
||||
required this.shareType,
|
||||
required this.permissions,
|
||||
this.path,
|
||||
this.itemType,
|
||||
this.shareWith,
|
||||
this.shareWithDisplayname,
|
||||
this.url,
|
||||
this.expiration,
|
||||
this.label,
|
||||
this.password,
|
||||
});
|
||||
|
||||
bool get isPublicLink => shareType == kShareTypePublicLink;
|
||||
bool get isGroup => shareType == kShareTypeGroup;
|
||||
bool get isEmail => shareType == kShareTypeEmail;
|
||||
bool get isRoom => shareType == kShareTypeRoom;
|
||||
bool get isFolder => itemType == 'folder';
|
||||
|
||||
/// Whether a (link) password is currently set. See [password].
|
||||
bool get hasPassword => password != null && password!.isNotEmpty;
|
||||
|
||||
/// Best display title for the share row.
|
||||
String get displayTitle {
|
||||
if (isPublicLink) return label?.isNotEmpty == true ? label! : 'Link';
|
||||
final name = shareWithDisplayname;
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
return shareWith ?? 'Unbekannt';
|
||||
}
|
||||
|
||||
/// Human label for the kind of share (for subtitles/headers).
|
||||
String get kindLabel {
|
||||
if (isPublicLink) return 'Öffentlicher Link';
|
||||
if (isGroup) return 'Gruppe';
|
||||
if (isRoom) return 'Talk-Chat';
|
||||
if (isEmail) return 'E-Mail';
|
||||
if (shareType == kShareTypeUser) return 'Person';
|
||||
return 'Freigabe';
|
||||
}
|
||||
|
||||
static int _asInt(Object? value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static String? _asString(Object? value) {
|
||||
if (value == null) return null;
|
||||
final s = value.toString();
|
||||
return s.isEmpty ? null : s;
|
||||
}
|
||||
|
||||
factory Share.fromJson(Map<String, dynamic> json) => Share(
|
||||
id: _asInt(json['id']),
|
||||
shareType: _asInt(json['share_type']),
|
||||
permissions: _asInt(json['permissions']),
|
||||
path: _asString(json['path']),
|
||||
itemType: _asString(json['item_type']),
|
||||
shareWith: _asString(json['share_with']),
|
||||
shareWithDisplayname: _asString(json['share_with_displayname']),
|
||||
url: _asString(json['url']),
|
||||
expiration: _asString(json['expiration']),
|
||||
label: _asString(json['label']),
|
||||
password: _asString(json['password']),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Parameters for updating an existing share via OCS `PUT shares/{id}`.
|
||||
/// Only the fields that are explicitly set are sent — every field is optional
|
||||
/// and a null is omitted from the request (sending `permissions=null` would be
|
||||
/// rejected by the server).
|
||||
///
|
||||
/// Use an empty string for [expireDate]/[password] to explicitly clear the
|
||||
/// value server-side (Nextcloud treats `expireDate=` as "remove expiry").
|
||||
class ShareUpdateParams {
|
||||
final int? permissions;
|
||||
final String? password;
|
||||
final String? expireDate;
|
||||
|
||||
const ShareUpdateParams({this.permissions, this.password, this.expireDate});
|
||||
|
||||
Map<String, String> toQuery() => {
|
||||
if (permissions != null) 'permissions': permissions.toString(),
|
||||
'password': ?password,
|
||||
'expireDate': ?expireDate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/// Nextcloud share permission bitmask helpers. These mirror the constants the
|
||||
/// OCS `files_sharing` API expects in the `permissions` field. Kept as pure
|
||||
/// functions (no Flutter/IO) so they are unit-testable.
|
||||
library;
|
||||
|
||||
/// Individual permission bits (Nextcloud `OCS\Constants`).
|
||||
const int kPermissionRead = 1;
|
||||
const int kPermissionUpdate = 2;
|
||||
const int kPermissionCreate = 4;
|
||||
const int kPermissionDelete = 8;
|
||||
const int kPermissionShare = 16;
|
||||
|
||||
/// User-facing presets that map onto a bitmask.
|
||||
enum SharePreset {
|
||||
/// Recipient can only view/download.
|
||||
readOnly,
|
||||
|
||||
/// Recipient can view, edit, add and remove (full editing).
|
||||
edit,
|
||||
|
||||
/// Upload-only "file request" — recipient can add files to a folder but not
|
||||
/// see existing contents. Only meaningful for folders.
|
||||
fileDrop,
|
||||
}
|
||||
|
||||
extension SharePresetLabel on SharePreset {
|
||||
String get label {
|
||||
switch (this) {
|
||||
case SharePreset.readOnly:
|
||||
return 'Nur Lesen';
|
||||
case SharePreset.edit:
|
||||
return 'Bearbeiten';
|
||||
case SharePreset.fileDrop:
|
||||
return 'Datei-Anfrage';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if [mask] contains the given [flag].
|
||||
bool hasPermission(int mask, int flag) => mask & flag == flag;
|
||||
|
||||
/// Builds the permission bitmask for a [preset].
|
||||
///
|
||||
/// [isFolder] matters for the `edit` preset: a file can only carry
|
||||
/// read+update, while a folder additionally supports create+delete. Nextcloud
|
||||
/// rejects create/delete on a file ("Failed to update share"), so they must be
|
||||
/// omitted there. When [allowReshare] is true the reshare bit is added to the
|
||||
/// editing presets — mirroring how the Nextcloud clients respect the
|
||||
/// `resharing` capability.
|
||||
int permissionsFor(
|
||||
SharePreset preset, {
|
||||
bool allowReshare = false,
|
||||
bool isFolder = false,
|
||||
}) {
|
||||
switch (preset) {
|
||||
case SharePreset.readOnly:
|
||||
return kPermissionRead;
|
||||
case SharePreset.edit:
|
||||
var base = kPermissionRead | kPermissionUpdate;
|
||||
if (isFolder) base |= kPermissionCreate | kPermissionDelete;
|
||||
return allowReshare ? base | kPermissionShare : base;
|
||||
case SharePreset.fileDrop:
|
||||
return kPermissionCreate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies an arbitrary permission bitmask into the closest preset, or null
|
||||
/// if it doesn't match any (e.g. a custom combination). The reshare bit is
|
||||
/// ignored for matching so an "edit" share stays "edit" regardless of reshare.
|
||||
SharePreset? presetFromBitmask(int mask) {
|
||||
final normalized = mask & ~kPermissionShare;
|
||||
if (normalized == kPermissionCreate) return SharePreset.fileDrop;
|
||||
if (normalized == kPermissionRead) return SharePreset.readOnly;
|
||||
// Any read share that also carries a write bit (update/create/delete) is
|
||||
// surfaced as "edit".
|
||||
const writeBits = kPermissionUpdate | kPermissionCreate | kPermissionDelete;
|
||||
if (hasPermission(normalized, kPermissionRead) &&
|
||||
normalized & writeBits != 0) {
|
||||
return SharePreset.edit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -24,6 +24,11 @@ class CacheableFile {
|
||||
/// when a preview is going to load anyway.
|
||||
bool? hasPreview;
|
||||
|
||||
/// True when this entry is an incoming share — i.e. shared with the current
|
||||
/// user by someone else (`nc:mount-type == 'shared'`). Used to badge the
|
||||
/// file/folder in the list. Nullable so older cached entries decode fine.
|
||||
bool? isSharedWithMe;
|
||||
|
||||
CacheableFile({
|
||||
required this.path,
|
||||
required this.isDirectory,
|
||||
@@ -35,6 +40,7 @@ class CacheableFile {
|
||||
this.modifiedAt,
|
||||
this.fileId,
|
||||
this.hasPreview,
|
||||
this.isSharedWithMe,
|
||||
});
|
||||
|
||||
CacheableFile.fromDavFile(WebDavFile file) {
|
||||
@@ -48,6 +54,11 @@ class CacheableFile {
|
||||
modifiedAt = file.lastModified;
|
||||
fileId = int.tryParse(file.fileId ?? '');
|
||||
hasPreview = file.hasPreview;
|
||||
// Incoming share: the item is mounted into the user's files by someone
|
||||
// else. Outgoing shares ([isSharedByMe]) can't be derived from WebDAV with
|
||||
// the pinned package, so they are filled in by ListFiles via one OCS call
|
||||
// per folder.
|
||||
isSharedWithMe = file.props.ncmounttype == 'shared';
|
||||
}
|
||||
|
||||
factory CacheableFile.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -22,6 +22,7 @@ CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
|
||||
: DateTime.parse(json['modifiedAt'] as String),
|
||||
fileId: (json['fileId'] as num?)?.toInt(),
|
||||
hasPreview: json['hasPreview'] as bool?,
|
||||
isSharedWithMe: json['isSharedWithMe'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
||||
@@ -36,4 +37,5 @@ Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
||||
'modifiedAt': instance.modifiedAt?.toIso8601String(),
|
||||
'fileId': instance.fileId,
|
||||
'hasPreview': instance.hasPreview,
|
||||
'isSharedWithMe': instance.isSharedWithMe,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../webdav_api.dart';
|
||||
@@ -37,19 +39,46 @@ class ListFiles extends WebdavApi<ListFilesParams> {
|
||||
ocsize: true,
|
||||
nccreationtime: true,
|
||||
nchaspreview: true,
|
||||
// 'shared' here means an incoming share (mounted into the user's files
|
||||
// by someone else); used to badge those entries in the list.
|
||||
ncmounttype: true,
|
||||
);
|
||||
|
||||
var files = await _fetch(webdav, prop, timeout);
|
||||
// A freshly-entered incoming share sometimes answers its first PROPFIND
|
||||
// without the OC/NC props (no fileid / has-preview / mount-type) while the
|
||||
// share mount warms up server-side — which drops thumbnails AND share
|
||||
// badges together. Retry a couple of times so the folder self-heals
|
||||
// instead of needing manual re-entry.
|
||||
for (var attempt = 0; attempt < 2 && _looksIncomplete(files); attempt++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
files = await _fetch(webdav, prop, timeout);
|
||||
}
|
||||
|
||||
return ListFilesResponse(files);
|
||||
}
|
||||
|
||||
Future<Set<CacheableFile>> _fetch(
|
||||
WebDavClient webdav,
|
||||
WebDavPropWithoutValues prop,
|
||||
Duration timeout,
|
||||
) async {
|
||||
final davFiles =
|
||||
(await webdav
|
||||
.propfind(PathUri.parse(params.path), prop: prop)
|
||||
.timeout(timeout))
|
||||
.toWebDavFiles();
|
||||
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
||||
|
||||
// somehow the current working folder is also listed, it is filtered here.
|
||||
files.removeWhere(
|
||||
(element) => element.path == '/${params.path}/' || element.path == '/',
|
||||
);
|
||||
|
||||
return ListFilesResponse(files);
|
||||
return files;
|
||||
}
|
||||
|
||||
/// True when the server returned entries but none carry a `fileId` — a sign
|
||||
/// the OC/NC properties were omitted (cold share mount), so thumbnails and
|
||||
/// share badges would be missing for the whole folder.
|
||||
bool _looksIncomplete(Set<CacheableFile> files) =>
|
||||
files.isNotEmpty && files.every((file) => file.fileId == null);
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
||||
/// [invalidate].
|
||||
static int _cacheTimeFor(String path) {
|
||||
final stripped = path.replaceAll('/', '').trim();
|
||||
return stripped.isEmpty
|
||||
? RequestCache.cacheDay
|
||||
: RequestCache.cacheNothing;
|
||||
return stripped.isEmpty ? RequestCache.cacheDay : RequestCache.cacheNothing;
|
||||
}
|
||||
|
||||
/// Triggers a root-listing fetch in the background if no cached payload
|
||||
|
||||
Reference in New Issue
Block a user