Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ env:
FLUTTER_VERSION: '3.44.2'

jobs:

analyze:
runs-on: ubuntu-latest
if: github.event.repository.private == false
Expand Down Expand Up @@ -103,8 +102,7 @@ jobs:
id: lychee
uses: lycheeverse/lychee-action@v2
with: # Don't fail for now but then create an issue - useful?
args:
--exclude-file .lycheeignore
args: --exclude-path .lycheeignore
--no-progress
'*.md'
'./**/*.dart'
Expand Down
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ Visit the package at [pub.dev](https://pub.dev/packages/solidpod).

## 1.0

+ Switch public to private sharing [1.0.15 20260728 jesscmoore]
+ Preserve encryption state on overwrite [1.0.14 20260730 jesscmoore]
+ Add load test to the example app [1.0.13 20260702 tonypioneer]
+ Migrate TEMPALTE to solidui [1.0.12 20260629 tonypioneer]
+ Migrate TEMPLATE to solidui [1.0.12 20260629 tonypioneer]
+ Support profile editing [1.0.11 20260626 tonypioneer]
+ Bug fix template for dart run [1.0.10 20260622 tonypioneer]
+ Add app template for a 'create' experience [1.0.9 20260619 tonypioneer]
Expand All @@ -32,8 +34,8 @@ Visit the package at [pub.dev](https://pub.dev/packages/solidpod).
+ Check missing resources [0.12.9 20260520 tonypioneer]
+ Support checking webID [0.12.8 20260520 tonypioneer]
+ Update Try Another WebID workflow [0.12.7 20260520 tonypioneer]
+ Bug fix to ttl rdf for special chars #628 [0.12.6 20260518 tonypioneer]
+ Upgrade solidauth and fix key file saving edge cases [0.12.5 20260427 jesscmoore]
+ Bug fix to ttl RDF for special chars #628 [0.12.6 20260518 tonypioneer]
+ Upgrade solid_auth and fix key file saving edge cases [0.12.5 20260427 jesscmoore]
+ Support user profile. [0.12.4 20260421 tonypioneer]
+ Key map + paths updates. Update file_picker. [0.12.3 20260420 jesscmoore]
+ Add silentLogout() [0.12.2 20260325 tonypioneer]
Expand Down
55 changes: 55 additions & 0 deletions lib/src/solid/grant_permission.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ import 'package:solidpod/src/solid/api/rest_api.dart';
import 'package:solidpod/src/solid/constants/common.dart';
import 'package:solidpod/src/solid/constants/web_acl.dart';
import 'package:solidpod/src/solid/models/log_entry.dart';
import 'package:solidpod/src/solid/read_permission.dart'
show getUserClassPermissions;
import 'package:solidpod/src/solid/revoke_permission.dart'
show revokePermission;
import 'package:solidpod/src/solid/solid_func_call_status.dart';
import 'package:solidpod/src/solid/utils/exceptions.dart';
import 'package:solidpod/src/solid/utils/get_url_helper.dart';
Expand Down Expand Up @@ -70,6 +74,17 @@ import 'package:solidpod/src/solid/utils/permission.dart' show genAclTurtle;
/// - [isFile] Optional flag describing whether the resources is a file or
/// not.
/// - [groupName] - Optional name of the group permission.
/// - [revokePublicAccessOnSpecificGrant] - When [recipientType] is
/// individual or group and the resource currently has a Public or
/// Authenticated User class grant (and is therefore plaintext on the
/// server, per the decryption step this function performs for those
/// recipient classes), revoke that grant and re-encrypt the resource
/// before proceeding. Without this, the resource would end up both still
/// readable by the previously-granted class *and* holding a stale
/// individual key that does not match the (still plaintext) content.
/// Defaults to `true`; set to `false` to keep today's behaviour where
/// granting to a specific recipient never touches an existing
/// public/authUser grant.

Future<SolidFunctionCallStatus> grantPermission({
required String fileName,
Expand All @@ -81,6 +96,7 @@ Future<SolidFunctionCallStatus> grantPermission({
bool isFile = true,
bool isExternalRes = false,
String? groupName,
bool revokePublicAccessOnSpecificGrant = true,
}) async {
if (!await isUserLoggedIn()) {
throw NotLoggedInException(
Expand Down Expand Up @@ -150,6 +166,45 @@ Future<SolidFunctionCallStatus> grantPermission({
// if recipient pods have been initialised
if (allRecipientsInitialised || !hasSpecificRecipients) {
if (resStatus == ResourceStatus.exist) {
// Sharing to a specific individual/group assumes the resource is
// ciphertext under an individual key (see the `fileHasIndKey`
// branch below). If it's currently also granted to the Public or
// Authenticated User class, it's plaintext on the server (that
// class has no key of its own to decrypt with) — so revoke that
// grant and re-encrypt first. Otherwise the resource would end up
// both still openly readable *and* holding a stale individual key
// that doesn't match the (still plaintext) bytes. Must run before
// `setPermissionAcl` below, so `revokePermission`'s own ACL read
// still sees the pre-existing grant.
if (hasSpecificRecipients && revokePublicAccessOnSpecificGrant) {
final existingClassPerms = await getUserClassPermissions(
fileName: resourceUrl,
isFile: isFile,
isFileUrl: true,
isExternalRes: isExternalRes,
);
for (final classType in existingClassPerms.keys) {
final classAgent = classType == RecipientType.public
? publicAgent
: authenticatedAgent;
debugPrint(
'[grantPermission] revoking existing $classType access on '
'"$resourceUrl" before granting to $recipientType',
);
await revokePermission(
fileName: resourceUrl,
isFileUrl: true,
permissionList: existingClassPerms[classType]!,
recipientIndOrGroupWebId: classAgent.value,
recipientType: classType,
ownerWebId: ownerWebId,
granterWebId: granterWebId,
isFile: isFile,
isExternalRes: isExternalRes,
);
}
}

// Add the permission line to the relevant ACL file
await setPermissionAcl(
resourceUrl,
Expand Down
44 changes: 44 additions & 0 deletions lib/src/solid/read_permission.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ library;

import 'dart:core';

import 'package:rdflib/rdflib.dart';

import 'package:solidpod/src/solid/constants/common.dart'
show agentClassPred, agentStr, permStr;
import 'package:solidpod/src/solid/constants/web_acl.dart'
show RecipientType, authenticatedAgent, publicAgent;
import 'package:solidpod/src/solid/utils/get_url_helper.dart';
import 'package:solidpod/src/solid/utils/misc.dart';
import 'package:solidpod/src/solid/utils/permission.dart';
Expand Down Expand Up @@ -70,3 +76,41 @@ Future<Map<dynamic, dynamic>> readPermission({

return permMap;
}

/// The Public/Authenticated-User access modes currently granted on
/// [fileName], keyed by [RecipientType.public]/[RecipientType.authUser].
/// A class with no current grant is omitted from the result.
///
/// Single source of truth for "does this resource currently have a
/// Public/AuthenticatedUser grant" — used both by [grantPermission] (to
/// decide whether an individual/group grant must first revoke and
/// re-encrypt) and by solidui's confirmation dialog for the same action.

Future<Map<RecipientType, List<String>>> getUserClassPermissions({
required String fileName,
required bool isFile,
bool isFileUrl = false,
bool isExternalRes = false,
}) async {
final permMap = await readPermission(
fileName: fileName,
isFile: isFile,
isFileUrl: isFileUrl,
isExternalRes: isExternalRes,
);

final result = <RecipientType, List<String>>{};
for (final receiverId in permMap.keys) {
if (receiverId is! String ||
permMap[receiverId][agentStr] != agentClassPred) {
continue;
}
final perms = (permMap[receiverId][permStr] as List).cast<String>();
if (URIRef(receiverId) == publicAgent) {
result[RecipientType.public] = perms;
} else if (URIRef(receiverId) == authenticatedAgent) {
result[RecipientType.authUser] = perms;
}
}
return result;
}
27 changes: 23 additions & 4 deletions lib/src/solid/write_external_pod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import 'dart:convert';
import 'package:flutter/material.dart' hide Key;

import 'package:solidpod/src/solid/api/rest_api.dart';
import 'package:solidpod/src/solid/check_encryption.dart'
show isContentEncrypted;
import 'package:solidpod/src/solid/common_func.dart';
import 'package:solidpod/src/solid/constants/common.dart';
import 'package:solidpod/src/solid/utils/exceptions.dart';
Expand All @@ -44,7 +46,17 @@ import 'package:solidpod/src/solid/utils/misc.dart';

/// Write file [fileUrl] with content [fileContent] to an external PODs in the
/// data directory (within potential subdirectories encoded in [fileUrl]).
/// The content will be encrypted if the original content is true.
///
/// [encrypted] defaults to `null`, meaning "not specified by the caller": when
/// overwriting an existing file, the file's *current* at-rest state on the
/// server is mirrored (plaintext stays plaintext, ciphertext stays
/// ciphertext) rather than always re-encrypting just because a shared
/// individual key happens to be on record. This matters for a resource the
/// owner decrypted in place for Public/Authenticated User sharing (see
/// `decryptFileInPlace` in solidpod) — without this, a recipient with write
/// access editing the file would silently re-encrypt it and break that
/// sharing grant. Pass `true`/`false` explicitly to force a specific
/// encryption state regardless of what's currently on the server.
///
/// The encryption boilerplate shared with [writePod] is factored out into
/// [getEncTTLStrWithRandomIV], and the "own POD vs external POD" routing is
Expand All @@ -57,7 +69,7 @@ Future<void> writeExternalPod(
String fileUrl,
String fileContent,
String fileOwnerWebId, {
bool encrypted = true,
bool? encrypted,
bool overwrite = true,
String? inheritKeyFrom,
}) async {
Expand Down Expand Up @@ -89,9 +101,16 @@ Future<void> writeExternalPod(
case ResourceStatus.exist:
final remoteFileContent = utf8.decode(await getResource(fileUrl));

// When the caller didn't specify [encrypted], mirror whatever is
// actually on the server right now instead of assuming a shared key
// on record means the file should be (re-)encrypted — the owner may
// have decrypted it in place for Public/Authenticated User sharing.
final wantEncrypted = encrypted ??
isContentEncrypted(fileUrl: fileUrl, content: remoteFileContent);

final key = await KeyManager.getSharedIndividualKey(fileUrl);

if (key != null) {
if (wantEncrypted && key != null) {
// Get file path
// final filePath =
// fileUrl.replaceAll(fileOwnerWebId.replaceAll(profCard, ''), '');
Expand All @@ -108,7 +127,7 @@ Future<void> writeExternalPod(
'but the extension of provided filename "$fileUrl" is not ".ttl"',
);
}
} else if (hasInheritedKey(remoteFileContent, fileUrl)) {
} else if (wantEncrypted && hasInheritedKey(remoteFileContent, fileUrl)) {
// Get file path
// final filePath =
// fileUrl.replaceAll(fileOwnerWebId.replaceAll(profCard, ''), '');
Expand Down
44 changes: 40 additions & 4 deletions lib/src/solid/write_pod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@

library;

import 'dart:convert' show utf8;

import 'package:flutter/foundation.dart' show debugPrint;

import 'package:encrypter_plus/encrypter_plus.dart' show Key;
import 'package:mime/mime.dart' as mime;

import 'package:solidpod/src/solid/api/rest_api.dart';
import 'package:solidpod/src/solid/check_encryption.dart'
show isContentEncrypted;
import 'package:solidpod/src/solid/constants/common.dart';
import 'package:solidpod/src/solid/constants/path_type.dart';
import 'package:solidpod/src/solid/utils/exceptions.dart';
Expand All @@ -58,7 +62,18 @@ import 'package:solidpod/src/solid/write_external_pod.dart'
/// Arguments:
/// - [filePath]: The path (relative to appname/data/) of the file to write
/// - [fileContent]: The content to write to the file
/// - [encrypted]: Whether to encrypt the file content (default: true)
/// - [encrypted]: Whether to encrypt the file content. Defaults to `null`,
/// meaning "not specified by the caller": for a new file (or when
/// [overwrite] is false) this behaves as `true`; when [overwrite] is true
/// and the file already exists, the file's *current* at-rest state on the
/// server is mirrored instead (plaintext stays plaintext, ciphertext stays
/// ciphertext). This matters for a resource that was decrypted in place
/// for Public/Authenticated User sharing (see `decryptFileInPlace`) —
/// without this, an unrelated edit would silently re-encrypt it and break
/// that sharing grant, since a class-based ACL grant has no key to
/// decrypt with. Pass `true`/`false` explicitly to override this and
/// force a specific encryption state regardless of what's currently on
/// the server.
/// - [createAcl]: Whether to create a separate acl for the resource (default: true)
/// - [overwrite]: Whether to overwrite the content of an existing file (default: false)
/// - [pathType]: Optional type of relative path (for both [filePath] and [inheritKeyFrom])
Expand All @@ -80,7 +95,7 @@ import 'package:solidpod/src/solid/write_external_pod.dart'
Future<void> writePod(
String filePath,
String fileContent, {
bool encrypted = true,
bool? encrypted,
bool createAcl = true,
bool overwrite = false,
PathType pathType = PathType.relativeToData,
Expand Down Expand Up @@ -133,6 +148,27 @@ Future<void> writePod(
);
}

final status = await checkResourceStatus(fileUrl);

// Resolve the effective encryption flag. When the caller didn't specify
// [encrypted] and this is an overwrite of an existing file, mirror the
// file's current at-rest state instead of assuming `true` — otherwise an
// unrelated edit would silently re-encrypt a file that was deliberately
// decrypted in place for Public/Authenticated User sharing (see
// `decryptFileInPlace`), stranding a resource whose ACL still promises
// open access but whose bytes no longer are.

var resolvedEncrypted = encrypted ?? true;
if (encrypted == null &&
inheritKeyFrom == null &&
overwrite &&
status == ResourceStatus.exist) {
final currentContent = utf8.decode(await getResource(fileUrl));
// Determine current encryption state
resolvedEncrypted =
isContentEncrypted(fileUrl: fileUrl, content: currentContent);
}

Key? encKey;
String? inheritKeyUrl;
if (inheritKeyFrom != null) {
Expand All @@ -143,7 +179,7 @@ Future<void> writePod(
);
}

if (encrypted || inheritKeyFrom != null) {
if (resolvedEncrypted || inheritKeyFrom != null) {
if (!fileUrl.endsWith('.ttl')) {
throw Exception(
'Encrypted text file should be in turtle format, '
Expand All @@ -154,7 +190,7 @@ Future<void> writePod(
encKey = await configureEncKey(fileUrl, inheritKeyUrl: inheritKeyUrl);
}

switch (await checkResourceStatus(fileUrl)) {
switch (status) {
case ResourceStatus.exist:
if (overwrite) {
debugPrint('NOTE: Overwriting existing file "$filePath"');
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: solidpod
description: Support access to private data from PODs on Solid servers.
version: 1.0.13
version: 1.0.15
homepage: https://github.com/anusii/solidpod

environment:
Expand Down
Loading