From 32f7c59bd47ecb5fbd00a7a5b578a28810d32693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Fri, 12 Jun 2026 22:26:21 +0300 Subject: [PATCH] Protect server-managed Android public links --- .../shares/PublicShareDialogFragment.kt | 43 ++++++++++++++++--- .../shares/PublicShareStatusFormatter.kt | 16 ++++--- .../main/res/layout/share_public_dialog.xml | 15 +++++++ .../src/main/res/values-ru/strings.xml | 1 + .../qsferaApp/src/main/res/values/strings.xml | 1 + .../shares/PublicShareStatusFormatterTest.kt | 2 + PRODUCTION_READINESS.md | 2 +- .../docs/sharing-controls-production-audit.md | 8 ++-- scripts/verify-sharing-controls-coverage.ps1 | 6 +++ 9 files changed, 76 insertions(+), 18 deletions(-) diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt index d74f03c3..7a826a3a 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt @@ -238,16 +238,16 @@ class PublicShareDialogFragment : DialogFragment() { if (updating()) { binding.publicShareDialogTitle.setText(R.string.share_via_link_edit_title) binding.shareViaLinkNameValue.setText(publicShare?.name) + val publicShareStatus = publicShare?.let { PublicShareStatusFormatter.statusFor(it) } - when (publicShare?.permissions) { - RemoteShare.CREATE_PERMISSION_FLAG - or RemoteShare.DELETE_PERMISSION_FLAG - or RemoteShare.UPDATE_PERMISSION_FLAG - or RemoteShare.READ_PERMISSION_FLAG -> + when (publicShareStatus?.accessMode) { + PublicShareAccessMode.READ_AND_WRITE -> binding.shareViaLinkEditPermissionReadAndWrite.isChecked = true - RemoteShare.CREATE_PERMISSION_FLAG -> binding.shareViaLinkEditPermissionUploadFiles.isChecked = true - else -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true + PublicShareAccessMode.UPLOAD_ONLY -> binding.shareViaLinkEditPermissionUploadFiles.isChecked = true + PublicShareAccessMode.READ_ONLY -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true + PublicShareAccessMode.CUSTOM -> binding.shareViaLinkEditPermissionGroup.clearCheck() + null -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true } if (publicShare?.isPasswordProtected == true) { @@ -265,12 +265,19 @@ class PublicShareDialogFragment : DialogFragment() { binding.shareViaLinkExpirationValue.text = formattedDate } + if (publicShareStatus?.isEditableInOcsDialog == false) { + applyServerManagedPublicLinkState() + } } else { binding.shareViaLinkNameValue.setText(arguments?.getString(ARG_DEFAULT_LINK_NAME, "")) } } private fun onSaveShareSetting() { + if (isServerManagedPublicLink()) { + return + } + // Get data filled by user val publicLinkName = binding.shareViaLinkNameValue.text.toString() var publicLinkPassword: String? = binding.shareViaLinkPasswordValue.text.toString() @@ -826,6 +833,11 @@ class PublicShareDialogFragment : DialogFragment() { QSferaVersion(it, it) // FIXME: check productversion as second parameter, not versionstring } + if (isServerManagedPublicLink()) { + applyServerManagedPublicLinkState() + return + } + // Show keyboard to fill the public share name dialog?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE @@ -932,6 +944,23 @@ class PublicShareDialogFragment : DialogFragment() { } } + private fun isServerManagedPublicLink(): Boolean = + publicShare?.let { share -> !PublicShareStatusFormatter.statusFor(share).isEditableInOcsDialog } == true + + private fun applyServerManagedPublicLinkState() { + binding.shareViaLinkManagedAccessMessage.isVisible = true + binding.shareViaLinkEditPermissionGroup.isVisible = false + binding.shareViaLinkNameValue.isEnabled = false + binding.shareViaLinkPasswordSwitch.isEnabled = false + binding.shareViaLinkPasswordValue.isEnabled = false + binding.generatePasswordButton.isEnabled = false + binding.copyPasswordButton.isEnabled = false + binding.shareViaLinkExpirationSwitch.isEnabled = false + binding.shareViaLinkExpirationLabel.isEnabled = false + binding.shareViaLinkExpirationValue.isEnabled = false + binding.saveButton.isEnabled = false + } + private fun isReadOnlyPermission() = binding.shareViaLinkEditPermissionReadOnly.isChecked && capabilities?.filesSharingPublicPasswordEnforcedReadOnly == CapabilityBooleanType.TRUE diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatter.kt index d8594627..64d7a51a 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatter.kt @@ -6,11 +6,14 @@ import eu.qsfera.android.lib.resources.shares.RemoteShare internal object PublicShareStatusFormatter { fun statusFor(share: OCShare): PublicShareStatus = - PublicShareStatus( - accessMode = accessModeFor(share.permissions), - isPasswordProtected = share.isPasswordProtected, - expirationDateInMillis = share.expirationDate.takeIf { it > 0 } - ) + accessModeFor(share.permissions).let { accessMode -> + PublicShareStatus( + accessMode = accessMode, + isPasswordProtected = share.isPasswordProtected, + expirationDateInMillis = share.expirationDate.takeIf { it > 0 }, + isEditableInOcsDialog = accessMode != PublicShareAccessMode.CUSTOM, + ) + } private fun accessModeFor(permissions: Int): PublicShareAccessMode { val canRead = permissions hasPermission RemoteShare.READ_PERMISSION_FLAG @@ -39,7 +42,8 @@ internal object PublicShareStatusFormatter { internal data class PublicShareStatus( val accessMode: PublicShareAccessMode, val isPasswordProtected: Boolean, - val expirationDateInMillis: Long? + val expirationDateInMillis: Long?, + val isEditableInOcsDialog: Boolean, ) internal enum class PublicShareAccessMode { diff --git a/Android/qsferaApp/src/main/res/layout/share_public_dialog.xml b/Android/qsferaApp/src/main/res/layout/share_public_dialog.xml index 084e702a..66df8774 100644 --- a/Android/qsferaApp/src/main/res/layout/share_public_dialog.xml +++ b/Android/qsferaApp/src/main/res/layout/share_public_dialog.xml @@ -76,6 +76,21 @@ + + Скачивание / просмотр / загрузка Только загрузка Особые права + Эта ссылка использует доступ, управляемый сервером: например, доступ только для организации или безопасный просмотр без скачивания. Здесь ее можно скопировать или удалить. Изменяйте ее в веб-приложении, чтобы не изменить защиту. Защищено паролем Без пароля Действует до %1$s diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index b0f24673..dcb0e703 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -683,6 +683,7 @@ Download / View / Upload Upload only Custom access + This link uses server-managed access, such as organization-only access or secure view without downloads. You can copy or remove it here. Edit it from the web app to avoid changing its protection. Password protected No password Expires %1$s diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatterTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatterTest.kt index f1659632..ab7b29c9 100644 --- a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatterTest.kt +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatterTest.kt @@ -23,6 +23,7 @@ class PublicShareStatusFormatterTest { assertEquals(PublicShareAccessMode.READ_ONLY, status.accessMode) assertFalse(status.isPasswordProtected) assertNull(status.expirationDateInMillis) + assertTrue(status.isEditableInOcsDialog) } @Test @@ -62,5 +63,6 @@ class PublicShareStatusFormatterTest { val status = PublicShareStatusFormatter.statusFor(share) assertEquals(PublicShareAccessMode.CUSTOM, status.accessMode) + assertFalse(status.isEditableInOcsDialog) } } diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 6c4c4c39..fff8489e 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -40,6 +40,7 @@ Date: 2026-06-08. - Android photo/video backup status now shows per-source scan state, missing SAF folder access, and selected-account low-storage warnings in the settings summary. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` passed on 2026-06-10. - Android photo/video backup status now also observes automatic-upload transfers live and reports uploading, queued, failed, and paused-by-network/roaming/charging states in the settings summary. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` passed on 2026-06-10. - Android public-link rows now show the effective access mode, password state, and expiration state directly in the share list, so users can review link safety without reopening each link. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.sharing.shares.PublicShareStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` passed on 2026-06-10. +- Android now keeps Graph-style `internal` and `blocksDownload` public links as server-managed custom-access states in the OCS public-link editor: list rows show `Custom access`, the edit dialog explains that the link is managed by the server, and saving is disabled to avoid downgrading organization-only or secure-view protection to a standard OCS link. - Sharing controls were audited against the Google Drive and Yandex Disk benchmarks in `Server/docs/sharing-controls-production-audit.md`, and `scripts/verify-sharing-controls-coverage.ps1` now checks local Android UI, Graph link types, server link handling, and acceptance-feature coverage for password, expiration, access roles, internal links, secure-view/no-download behavior, and role-based resharing. - Server `qsfera_full` compose files now require explicit admin, Keycloak, and S3/MinIO secrets instead of demo defaults. - Server `qsfera_full/env.production.example` provides a tracked production-safe starting point with TLS validation enabled and demo users disabled. @@ -93,4 +94,3 @@ Date: 2026-06-08. ## Remaining P1 - Configure `QSFERA_ALERT_WEBHOOK_URL` in `/etc/qsfera/rpi-production-check.env` if off-host alert delivery is required; the installed failure service currently logs through `systemd-cat` and journal without an external endpoint. -- Decide and implement Android-facing controls for Graph `internal` links and `blocksDownload` secure-view links, or explicitly keep them server-managed/custom-access states. Server/API coverage is documented in `Server/docs/sharing-controls-production-audit.md`; the Android public-link dialog still exposes only password, expiration, read-only, read/write, and upload-only OCS controls. diff --git a/Server/docs/sharing-controls-production-audit.md b/Server/docs/sharing-controls-production-audit.md index e668234d..1e7df175 100644 --- a/Server/docs/sharing-controls-production-audit.md +++ b/Server/docs/sharing-controls-production-audit.md @@ -14,8 +14,8 @@ Date: 2026-06-10. | Public-link password | Android exposes `shareViaLinkPasswordSwitch`; Graph `CreateLink` stores `Password`; acceptance tests create and update password-protected link shares and verify old passwords fail with HTTP `401`. | Covered for Android OCS links and Graph links. | | Public-link expiration | Android exposes `shareViaLinkExpirationSwitch`; Graph `CreateLink` reads `expirationDateTime`; acceptance tests create, update, and remove expiration dates. | Covered for Android OCS links and Graph links. | | Link access role | Android exposes read-only, read/write, and upload-only radio options; Graph link types include `view`, `edit`, `upload`, and `createOnly`; acceptance tests cover those roles for files, folders, and project-space links. | Covered, with different Android OCS labels and Graph role names. | -| Disable downloads / secure view | Graph link types include `blocksDownload`; acceptance tests also verify the `Secure Viewer` role cannot download files. Android public-link rows now display non-standard link permissions as `Custom access`. | Server/API coverage exists; Android does not expose a dedicated `blocksDownload` switch. | -| Organization-only access | Graph link types include `internal`; frontend config documents default link permission `0` as an internal/member-only link; server rejects redundant passwords on internal links; acceptance tests create and update internal links. | Server/API coverage exists; Android does not expose a separate organization-only/internal link choice. | +| Disable downloads / secure view | Graph link types include `blocksDownload`; acceptance tests also verify the `Secure Viewer` role cannot download files. Android public-link rows display non-standard link permissions as `Custom access`, and the Android edit dialog treats those links as server-managed so they cannot be downgraded through the OCS dialog. | Server/API coverage exists; Android keeps `blocksDownload` links server-managed until a Graph-link editor is added. | +| Organization-only access | Graph link types include `internal`; frontend config documents default link permission `0` as an internal/member-only link; server rejects redundant passwords on internal links; acceptance tests create and update internal links. Android public-link rows display these non-standard states as `Custom access`, and the Android edit dialog treats them as server-managed. | Server/API coverage exists; Android keeps `internal` links server-managed until a Graph-link editor is added. | | Role inheritance / resharing | Space sharing acceptance tests verify managers can reshare spaces, editors/viewers cannot, and project-space public-link creation requires manager rights. | Covered by server acceptance scenarios. | ## Repeatable Local Check @@ -28,6 +28,6 @@ Run from the repository root: The script is a static coverage gate over Android UI files, Graph link type code, server link creation code, and acceptance feature files. It does not replace a full server acceptance run. -## Remaining Product Gap +## Product Decision -The Android public-link dialog currently exposes the OCS link controls already present in the client: password, expiration, read-only, read/write, and upload-only. It does not expose dedicated Graph `internal` or `blocksDownload` controls. If QSfera wants Android parity with Yandex Disk's organization-only and disable-download controls, the next client/server integration step is to add Android Graph-link creation/editing paths for those two link types or define an explicit product decision that Android continues to show them as server-managed/custom access states. +The Android public-link dialog exposes the OCS link controls already present in the client: password, expiration, read-only, read/write, and upload-only. Dedicated Graph `internal` and `blocksDownload` creation/editing controls remain server-managed on Android until a Graph-link editor is added. The Android list shows those links as `Custom access`, and the Android edit dialog disables saving for them so a user cannot accidentally replace organization-only or secure-view protection with a standard OCS read-only link. diff --git a/scripts/verify-sharing-controls-coverage.ps1 b/scripts/verify-sharing-controls-coverage.ps1 index cbd9596b..5b6ee1bb 100644 --- a/scripts/verify-sharing-controls-coverage.ps1 +++ b/scripts/verify-sharing-controls-coverage.ps1 @@ -37,6 +37,12 @@ Assert-FileContains "Android public-link dialog exposes upload-only mode" ` Assert-FileContains "Android public-link rows show safety status" ` "Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/SharePublicLinkListAdapter.kt" ` "PublicShareStatusFormatter\.statusFor" +Assert-FileContains "Android public-link dialog protects server-managed custom links" ` + "Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt" ` + "isServerManagedPublicLink" +Assert-FileContains "Android public-link dialog explains managed access" ` + "Android/qsferaApp/src/main/res/layout/share_public_dialog.xml" ` + "shareViaLinkManagedAccessMessage" Assert-FileContains "Graph API declares internal link type" ` "Server/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sharing_link_type.go" `