From dccd80b9fd5dce674c20e81cb120f4a2088e0c6f Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Sat, 19 Oct 2024 11:48:28 +0200 Subject: [PATCH] WIP --- .gitignore | 1 + app/build.gradle | 2 +- .../5.json | 176 ++++++++++++++++++ .../android/webdav/DocumentsProviderTest.kt | 11 +- .../dev/rocli/android/webdav/data/Account.kt | 9 + .../rocli/android/webdav/data/AppDatabase.kt | 13 +- .../webdav/fragments/AccountFragment.kt | 44 ++++- .../android/webdav/provider/WebDavClient.kt | 1 - app/src/main/res/layout/fragment_account.xml | 28 ++- app/src/main/res/values/arrays.xml | 6 + tests/docker-compose.yml | 15 ++ tests/servers/apache-digest/.passwd | 1 + tests/servers/apache-digest/httpd-vhosts.conf | 24 +++ tests/servers/apache/Dockerfile | 3 +- 14 files changed, 317 insertions(+), 17 deletions(-) create mode 100644 app/schemas/dev.rocli.android.webdav.data.AppDatabase/5.json create mode 100644 tests/servers/apache-digest/.passwd create mode 100644 tests/servers/apache-digest/httpd-vhosts.conf diff --git a/.gitignore b/.gitignore index 1ecc0a6..856e17d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin/ /local.properties /.idea .DS_Store diff --git a/app/build.gradle b/app/build.gradle index 5163c34..d7c8da4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -140,10 +140,10 @@ dependencies { implementation "com.mikepenz:aboutlibraries-core:$aboutLibrariesVersion" implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' - implementation 'io.github.rburgst:okhttp-digest:3.1.0' implementation 'com.squareup.retrofit2:retrofit:2.11.0' implementation 'com.squareup.retrofit2:converter-simplexml:2.11.0' implementation 'com.github.thegrizzlylabs:sardine-android:0.9' + implementation 'io.github.rburgst:okhttp-digest:3.1.0' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' kapt "androidx.room:room-compiler:$roomVersion" diff --git a/app/schemas/dev.rocli.android.webdav.data.AppDatabase/5.json b/app/schemas/dev.rocli.android.webdav.data.AppDatabase/5.json new file mode 100644 index 0000000..7c3b0b2 --- /dev/null +++ b/app/schemas/dev.rocli.android.webdav.data.AppDatabase/5.json @@ -0,0 +1,176 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "c98cd93c63f8ee003926006b68582ecf", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `url` TEXT, `protocol` TEXT NOT NULL DEFAULT 'AUTO', `verify_certs` INTEGER NOT NULL, `auth_type` TEXT NOT NULL DEFAULT 'NONE', `username` TEXT, `password` TEXT, `client_cert` TEXT, `max_cache_file_size` INTEGER NOT NULL, `act_as_local_storage` INTEGER NOT NULL DEFAULT false)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'AUTO'" + }, + { + "fieldPath": "verifyCerts", + "columnName": "verify_certs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authType", + "columnName": "auth_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientCert", + "columnName": "client_cert", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxCacheFileSize", + "columnName": "max_cache_file_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actAsLocalStorage", + "columnName": "act_as_local_storage", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "cache_entry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account_id` INTEGER NOT NULL, `status` TEXT NOT NULL, `path` TEXT NOT NULL, `etag` TEXT, `content_length` INTEGER, `last_modified` INTEGER, FOREIGN KEY(`account_id`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_cache_entry_account_id_path", + "unique": true, + "columnNames": [ + "account_id", + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cache_entry_account_id_path` ON `${TABLE_NAME}` (`account_id`, `path`)" + } + ], + "foreignKeys": [ + { + "table": "account", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c98cd93c63f8ee003926006b68582ecf')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/rocli/android/webdav/DocumentsProviderTest.kt b/app/src/androidTest/java/dev/rocli/android/webdav/DocumentsProviderTest.kt index 1cddfa8..b31b157 100644 --- a/app/src/androidTest/java/dev/rocli/android/webdav/DocumentsProviderTest.kt +++ b/app/src/androidTest/java/dev/rocli/android/webdav/DocumentsProviderTest.kt @@ -54,13 +54,12 @@ class DocumentsProviderTest(private val testName: String, private val account: A @Parameterized.Parameters(name = "{0}") fun params(): Collection> { return listOf( - arrayOf("hacdias", Account( + /*arrayOf("hacdias", Account( name = "Hacdias", url = "http://${HOST}:8001", username = SecretString("test"), password = SecretString("test") - ) - ), + )), arrayOf("nginx", Account( name = "Nginx", url = "http://${HOST}:8002" @@ -78,6 +77,12 @@ class DocumentsProviderTest(private val testName: String, private val account: A arrayOf("apache-subpath", Account( name = "Apache (subpath) proxied through Nginx", url = "http://${HOST}:8005/webdav" + )),*/ + arrayOf("apache-digest", Account( + name = "Apache using digest auth", + url = "http://${HOST}:8006", + username = SecretString("test"), + password = SecretString("test") )), ) } diff --git a/app/src/main/java/dev/rocli/android/webdav/data/Account.kt b/app/src/main/java/dev/rocli/android/webdav/data/Account.kt index bac1b60..8abdb43 100644 --- a/app/src/main/java/dev/rocli/android/webdav/data/Account.kt +++ b/app/src/main/java/dev/rocli/android/webdav/data/Account.kt @@ -29,6 +29,9 @@ data class Account( @ColumnInfo(name = "verify_certs") var verifyCerts: Boolean = true, + @ColumnInfo(name = "auth_type", defaultValue = "NONE") + var authType: AuthType = AuthType.NONE, + @ColumnInfo(name = "username") var username: SecretString? = null, @@ -73,6 +76,12 @@ data class Account( enum class Protocol { AUTO, HTTP1 } + + enum class AuthType { + NONE, + BASIC, + DIGEST + } } fun List.byId(id: Long): Account { diff --git a/app/src/main/java/dev/rocli/android/webdav/data/AppDatabase.kt b/app/src/main/java/dev/rocli/android/webdav/data/AppDatabase.kt index 65d06a8..8e3cfac 100644 --- a/app/src/main/java/dev/rocli/android/webdav/data/AppDatabase.kt +++ b/app/src/main/java/dev/rocli/android/webdav/data/AppDatabase.kt @@ -4,19 +4,28 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase @Database( - version = 4, + version = 5, exportSchema = true, entities = [Account::class, CacheEntry::class], autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), - AutoMigration(from = 3, to = 4) + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5, spec = AppDatabase.AuthTypeMigration::class) ] ) @TypeConverters(SecretStringConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun cacheDao(): CacheDao + + class AuthTypeMigration : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE account SET auth_type = 'BASIC' WHERE username IS NOT NULL OR password IS NOT NULL") + } + } } diff --git a/app/src/main/java/dev/rocli/android/webdav/fragments/AccountFragment.kt b/app/src/main/java/dev/rocli/android/webdav/fragments/AccountFragment.kt index 4cfb431..beb35ed 100644 --- a/app/src/main/java/dev/rocli/android/webdav/fragments/AccountFragment.kt +++ b/app/src/main/java/dev/rocli/android/webdav/fragments/AccountFragment.kt @@ -14,6 +14,7 @@ import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.core.view.children +import androidx.core.widget.doAfterTextChanged import androidx.databinding.BindingAdapter import androidx.databinding.DataBindingUtil import androidx.databinding.InverseBindingAdapter @@ -29,8 +30,6 @@ import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import dev.rocli.android.webdav.R import dev.rocli.android.webdav.data.Account import dev.rocli.android.webdav.data.AccountDao @@ -41,6 +40,8 @@ import dev.rocli.android.webdav.provider.WebDavCache import dev.rocli.android.webdav.provider.WebDavClientManager import dev.rocli.android.webdav.provider.WebDavPath import dev.rocli.android.webdav.provider.WebDavProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrl import javax.inject.Inject @@ -71,8 +72,19 @@ class AccountFragment : Fragment() { binding.account = Account() } - val adapter = ArrayAdapter.createFromResource(requireContext(), R.array.protocol_options, R.layout.dropdown_list_item) - binding.dropdownProtocol.setAdapter(adapter) + val protocolAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.protocol_options, R.layout.dropdown_list_item) + binding.dropdownProtocol.setAdapter(protocolAdapter) + + val authTypeAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.auth_type_options, R.layout.dropdown_list_item) + binding.dropdownAuthType.setAdapter(authTypeAdapter) + binding.dropdownAuthType.doAfterTextChanged { + binding.account?.let { + it.username = null + it.password = null + } + updateUserPassVisibility() + } + updateUserPassVisibility() if (binding.account!!.clientCert.isNullOrBlank()) { binding.textLayoutCertificate.setEndIconDrawable(R.drawable.ic_outline_add_24) @@ -202,6 +214,13 @@ class AccountFragment : Fragment() { return true } + private fun updateUserPassVisibility() { + val visibility = if (binding.account!!.authType == Account.AuthType.NONE) + View.GONE else View.VISIBLE + binding.textUsername.visibility = visibility + binding.textPassword.visibility = visibility + } + private fun validateForm(clientCert: Boolean = false): Boolean { var res = true if (binding.textName.text.toString().isBlank()) { @@ -306,7 +325,7 @@ fun Slider.getSliderValueLong(): Long { } @BindingAdapter("android:text") -fun > AutoCompleteTextView.setDropdownValueEnum(newValue: T) { +fun AutoCompleteTextView.setDropdownValueProtocol(newValue: Account.Protocol) { val array = this.resources!!.getStringArray(R.array.protocol_options) val text = array[newValue.ordinal] if (this.text.toString() != text) { @@ -320,6 +339,21 @@ fun AutoCompleteTextView.getDropdownValueProtocol(): Account.Protocol { return Account.Protocol.entries[array.indexOf(this.text.toString())] } +@BindingAdapter("android:text") +fun AutoCompleteTextView.setDropdownValueAuthType(newValue: Account.AuthType) { + val array = this.resources!!.getStringArray(R.array.auth_type_options) + val text = array[newValue.ordinal] + if (this.text.toString() != text) { + this.setText(text, false) + } +} + +@InverseBindingAdapter(attribute = "android:text") +fun AutoCompleteTextView.getDropdownValueAuthType(): Account.AuthType { + val array = this.resources!!.getStringArray(R.array.auth_type_options) + return Account.AuthType.entries[array.indexOf(this.text.toString())] +} + @BindingAdapter("android:text") fun TextInputEditText.setSecretStringValue(newValue: SecretString?) { this.setText(newValue?.value) diff --git a/app/src/main/java/dev/rocli/android/webdav/provider/WebDavClient.kt b/app/src/main/java/dev/rocli/android/webdav/provider/WebDavClient.kt index eb9aa6f..5cff01c 100644 --- a/app/src/main/java/dev/rocli/android/webdav/provider/WebDavClient.kt +++ b/app/src/main/java/dev/rocli/android/webdav/provider/WebDavClient.kt @@ -249,7 +249,6 @@ class WebDavClient( builder.authenticator(CachingAuthenticatorDecorator(authenticator, authCache)) builder.addInterceptor(AuthenticationCacheInterceptor(authCache)) - } val serializer = buildSerializer() diff --git a/app/src/main/res/layout/fragment_account.xml b/app/src/main/res/layout/fragment_account.xml index 60cce81..965b5de 100644 --- a/app/src/main/res/layout/fragment_account.xml +++ b/app/src/main/res/layout/fragment_account.xml @@ -4,6 +4,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + + + + + app:layout_constraintTop_toBottomOf="@+id/layout_auth_type" + app:layout_constraintVertical_chainStyle="packed"> + app:layout_constraintTop_toBottomOf="@+id/text_layout_username" + app:layout_constraintVertical_chainStyle="packed"> - Auto HTTP/1.1 + + + None + Basic + Digest + diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 4b674fa..735c3d3 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -83,7 +83,22 @@ services: test: "curl -v --fail-with-body -X PROPFIND -H 'Depth: 1' http://127.0.0.1 || exit 1" start_period: 5s #start_interval: 1s + webdav-apache-digest: + build: ./servers/apache + environment: + - DATA_DIR=/dav/apache-digest + volumes: + - *vol-data + - "./servers/apache-digest/httpd-vhosts.conf:/usr/local/apache2/conf/extra/httpd-vhosts.conf" + - "./servers/apache-digest/.passwd:/.passwd" + ports: + - "8006:80" + healthcheck: + test: "curl -v --fail-with-body --digest -u test:test -X PROPFIND -H 'Depth: 1' http://127.0.0.1 || exit 1" + start_period: 5s + #start_interval: 1s webdav-apache-subpath: + # This container is a dependency of webdav-nginx-apache-subpath build: ./servers/apache environment: - DATA_DIR=/dav/apache-subpath diff --git a/tests/servers/apache-digest/.passwd b/tests/servers/apache-digest/.passwd new file mode 100644 index 0000000..ffff6bb --- /dev/null +++ b/tests/servers/apache-digest/.passwd @@ -0,0 +1 @@ +test:test:aeeebbfd75d1499d24388f5b9b10e0ef diff --git a/tests/servers/apache-digest/httpd-vhosts.conf b/tests/servers/apache-digest/httpd-vhosts.conf new file mode 100644 index 0000000..e02475f --- /dev/null +++ b/tests/servers/apache-digest/httpd-vhosts.conf @@ -0,0 +1,24 @@ +DavLockDB "/usr/local/apache2/var/DavLock" + + + ServerAlias * + ServerName webdav + + DocumentRoot /data + + + Options FollowSymLinks + Options Indexes + + Order allow,deny + Allow from all + Require valid-user + + AuthType Digest + AuthName "test" + AuthDigestProvider file + AuthUserFile "/.passwd" + + DAV On + + diff --git a/tests/servers/apache/Dockerfile b/tests/servers/apache/Dockerfile index b35d6b9..c15f89e 100644 --- a/tests/servers/apache/Dockerfile +++ b/tests/servers/apache/Dockerfile @@ -4,7 +4,8 @@ RUN apk add curl COPY ./run.sh / COPY ./httpd-vhosts.conf conf/extra/httpd-vhosts.conf RUN sed -i '/httpd-vhosts/s/^#//g' conf/httpd.conf \ - && sed -i '/mod_dav/s/^#//g' conf/httpd.conf + && sed -i '/mod_dav/s/^#//g' conf/httpd.conf \ + && sed -i '/mod_auth_digest/s/^#//g' conf/httpd.conf RUN mkdir -p /usr/local/apache2/var && chown daemon:daemon /usr/local/apache2/var CMD ["/run.sh"] EXPOSE 80