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:
2026-06-02 21:42:08 +02:00
parent b6d06dd3b4
commit baa26a6e79
33 changed files with 2453 additions and 29 deletions
@@ -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;
}