From 43caef7a6a5703fc4af2e529a492e68e699940c4 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: Mon, 8 Jun 2026 19:57:40 +0300 Subject: [PATCH] Prepare QSfera production release --- Android/.gitignore | 4 + Android/qsferaApp/build.gradle | 30 +- .../SettingsPictureUploadsViewModel.kt | 7 +- .../SettingsVideoUploadsViewModel.kt | 7 +- .../android/providers/WorkManagerProvider.kt | 43 +-- .../android/workers/AutomaticUploadsWorker.kt | 7 +- .../model/FolderBackUpConfiguration.kt | 1 + Android/scripts/new-argus-upload-keystore.ps1 | 96 ++++++ Android/scripts/prepare-argus-publication.ps1 | 277 ++++++++++++++++++ Android/scripts/publish-release.ps1 | 141 +++++++++ Desktop/.github/workflows/main.yml | 16 + .../scripts/validate-production-release.ps1 | 60 ++++ Desktop/src/libsync/configfile.cpp | 2 +- Desktop/test/CMakeLists.txt | 1 + Desktop/test/testconfigfile.cpp | 52 ++++ PRODUCTION_READINESS.md | 72 +++++ Server/.env.example | 10 + Server/.gitignore | 3 +- .../deployments/qsfera_full/README.md | 4 +- .../deployments/qsfera_full/decomposeds3.yml | 6 +- .../qsfera_full/env.production.example | 49 ++++ .../deployments/qsfera_full/keycloak.yml | 8 +- .../deployments/qsfera_full/minio.yml | 6 +- .../deployments/qsfera_full/qsfera.yml | 2 +- .../qsfera_full/validate-production-env.sh | 155 ++++++++++ Server/docker-compose.rpi5.yml | 35 +++ Server/docs/raspberry-pi-5-docker.md | 107 +++++++ 27 files changed, 1159 insertions(+), 42 deletions(-) create mode 100644 Android/scripts/new-argus-upload-keystore.ps1 create mode 100644 Android/scripts/prepare-argus-publication.ps1 create mode 100644 Android/scripts/publish-release.ps1 create mode 100644 Desktop/scripts/validate-production-release.ps1 create mode 100644 Desktop/test/testconfigfile.cpp create mode 100644 PRODUCTION_READINESS.md create mode 100644 Server/.env.example create mode 100644 Server/devtools/deployments/qsfera_full/env.production.example create mode 100644 Server/devtools/deployments/qsfera_full/validate-production-env.sh create mode 100644 Server/docker-compose.rpi5.yml create mode 100644 Server/docs/raspberry-pi-5-docker.md diff --git a/Android/.gitignore b/Android/.gitignore index 91c8510f..8266ae2b 100644 --- a/Android/.gitignore +++ b/Android/.gitignore @@ -12,6 +12,10 @@ # Local configuration files (sdk path, etc) local.properties +# Local Argus release signing secrets +argus-signing.local.properties +signing/ + # Mac .DS_Store files .DS_Store diff --git a/Android/qsferaApp/build.gradle b/Android/qsferaApp/build.gradle index 1782bed2..95f1bed8 100644 --- a/Android/qsferaApp/build.gradle +++ b/Android/qsferaApp/build.gradle @@ -14,6 +14,18 @@ def envValue(String primary, String legacy = null) { return legacy ? System.getenv(legacy) : null } +def releaseSigningEnv = [ + [primary: 'QSFERA_RELEASE_KEYSTORE', legacy: 'OC_RELEASE_KEYSTORE'], + [primary: 'QSFERA_RELEASE_KEYSTORE_PASSWORD', legacy: 'OC_RELEASE_KEYSTORE_PASSWORD'], + [primary: 'QSFERA_RELEASE_KEY_ALIAS', legacy: 'OC_RELEASE_KEY_ALIAS'], + [primary: 'QSFERA_RELEASE_KEY_PASSWORD', legacy: 'OC_RELEASE_KEY_PASSWORD'], +] + +def missingReleaseSigningEnvKeys = { + releaseSigningEnv.findAll { !envValue(it.primary, it.legacy) } + .collect { "${it.primary} (${it.legacy} legacy fallback)" } +} + dependencies { // Data and domain modules implementation project(':qsferaDomain') @@ -123,8 +135,8 @@ android { testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner" - versionCode = 32 - versionName = "1.3.4" + versionCode = 34 + versionName = "1.3.6" buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\"" buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\"" @@ -235,6 +247,20 @@ android { testNamespace "eu.qsfera.android.test" } +gradle.taskGraph.whenReady { taskGraph -> + def releaseBuildRequested = taskGraph.allTasks.any { task -> + def taskName = task.name.toLowerCase() + taskName.endsWith("release") && + (taskName.startsWith("assemble") || taskName.startsWith("bundle") || taskName.startsWith("package")) + } + def missingSigningEnvKeys = missingReleaseSigningEnvKeys() + if (releaseBuildRequested && !missingSigningEnvKeys.isEmpty()) { + throw new GradleException( + "Release builds must be signed. Missing env vars: ${missingSigningEnvKeys.join(', ')}" + ) + } +} + // Updates output file names of a given variant to format // [appName].[variant.versionName].[QSFERA_BUILD_NUMBER]-[variant.name].apk. // diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt index e3d61d75..b35f7f35 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsPictureUploadsViewModel.kt @@ -30,6 +30,7 @@ import eu.qsfera.android.R import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase @@ -181,7 +182,7 @@ class SettingsPictureUploadsViewModel( SavePictureUploadsConfigurationUseCase.Params( composePictureUploadsConfiguration( sourcePath = encodeSourcePaths(updatedSourcePaths), - timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths } + timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths } ) ) ) @@ -220,9 +221,9 @@ class SettingsPictureUploadsViewModel( behavior = behavior ?: UploadBehavior.COPY, sourcePath = sourcePath.orEmpty(), uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH, - wifiOnly = wifiOnly ?: false, + wifiOnly = wifiOnly ?: true, chargingOnly = chargingOnly ?: false, - lastSyncTimestamp = timestamp ?: System.currentTimeMillis(), + lastSyncTimestamp = timestamp ?: initialSyncTimestamp, name = _pictureUploads.value?.name ?: pictureUploadsName, spaceId = spaceId, ).also { diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt index 17488b3a..056e4bba 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/SettingsVideoUploadsViewModel.kt @@ -30,6 +30,7 @@ import eu.qsfera.android.R import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths +import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase @@ -181,7 +182,7 @@ class SettingsVideoUploadsViewModel( SaveVideoUploadsConfigurationUseCase.Params( composeVideoUploadsConfiguration( sourcePath = encodeSourcePaths(updatedSourcePaths), - timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths } + timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths } ) ) ) @@ -221,9 +222,9 @@ class SettingsVideoUploadsViewModel( behavior = behavior ?: UploadBehavior.COPY, sourcePath = sourcePath.orEmpty(), uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH, - wifiOnly = wifiOnly ?: false, + wifiOnly = wifiOnly ?: true, chargingOnly = chargingOnly ?: false, - lastSyncTimestamp = timestamp ?: System.currentTimeMillis(), + lastSyncTimestamp = timestamp ?: initialSyncTimestamp, name = _videoUploads.value?.name ?: videoUploadsName, spaceId = spaceId, ).also { diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt index 771db03f..dbe7f961 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/providers/WorkManagerProvider.kt @@ -42,6 +42,9 @@ import eu.qsfera.android.workers.OldLogsCollectorWorker import eu.qsfera.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker import eu.qsfera.android.workers.UploadFileFromContentUriWorker import eu.qsfera.android.workers.UploadFileFromFileSystemWorker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.TimeUnit @@ -90,28 +93,30 @@ class WorkManagerProvider( * concurrent scans or redundant enqueues on rapid foreground/background switches. */ fun enqueueImmediateAutomaticUploadsWorker() { - val wm = WorkManager.getInstance(context) + CoroutineScope(Dispatchers.IO).launch { + val wm = WorkManager.getInstance(context) - val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER) - .get().any { it.state == WorkInfo.State.RUNNING } - val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) - .get().any { it.state == WorkInfo.State.RUNNING } + val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER) + .get().any { it.state == WorkInfo.State.RUNNING } + val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) + .get().any { it.state == WorkInfo.State.RUNNING } - if (periodicRunning || immediateRunning) { - Timber.d("Automatic uploads worker already running, skipping immediate run") - return + if (periodicRunning || immediateRunning) { + Timber.d("Automatic uploads worker already running, skipping immediate run") + return@launch + } + + val immediateWorker = OneTimeWorkRequestBuilder() + .addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) + .setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS) + .build() + + wm.enqueueUniqueWork( + AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER, + ExistingWorkPolicy.KEEP, + immediateWorker + ) } - - val immediateWorker = OneTimeWorkRequestBuilder() - .addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER) - .setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS) - .build() - - wm.enqueueUniqueWork( - AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER, - ExistingWorkPolicy.KEEP, - immediateWorker - ) } fun enqueueOldLogsCollectorWorker() { diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt index f5afeb82..6d62b4de 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt @@ -128,8 +128,13 @@ class AutomaticUploadsWorker( private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List) { sourcePaths.forEach { sourcePath -> val sourceUri: Uri = sourcePath.toUri() - DocumentFile.fromTreeUri(applicationContext, sourceUri) + val hasPersistedReadPermission = appContext.contentResolver.persistedUriPermissions + .any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission } + val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri) ?: throw IllegalArgumentException("Source path is not a valid tree URI: $sourcePath") + if (!hasPersistedReadPermission || !documentTree.canRead()) { + throw IllegalArgumentException("Source path is not readable: $sourcePath") + } } } diff --git a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt index 36d1aab5..cad26843 100644 --- a/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt +++ b/Android/qsferaDomain/src/main/java/eu/qsfera/android/domain/automaticuploads/model/FolderBackUpConfiguration.kt @@ -38,6 +38,7 @@ data class FolderBackUpConfiguration( companion object { const val pictureUploadsName = "Picture uploads" const val videoUploadsName = "Video uploads" + const val initialSyncTimestamp = 0L fun parseSourcePaths(sourcePath: String): List = sourcePath diff --git a/Android/scripts/new-argus-upload-keystore.ps1 b/Android/scripts/new-argus-upload-keystore.ps1 new file mode 100644 index 00000000..01516eb5 --- /dev/null +++ b/Android/scripts/new-argus-upload-keystore.ps1 @@ -0,0 +1,96 @@ +param( + [string]$KeystorePath = 'signing/argus-upload.jks', + [string]$PropertiesPath = 'argus-signing.local.properties', + [string]$KeyAlias = 'argus-upload', + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function New-Secret { + $value = ((1..3 | ForEach-Object { [Guid]::NewGuid().ToString('N') }) -join '') + return $value.Substring(0, 32) +} + +function Resolve-JavaHome { + $candidates = @( + $env:JAVA_HOME, + 'C:\Program Files\Android\Android Studio\jbr', + 'C:\Program Files\Android\openjdk\jdk-21.0.8' + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + foreach ($candidate in $candidates) { + $keytool = Join-Path $candidate 'bin\keytool.exe' + if (Test-Path $keytool) { + return $candidate + } + } + + throw 'keytool.exe was not found. Set JAVA_HOME to a JDK path.' +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$resolvedKeystorePath = if ([System.IO.Path]::IsPathRooted($KeystorePath)) { + $KeystorePath +} else { + Join-Path $repoRoot $KeystorePath +} +$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) { + $PropertiesPath +} else { + Join-Path $repoRoot $PropertiesPath +} + +if ((Test-Path $resolvedKeystorePath) -and (Test-Path $resolvedPropertiesPath) -and -not $Force) { + Write-Output 'Keystore and properties already exist.' + Write-Output "Keystore: $resolvedKeystorePath" + Write-Output "Properties: $resolvedPropertiesPath" + exit 0 +} + +if (((Test-Path $resolvedKeystorePath) -xor (Test-Path $resolvedPropertiesPath)) -and -not $Force) { + throw 'Keystore and properties are in a partial state. Use -Force to recreate the pair.' +} + +$keystoreDirectory = Split-Path -Parent $resolvedKeystorePath +if (-not (Test-Path $keystoreDirectory)) { + New-Item -ItemType Directory -Path $keystoreDirectory | Out-Null +} + +$storePassword = New-Secret +$keyPassword = $storePassword +$javaHome = Resolve-JavaHome +$keytoolPath = Join-Path $javaHome 'bin\keytool.exe' + +if (Test-Path $resolvedKeystorePath) { + Remove-Item $resolvedKeystorePath -Force +} + +& $keytoolPath ` + -genkeypair ` + -keystore $resolvedKeystorePath ` + -storetype PKCS12 ` + -storepass $storePassword ` + -keypass $keyPassword ` + -alias $KeyAlias ` + -keyalg RSA ` + -keysize 4096 ` + -validity 10000 ` + -dname 'CN=QSfera Argus Upload, O=QSfera, C=RU' | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw "keytool failed with exit code $LASTEXITCODE." +} + +$propertiesContent = @( + "QSFERA_RELEASE_KEYSTORE=$resolvedKeystorePath" + "QSFERA_RELEASE_KEYSTORE_PASSWORD=$storePassword" + "QSFERA_RELEASE_KEY_ALIAS=$KeyAlias" + "QSFERA_RELEASE_KEY_PASSWORD=$keyPassword" +) -join [Environment]::NewLine + +[System.IO.File]::WriteAllText($resolvedPropertiesPath, $propertiesContent, [System.Text.Encoding]::ASCII) + +Write-Output 'Created QSfera Argus upload keystore.' +Write-Output "Keystore: $resolvedKeystorePath" +Write-Output "Properties: $resolvedPropertiesPath" diff --git a/Android/scripts/prepare-argus-publication.ps1 b/Android/scripts/prepare-argus-publication.ps1 new file mode 100644 index 00000000..2766ba9f --- /dev/null +++ b/Android/scripts/prepare-argus-publication.ps1 @@ -0,0 +1,277 @@ +param( + [string]$Slug = 'qsfera-mobile', + [string]$Name, + [string]$Summary = 'Android client for QSfera server.', + [string]$Description = 'Android client for QSfera server, prepared for Argus publication.', + [string]$Version, + [string]$Channel = 'stable', + [string]$Platform = 'android', + [string]$Notes, + [string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/QSfera.git', + [string]$HomepageUrl = 'https://qsfera.kusoft.xyz', + [switch]$Unlisted, + [switch]$SkipSigningInitialization +) + +$ErrorActionPreference = 'Stop' + +function Require-Tool([string]$Path, [string]$Label) { + if (-not (Test-Path $Path)) { + throw "$Label was not found: $Path" + } +} + +function Get-ApkBadgingValue([string]$BadgingText, [string]$Pattern) { + $match = [regex]::Match($BadgingText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) + if (-not $match.Success) { + return $null + } + return $match.Groups[1].Value +} + +function Read-Properties([string]$Path) { + $result = @{} + foreach ($line in Get-Content $Path) { + $trimmed = $line.Trim() + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) { + continue + } + + $separator = $trimmed.IndexOf('=') + if ($separator -le 0) { + continue + } + + $key = $trimmed.Substring(0, $separator).Trim() + $value = $trimmed.Substring($separator + 1).Trim() + $result[$key] = $value + } + return $result +} + +function Resolve-JavaHome { + $candidates = @( + $env:JAVA_HOME, + 'C:\Program Files\Android\Android Studio\jbr', + 'C:\Program Files\Android\openjdk\jdk-21.0.8' + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + foreach ($candidate in $candidates) { + $java = Join-Path $candidate 'bin\java.exe' + if (Test-Path $java) { + return $candidate + } + } + + throw 'java.exe was not found. Set JAVA_HOME to a JDK path.' +} + +function Resolve-AndroidHome { + $candidates = @( + $env:ANDROID_HOME, + "$env:LOCALAPPDATA\Android\Sdk" + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + foreach ($candidate in $candidates) { + if (Test-Path (Join-Path $candidate 'build-tools')) { + return $candidate + } + } + + throw 'Android SDK was not found. Set ANDROID_HOME to the SDK path.' +} + +function Resolve-ArgusPlatform([string[]]$NativeCodes) { + $normalized = $NativeCodes | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() } + $allUniversalAbis = @('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64') + if (@($allUniversalAbis | Where-Object { $normalized -contains $_ }).Count -eq $allUniversalAbis.Count) { + return 'android-universal' + } + if ($normalized -contains 'arm64-v8a') { + return 'android-arm64' + } + if ($normalized -contains 'armeabi-v7a') { + return 'android-armv7' + } + if ($normalized -contains 'x86_64') { + return 'android-x86_64' + } + if ($normalized -contains 'x86') { + return 'android-x86' + } + return 'android' +} + +function Find-FirstTool([string]$AndroidHome, [string]$ToolName) { + $tool = Get-ChildItem -Path (Join-Path $AndroidHome 'build-tools') -Recurse -File -Filter $ToolName | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $tool) { + throw "$ToolName was not found under Android build-tools." + } + return $tool.FullName +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +$javaHome = Resolve-JavaHome +$androidHome = Resolve-AndroidHome +$env:JAVA_HOME = $javaHome +$env:ANDROID_HOME = $androidHome + +$gradlePath = Join-Path $repoRoot 'gradlew.bat' +$aaptPath = Find-FirstTool $androidHome 'aapt.exe' +$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat' +$signingPropertiesPath = Join-Path $repoRoot 'argus-signing.local.properties' +$signingInitScript = Join-Path $PSScriptRoot 'new-argus-upload-keystore.ps1' + +Require-Tool $gradlePath 'Gradle wrapper' +Require-Tool $aaptPath 'aapt' +Require-Tool $apksignerPath 'apksigner' +Require-Tool $signingInitScript 'Argus signing init script' + +if (-not (Test-Path $signingPropertiesPath)) { + if ($SkipSigningInitialization) { + throw "Signing properties were not found: $signingPropertiesPath" + } + & $signingInitScript + if ($LASTEXITCODE -ne 0) { + throw "Signing initialization failed with exit code $LASTEXITCODE." + } +} + +$signingProperties = Read-Properties $signingPropertiesPath +foreach ($key in @( + 'QSFERA_RELEASE_KEYSTORE', + 'QSFERA_RELEASE_KEYSTORE_PASSWORD', + 'QSFERA_RELEASE_KEY_ALIAS', + 'QSFERA_RELEASE_KEY_PASSWORD' +)) { + if (-not $signingProperties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($signingProperties[$key])) { + throw "Signing property is missing: $key" + } +} + +$env:QSFERA_RELEASE_KEYSTORE = $signingProperties['QSFERA_RELEASE_KEYSTORE'] +$env:QSFERA_RELEASE_KEYSTORE_PASSWORD = $signingProperties['QSFERA_RELEASE_KEYSTORE_PASSWORD'] +$env:QSFERA_RELEASE_KEY_ALIAS = $signingProperties['QSFERA_RELEASE_KEY_ALIAS'] +$env:QSFERA_RELEASE_KEY_PASSWORD = $signingProperties['QSFERA_RELEASE_KEY_PASSWORD'] + +& $gradlePath :qsferaApp:assembleOriginalRelease --console=plain +if ($LASTEXITCODE -ne 0) { + throw "Gradle release build failed with exit code $LASTEXITCODE." +} + +$outputMetadataPath = Join-Path $repoRoot 'qsferaApp\build\outputs\apk\original\release\output-metadata.json' +if (-not (Test-Path $outputMetadataPath)) { + throw "Release output metadata was not found: $outputMetadataPath" +} + +$outputMetadata = Get-Content $outputMetadataPath -Raw | ConvertFrom-Json +$outputFile = $outputMetadata.elements[0].outputFile +$releaseApkPath = Join-Path $repoRoot ("qsferaApp\build\outputs\apk\original\release\{0}" -f $outputFile) +if (-not (Test-Path $releaseApkPath)) { + throw "Release APK was not found: $releaseApkPath" +} +if ($releaseApkPath -like '*-unsigned.apk') { + throw "Release APK is unsigned: $releaseApkPath" +} + +$badging = & $aaptPath dump badging $releaseApkPath +if ($LASTEXITCODE -ne 0) { + throw "aapt badging failed with exit code $LASTEXITCODE." +} +$badgingText = ($badging | Out-String) +$packageName = Get-ApkBadgingValue $badgingText "package: name='([^']+)'" +$versionCode = Get-ApkBadgingValue $badgingText "versionCode='([^']+)'" +$versionName = Get-ApkBadgingValue $badgingText "versionName='([^']+)'" +$targetSdk = Get-ApkBadgingValue $badgingText "targetSdkVersion:'([^']+)'" +$applicationLabel = Get-ApkBadgingValue $badgingText "application-label:'([^']+)'" +$nativeCodeLine = ($badging | Where-Object { $_ -like 'native-code:*' } | Select-Object -First 1) +$nativeCodes = if ($nativeCodeLine) { + [regex]::Matches($nativeCodeLine, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value } +} else { + @() +} +$detectedPlatform = Resolve-ArgusPlatform $nativeCodes +$resolvedPlatform = if ([string]::IsNullOrWhiteSpace($Platform)) { + $detectedPlatform +} else { + $Platform.Trim() +} + +if (-not $packageName) { + throw 'Package name could not be extracted from APK.' +} +if ($packageName -ne 'eu.qsfera.android') { + throw "Unexpected package name: $packageName" +} +if (-not $versionCode) { + throw 'Version code could not be extracted from APK.' +} +if (-not $versionName) { + throw 'Version name could not be extracted from APK.' +} +if (-not $targetSdk) { + throw 'Target SDK could not be extracted from APK.' +} + +$certOutput = & $apksignerPath verify --print-certs $releaseApkPath +if ($LASTEXITCODE -ne 0) { + throw "apksigner verification failed with exit code $LASTEXITCODE." +} +$certText = ($certOutput | Out-String) +$certificateSha256 = Get-ApkBadgingValue $certText "SHA-256 digest: ([0-9a-fA-F]+)" +if ($certText -match 'CN=Android Debug' -or $certificateSha256 -eq 'b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941') { + throw 'Release APK is signed with the Android debug certificate.' +} + +$resolvedName = if ($Name) { $Name } elseif ($applicationLabel) { $applicationLabel } else { 'QSfera' } +$releaseVersion = if ($Version) { $Version } else { $versionName } +$stageDirectory = Join-Path $repoRoot ("build\argus-publication\v{0}" -f $versionCode) +if (-not (Test-Path $stageDirectory)) { + New-Item -ItemType Directory -Path $stageDirectory | Out-Null +} + +$stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode +$stagedApkPath = Join-Path $stageDirectory $stagedApkName +Copy-Item $releaseApkPath $stagedApkPath -Force + +$metadata = [ordered]@{ + packagePath = $stagedApkPath + slug = $Slug + name = $resolvedName + summary = $Summary + description = $Description + version = $releaseVersion + channel = $Channel + platform = $resolvedPlatform + packageKind = 'apk' + notes = if ($Notes) { $Notes } else { $null } + repositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { $null } + homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null } + androidPackageName = $packageName + androidVersionCode = [int]$versionCode + targetSdkVersion = [int]$targetSdk + isListed = (-not $Unlisted) + verifiedFromApk = [ordered]@{ + outputFile = $releaseApkPath + packageName = $packageName + versionCode = [int]$versionCode + versionName = $versionName + targetSdkVersion = [int]$targetSdk + nativeCodes = $nativeCodes + detectedPlatform = $detectedPlatform + applicationLabel = if ($applicationLabel) { $applicationLabel } else { $null } + certificateSha256 = $certificateSha256 + verifiedAt = [DateTimeOffset]::UtcNow.ToString('O') + } +} + +$metadataPath = Join-Path $stageDirectory 'argus-publication.json' +$metadata | ConvertTo-Json -Depth 6 | Set-Content -Path $metadataPath -Encoding UTF8 + +Write-Output 'Prepared QSfera Argus publication files.' +Write-Output "APK: $stagedApkPath" +Write-Output "Metadata: $metadataPath" diff --git a/Android/scripts/publish-release.ps1 b/Android/scripts/publish-release.ps1 new file mode 100644 index 00000000..015bde3c --- /dev/null +++ b/Android/scripts/publish-release.ps1 @@ -0,0 +1,141 @@ +param( + [string]$BaseUrl = 'https://argus.kusoft.xyz', + [Parameter(Mandatory = $true)] + [string]$Username, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [string]$Slug, + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Summary, + [Parameter(Mandatory = $true)] + [string]$Description, + [Parameter(Mandatory = $true)] + [string]$AndroidPackageName, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [ValidateRange(1, [int]::MaxValue)] + [int]$AndroidVersionCode, + [Parameter(Mandatory = $true)] + [string]$PackagePath, + [string]$Channel = 'stable', + [string]$Platform = 'android', + [string]$PackageKind = 'apk', + [string]$Notes, + [string]$RepositoryUrl, + [string]$HomepageUrl = 'https://qsfera.kusoft.xyz', + [switch]$Unlisted +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http + +function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) { + if ($null -eq $Response.Content) { + return '' + } + + return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() +} + +function Ensure-Success( + [System.Net.Http.HttpResponseMessage]$Response, + [string]$Operation +) { + if ($Response.IsSuccessStatusCode) { + return + } + + $body = Read-HttpBody $Response + throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body" +} + +function New-JsonContent([string]$Json) { + return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json') +} + +$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path +if (-not (Test-Path $resolvedPackagePath)) { + throw "Package file was not found: $resolvedPackagePath" +} + +$baseUri = $BaseUrl.TrimEnd('/') +$cookieContainer = [System.Net.CookieContainer]::new() +$handler = [System.Net.Http.HttpClientHandler]::new() +$handler.CookieContainer = $cookieContainer +$handler.UseCookies = $true +$client = [System.Net.Http.HttpClient]::new($handler) +$client.Timeout = [TimeSpan]::FromMinutes(15) + +try { + $loginPayload = @{ + username = $Username + password = $Password + } | ConvertTo-Json -Compress + + $loginResponse = $client.PostAsync( + "$baseUri/api/admin/session/login", + (New-JsonContent $loginPayload) + ).GetAwaiter().GetResult() + Ensure-Success $loginResponse 'Argus login' + + $appPayload = @{ + name = $Name + summary = $Summary + description = $Description + androidPackageName = $AndroidPackageName + repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl } + homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl } + isListed = (-not $Unlisted) + } | ConvertTo-Json -Compress + + $appResponse = $client.PutAsync( + "$baseUri/api/admin/apps/$Slug", + (New-JsonContent $appPayload) + ).GetAwaiter().GetResult() + Ensure-Success $appResponse 'Argus app metadata update' + + $form = [System.Net.Http.MultipartFormDataContent]::new() + $fields = [ordered]@{ + Version = $Version + Channel = $Channel + Platform = $Platform + PackageKind = $PackageKind + AndroidVersionCode = [string]$AndroidVersionCode + } + + foreach ($entry in $fields.GetEnumerator()) { + $form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key) + } + + if (-not [string]::IsNullOrWhiteSpace($Notes)) { + $form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes') + } + + $fileStream = [System.IO.File]::OpenRead($resolvedPackagePath) + try { + $fileContent = [System.Net.Http.StreamContent]::new($fileStream) + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive') + $form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath)) + + $releaseResponse = $client.PostAsync( + "$baseUri/api/admin/apps/$Slug/releases", + $form + ).GetAwaiter().GetResult() + Ensure-Success $releaseResponse 'Argus release upload' + + $releaseBody = Read-HttpBody $releaseResponse + if (-not [string]::IsNullOrWhiteSpace($releaseBody)) { + Write-Output $releaseBody + } + } finally { + $fileStream.Dispose() + $form.Dispose() + } +} finally { + $client.Dispose() + $handler.Dispose() +} diff --git a/Desktop/.github/workflows/main.yml b/Desktop/.github/workflows/main.yml index 36b6c54a..3910de0c 100644 --- a/Desktop/.github/workflows/main.yml +++ b/Desktop/.github/workflows/main.yml @@ -57,6 +57,8 @@ jobs: CRAFT_TARGET: ${{ matrix.target }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CRAFT_PACKAGE_SYMBOLS: ${{ github.event_name != 'pull_request' }} + SIGN_PACKAGE: ${{ secrets.QSFERA_DESKTOP_SIGN_PACKAGE }} + CRAFT_CODESIGN_CERTIFICATE: ${{ secrets.QSFERA_DESKTOP_CODESIGN_CERTIFICATE }} container: ${{ matrix.container }} @@ -188,11 +190,25 @@ jobs: - name: Package if: matrix.target != 'macos-clang-arm64' run: | + $requireSigning = "${{ matrix.os }}" -eq "windows-latest" -and "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta" + if ("${{ matrix.os }}" -eq "windows-latest") { + $validationArgs = @() + if ($requireSigning) { + $validationArgs += "-RequireSigning" + } + & "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs + } & "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package opencloud/opencloud-desktop - name: Package Appx if: ${{ matrix.os == 'windows-latest' && github.event_name != 'pull_request' }} run: | + $requireSigning = "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta" + $validationArgs = @() + if ($requireSigning) { + $validationArgs += "-RequireSigning" + } + & "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs & "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package --options "[Packager]PackageType=AppxPackager" --options "[Packager]Destination=${{ github.workspace }}/appx/" opencloud/opencloud-desktop - name: Prepare artifacts diff --git a/Desktop/scripts/validate-production-release.ps1 b/Desktop/scripts/validate-production-release.ps1 new file mode 100644 index 00000000..92369d33 --- /dev/null +++ b/Desktop/scripts/validate-production-release.ps1 @@ -0,0 +1,60 @@ +param( + [switch]$RequireSigning, + [string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +) + +$ErrorActionPreference = "Stop" + +function Fail($Message) { + Write-Error $Message + exit 1 +} + +function Read-Text($RelativePath) { + Get-Content -LiteralPath (Join-Path $Root $RelativePath) -Raw -Encoding UTF8 +} + +$branding = Read-Text "QSFERA.cmake" +$cmake = Read-Text "CMakeLists.txt" +$craft = Read-Text ".craft.ini" +$expectedAppName = -join ([char[]](0x041a, 0x0443, 0x0421, 0x0444, 0x0435, 0x0440, 0x0430)) + +if (-not $branding.Contains("APPLICATION_NAME") -or -not $branding.Contains($expectedAppName)) { + Fail "Desktop branding must use the production Cyrillic application name." +} + +if ($branding -notmatch 'APPLICATION_EXECUTABLE\s+"qsfera"') { + Fail "Desktop executable must remain `"qsfera`" for the production profile." +} + +if ($branding -notmatch 'APPLICATION_REV_DOMAIN\s+"eu\.qsfera\.desktop"') { + Fail "Desktop reverse-domain id must remain eu.qsfera.desktop for the production profile." +} + +if ($branding -notmatch 'APPLICATION_DEFAULT_SERVER_URL\s+"https://qsfera\.kusoft\.xyz"') { + Fail "Desktop default server URL must be https://qsfera.kusoft.xyz." +} + +if ($cmake -notmatch 'option\(WITH_UPDATE_NOTIFICATION\s+"Whether to check for new releases"\s+ON\)') { + Fail "Desktop release builds must keep update notifications enabled by default." +} + +if ($cmake -notmatch 'VIRTUAL_FILE_SYSTEM_PLUGINS\s+off\s+cfapi\s+openvfs') { + Fail "Desktop release builds must include the Windows CfAPI VFS plugin." +} + +if ($craft -notmatch 'CodeSigning/Enabled\s*=\s*\$\{Env:SIGN_PACKAGE\}') { + Fail "Craft code-signing must be controlled by SIGN_PACKAGE." +} + +if ($RequireSigning) { + if ($env:SIGN_PACKAGE -notin @("True", "true", "1", "yes", "YES")) { + Fail "Production Windows tag releases require SIGN_PACKAGE=True." + } + + if ([string]::IsNullOrWhiteSpace($env:CRAFT_CODESIGN_CERTIFICATE)) { + Fail "Production Windows tag releases require CRAFT_CODESIGN_CERTIFICATE." + } +} + +Write-Host "QSfera Desktop production release validation passed." diff --git a/Desktop/src/libsync/configfile.cpp b/Desktop/src/libsync/configfile.cpp index ab5e0aeb..8a961771 100644 --- a/Desktop/src/libsync/configfile.cpp +++ b/Desktop/src/libsync/configfile.cpp @@ -331,7 +331,7 @@ void ConfigFile::setSkipUpdateCheck(bool skip) QString ConfigFile::updateChannel() const { auto settings = makeQSettings(); - return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"stable"_s : u"beta"_s).toString(); + return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"beta"_s : u"stable"_s).toString(); } void ConfigFile::setUpdateChannel(const QString &channel) diff --git a/Desktop/test/CMakeLists.txt b/Desktop/test/CMakeLists.txt index d4bf97b4..47182ee7 100644 --- a/Desktop/test/CMakeLists.txt +++ b/Desktop/test/CMakeLists.txt @@ -13,6 +13,7 @@ qsfera_add_test(ConcatUrl) qsfera_add_test(XmlParse) qsfera_add_test(ChecksumValidator) qsfera_add_test(ConnectionValidator) +qsfera_add_test(ConfigFile) # TODO: we need keychain access for this test diff --git a/Desktop/test/testconfigfile.cpp b/Desktop/test/testconfigfile.cpp new file mode 100644 index 00000000..0d85d778 --- /dev/null +++ b/Desktop/test/testconfigfile.cpp @@ -0,0 +1,52 @@ +/* + * Copyright (C) QSfera + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include "configfile.h" +#include "common/version.h" + +#include +#include +#include + +using namespace OCC; +using namespace Qt::Literals::StringLiterals; + +class TestConfigFile : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void init() + { + QVERIFY(_configDir.isValid()); + QVERIFY(ConfigFile::setConfDir(_configDir.path())); + QFile::remove(ConfigFile::configFile()); + } + + void testDefaultUpdateChannelMatchesBuildChannel() + { + const auto expectedChannel = Version::isBeta() ? u"beta"_s : u"stable"_s; + QCOMPARE(ConfigFile().updateChannel(), expectedChannel); + } + + void testStoredUpdateChannelOverridesBuildDefault() + { + ConfigFile().setUpdateChannel(u"beta"_s); + QCOMPARE(ConfigFile().updateChannel(), u"beta"_s); + + ConfigFile().setUpdateChannel(u"stable"_s); + QCOMPARE(ConfigFile().updateChannel(), u"stable"_s); + } + +private: + QTemporaryDir _configDir; +}; + +QTEST_MAIN(TestConfigFile) +#include "testconfigfile.moc" diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md new file mode 100644 index 00000000..8b993b2b --- /dev/null +++ b/PRODUCTION_READINESS.md @@ -0,0 +1,72 @@ +# QSfera Production Readiness + +Date: 2026-06-08. + +## Benchmarks Used + +- Google Photos Android backup: automatic photo/video backup, selected device folders, backup status, mobile-data limits, roaming controls, and battery optimization guidance. + Source: https://support.google.com/photos/answer/6193313?co=GENIE.Platform%3DAndroid&hl=en +- Google Drive Android offline files and sharing roles. + Sources: https://support.google.com/drive/answer/2375012?co=GENIE.Platform%3DAndroid&hl=en and https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DAndroid&hl=en +- Google storage quota behavior across Drive and Photos. + Source: https://support.google.com/drive/answer/9312312?hl=en +- Google Drive for desktop: tray entry, multi-account sign-in, sync status, notifications, offline files, pause/resume sync, adding folders, and stream/mirror model. + Sources: https://support.google.com/drive/answer/10838124?co=GENIE.Platform%3DDesktop&hl=en-fm and https://support.google.com/drive/answer/13401938?hl=en +- Yandex Disk Android auto-upload setup, account choice, network choice, source folders, and uploaded/not-uploaded indicators. + Source: https://yandex.com/support/yandex-360/customers/disk/app/android/en/autoupload/setup +- Yandex Disk mobile link sharing controls: view-only, disable downloads, password, expiration, and organization-only links. + Source: https://yandex.com/support/yandex-360/customers/disk/app/android/en/actions/share +- Yandex Disk desktop: Windows/macOS desktop app, selective sync, auto-save folders, automatic update, autorun, and sleep-prevention behavior during sync. + Sources: https://www.yandex.com/support/yandex-360/customers/disk/web/en/desktop, https://www.yandex.com/support/yandex-360/customers/disk/desktop/windows/en/reserve-copy, and https://yandex.com/support/yandex-360/customers/disk/desktop/windows/en/installation + +## Completed In This Pass + +- Android release builds now fail fast when release signing variables are missing, so unsigned release artifacts cannot be published by accident. +- Android version was raised to `1.3.6` with `versionCode` `34`. +- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and Argus upload. +- Android `1.3.6`/`34` was published to Argus as `qsfera-mobile` on 2026-06-08. Public manifest and latest download both report SHA-256 `921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428`. +- Android auto-upload source paths are normalized and new/first source folders start from timestamp `0`, so existing media is eligible for initial backup. +- Android auto-upload validates persisted read permission and `DocumentFile.canRead()` before scanning SAF folders. +- Android immediate auto-upload scheduling no longer blocks the caller while waiting for WorkManager state. +- Android new auto-upload configurations default to Wi-Fi-only transfers for photos and videos. +- 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. +- Server `qsfera_full/validate-production-env.sh` checks production `.env` invariants before compose validation: TLS validation enabled, demo users disabled, pinned production image, required domains, required secrets, and no public-production MinIO. +- Server now has a Raspberry Pi 5 Docker path matching the Ikar/Argus deployment style: `Server/docker-compose.rpi5.yml`, `Server/.env.example`, and `Server/docs/raspberry-pi-5-docker.md`. +- Windows/Desktop update channel defaults now match the build channel: stable builds default to `stable`, beta builds default to `beta`. +- Windows/Desktop CI now validates production release invariants before Windows packaging and requires signing variables for stable tag releases. + +## Raspberry Pi 5 Check + +- Pi target checked on 2026-06-08: `192.168.0.185` reports `aarch64`, 64-bit Debian 13, Docker `29.4.3`, Docker Compose `v5.1.3`, and Docker server architecture `aarch64`. +- Current QSfera server is already running on that Pi as `qsfera-cloud-server:rpi5-local`, with `Arch=arm64`, bind-mounted config/data under `/mnt/data/qsfera/cloud-server`, and loopback port `127.0.0.1:9200`. +- `https://qsfera.kusoft.xyz/status.php` returned HTTP `200` during the check. +- The Pi root filesystem is nearly full: `/dev/mmcblk0p2` reported `57G` size, `52G` used, `2.4G` available, `96%` use. `/mnt/data` reported `916G` size, `18G` used, `853G` available, `2%` use. +- This is deployable on the Pi, but I do not count it as fully production-ready yet because the running image is a local/dev build (`qsfera-cloud-server:rpi5-local`, external status reports `edition: dev`) rather than a pinned production release image. + +## Android Argus Publication Check + +- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `bf2c78ad-7a26-459c-93f9-42b54c039279`, version `1.3.6`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `34`, package size `12217032`, and SHA-256 `921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428`. +- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12217032` byte APK with the same SHA-256 `921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428`. +- The previously published `1.3.5`/`33` APK matched Argus SHA-256 `b7d5bdcb49527c00983a15849252fa42189dee4287754803c46cf2f32e2130f3`, but `apksigner` reported `CN=Android Debug` with certificate SHA-256 `b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941`. +- The new `1.3.6`/`34` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`. +- `AppUpdateManager` validates downloaded APK package name, greater `versionCode`, Argus `androidVersionCode`, and signer digest against the installed app signature. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the production-signed `1.3.6`. + +## Remaining P0 + +- Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from `1.3.6` cannot be signed with the same certificate. +- Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`. +- Decide the rollout policy for any devices that installed the debug-signed Argus `1.3.5` APK. The production-signed `1.3.6` APK is the correct current release, but installed debug-signed clients will not satisfy the app's own signer-digest check for an in-place update. +- Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values. +- Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified. +- For the Pi deployment, switch the running service from the direct Docker container to `Server/docker-compose.rpi5.yml` only after backing up `/mnt/data/qsfera/cloud-server/config` and `/mnt/data/qsfera/cloud-server/data`. +- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference. +- Configure and verify the desktop updater endpoint via `APPLICATION_UPDATE_URL`; `Desktop/scripts/validate-production-release.ps1` confirms update notifications are enabled but cannot prove a live update feed without that URL. + +## Remaining P1 + +- Add Google Photos-style backup status UX: overall status, per-item status, queued/paused/permanent-failure states, and low-storage messaging. +- Add mobile-data controls comparable to Google Photos/Yandex Disk beyond the Wi-Fi-only default: mobile-data limit, no-video-over-mobile-data, and no-roaming options. +- Store auto-upload sync cursors per source folder rather than one timestamp for all folders. +- Add production monitoring runbook and compose verification for metrics, tracing, alerts, backups, and restore drills. +- Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance. diff --git a/Server/.env.example b/Server/.env.example new file mode 100644 index 00000000..8ea35668 --- /dev/null +++ b/Server/.env.example @@ -0,0 +1,10 @@ +QSFERA_RPI_IMAGE=qsfera-cloud-server:rpi5-local +QSFERA_RPI_HOST_PORT=9200 +QSFERA_RPI_CONFIG_PATH=/mnt/data/qsfera/cloud-server/config +QSFERA_RPI_DATA_PATH=/mnt/data/qsfera/cloud-server/data + +OC_URL=https://qsfera.example.com +OC_LOG_LEVEL=info +OC_INSECURE=false +PROXY_TLS=true +FRONTEND_CHECK_FOR_UPDATES=false diff --git a/Server/.gitignore b/Server/.gitignore index a6bd4639..1da14703 100644 --- a/Server/.gitignore +++ b/Server/.gitignore @@ -61,8 +61,9 @@ protogen/buf.sha1.lock go.work go.work.sum .env +.env.server .envrc .DS_Store # example deployments -**/qsfera-sandbox-* \ No newline at end of file +**/qsfera-sandbox-* diff --git a/Server/devtools/deployments/qsfera_full/README.md b/Server/devtools/deployments/qsfera_full/README.md index addce2f3..86367876 100644 --- a/Server/devtools/deployments/qsfera_full/README.md +++ b/Server/devtools/deployments/qsfera_full/README.md @@ -15,4 +15,6 @@ This deployment example is documented in two locations for different audiences: Providing details which are more developer focused. This description can also be used when deviating from the default.\ Note that this examples uses self signed certificates and is intended for testing purposes. - +For production rollouts from this compose directory, use `env.production.example` as the starting point for `.env`. +It keeps TLS validation enabled, disables demo users, and requires explicit admin, Keycloak, and S3 secrets instead of demo defaults. +Run `./validate-production-env.sh .env` before `docker compose config` and rollout. diff --git a/Server/devtools/deployments/qsfera_full/decomposeds3.yml b/Server/devtools/deployments/qsfera_full/decomposeds3.yml index 33933825..cca20be4 100644 --- a/Server/devtools/deployments/qsfera_full/decomposeds3.yml +++ b/Server/devtools/deployments/qsfera_full/decomposeds3.yml @@ -9,6 +9,6 @@ services: # decomposeds3 specific settings STORAGE_USERS_DECOMPOSEDS3_ENDPOINT: ${DECOMPOSEDS3_ENDPOINT:-http://minio:9000} STORAGE_USERS_DECOMPOSEDS3_REGION: ${DECOMPOSEDS3_REGION:-default} - STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera} - STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key} - STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:-qsfera-bucket} + STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting decomposeds3 in production} + STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting decomposeds3 in production} + STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting decomposeds3 in production} diff --git a/Server/devtools/deployments/qsfera_full/env.production.example b/Server/devtools/deployments/qsfera_full/env.production.example new file mode 100644 index 00000000..81567501 --- /dev/null +++ b/Server/devtools/deployments/qsfera_full/env.production.example @@ -0,0 +1,49 @@ +## Production-safe qsfera_full environment template. +## Copy to .env and fill every placeholder before running docker compose. + +COMPOSE_PATH_SEPARATOR=: +QSFERA=:qsfera.yml + +## Public deployment identity. +OC_DOMAIN= +TRAEFIK_ACME_MAIL= + +## Use the production image and pin a release tag for repeatable rollouts. +OC_DOCKER_IMAGE=qsfera/qsfera +OC_DOCKER_TAG= + +## Production must validate TLS certificates and must not create demo users. +INSECURE=false +DEMO_USERS=false + +## Required by qsfera.yml. +ADMIN_PASSWORD= + +## Optional: enable Keycloak-backed production identity. +## Uncomment KEYCLOAK and fill all values before enabling it. +#KEYCLOAK=:keycloak.yml +KEYCLOAK_DOMAIN= +KEYCLOAK_REALM=qsfera +KEYCLOAK_POSTGRES_PASSWORD= +KEYCLOAK_ADMIN_USER= +KEYCLOAK_ADMIN_PASSWORD= + +## Optional: enable S3-backed user storage. +## Uncomment DECOMPOSEDS3 and fill all values before enabling it. +#DECOMPOSEDS3=:decomposeds3.yml +DECOMPOSEDS3_ENDPOINT= +DECOMPOSEDS3_REGION= +DECOMPOSEDS3_ACCESS_KEY= +DECOMPOSEDS3_SECRET_KEY= +DECOMPOSEDS3_BUCKET= + +## Optional: local MinIO is intended for test/lab installs, not public production. +#DECOMPOSEDS3_MINIO=:minio.yml +#MINIO_DOMAIN= + +## Optional: use host paths for backup-friendly config and data storage. +#OC_CONFIG_DIR=/srv/qsfera/config +#OC_DATA_DIR=/srv/qsfera/data + +## Keep this last: it assembles the selected compose files. +COMPOSE_FILE=docker-compose.yml${QSFERA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${KEYCLOAK:-} diff --git a/Server/devtools/deployments/qsfera_full/keycloak.yml b/Server/devtools/deployments/qsfera_full/keycloak.yml index aa95b64d..f1ca81f3 100644 --- a/Server/devtools/deployments/qsfera_full/keycloak.yml +++ b/Server/devtools/deployments/qsfera_full/keycloak.yml @@ -33,7 +33,7 @@ services: environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak - POSTGRES_PASSWORD: keycloak + POSTGRES_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production} logging: driver: ${LOG_DRIVER:-local} restart: always @@ -53,10 +53,10 @@ services: KC_DB: postgres KC_DB_URL: "jdbc:postgresql://postgres:5432/keycloak" KC_DB_USERNAME: keycloak - KC_DB_PASSWORD: keycloak + KC_DB_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production} KC_FEATURES: impersonation - KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin} - KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:?Set KEYCLOAK_ADMIN_USER before starting Keycloak in production} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD before starting Keycloak in production} labels: - "traefik.enable=true" - "traefik.http.routers.keycloak.entrypoints=https" diff --git a/Server/devtools/deployments/qsfera_full/minio.yml b/Server/devtools/deployments/qsfera_full/minio.yml index f30b9a0f..4536b279 100644 --- a/Server/devtools/deployments/qsfera_full/minio.yml +++ b/Server/devtools/deployments/qsfera_full/minio.yml @@ -10,13 +10,13 @@ services: command: [ "-c", - "mkdir -p /data/${DECOMPOSEDS3_BUCKET:-qsfera-bucket} && minio server --console-address ':9001' /data", + "mkdir -p /data/${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting MinIO in production} && minio server --console-address ':9001' /data", ] volumes: - minio-data:/data environment: - MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera} - MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key} + MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting MinIO in production} + MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting MinIO in production} labels: - "traefik.enable=true" - "traefik.http.routers.minio.entrypoints=https" diff --git a/Server/devtools/deployments/qsfera_full/qsfera.yml b/Server/devtools/deployments/qsfera_full/qsfera.yml index c63a5ce4..0306521f 100644 --- a/Server/devtools/deployments/qsfera_full/qsfera.yml +++ b/Server/devtools/deployments/qsfera_full/qsfera.yml @@ -33,7 +33,7 @@ services: # basic auth (not recommended, but needed for eg. WebDav clients that do not support OpenID Connect) PROXY_ENABLE_BASIC_AUTH: "${PROXY_ENABLE_BASIC_AUTH:-false}" # admin user password - IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:-admin}" # this overrides the admin password from the configuration file + IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:?Set ADMIN_PASSWORD before starting qsfera_full in production}" # this overrides the admin password from the configuration file # demo users IDM_CREATE_DEMO_USERS: "${DEMO_USERS:-false}" # idp login form settings diff --git a/Server/devtools/deployments/qsfera_full/validate-production-env.sh b/Server/devtools/deployments/qsfera_full/validate-production-env.sh new file mode 100644 index 00000000..8ccdb293 --- /dev/null +++ b/Server/devtools/deployments/qsfera_full/validate-production-env.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env sh +set -eu + +ENV_FILE="${1:-.env}" +errors=0 +warnings=0 + +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: env file not found: $ENV_FILE" >&2 + exit 1 +fi + +value() { + key="$1" + awk -v key="$key" ' + /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + { + line = $0 + sub(/\r$/, "", line) + split(line, parts, "=") + current = parts[1] + gsub(/^[ \t]+|[ \t]+$/, "", current) + if (current == key) { + sub(/^[^=]*=/, "", line) + gsub(/^"/, "", line) + gsub(/"$/, "", line) + gsub(/^'\''/, "", line) + gsub(/'\''$/, "", line) + print line + exit + } + } + ' "$ENV_FILE" +} + +is_placeholder() { + case "${1:-}" in + ""|"<"*">"|*example.com*|"change-me"|"changeme"|"password"|"secret") + return 0 + ;; + *) + return 1 + ;; + esac +} + +error() { + errors=$((errors + 1)) + echo "ERROR: $1" >&2 +} + +warn() { + warnings=$((warnings + 1)) + echo "WARN: $1" >&2 +} + +require_value() { + key="$1" + label="${2:-$1}" + current="$(value "$key")" + if is_placeholder "$current"; then + error "$label must be set to a production value." + fi +} + +require_secret() { + key="$1" + current="$(value "$key")" + if is_placeholder "$current"; then + error "$key must be set." + return + fi + case "$current" in + admin|demo|keycloak|qsfera|qsfera-secret-key) + error "$key uses a known demo/default value." + return + ;; + esac + if [ "${#current}" -lt 16 ]; then + error "$key must be at least 16 characters." + fi +} + +enabled() { + current="$(value "$1")" + [ -n "$current" ] +} + +require_value OC_DOMAIN "OC_DOMAIN" +require_value TRAEFIK_ACME_MAIL "TRAEFIK_ACME_MAIL" +require_secret ADMIN_PASSWORD + +case "$(value INSECURE)" in + true|TRUE|1|yes|YES) + error "INSECURE must be false for production." + ;; +esac + +case "$(value DEMO_USERS)" in + true|TRUE|1|yes|YES) + error "DEMO_USERS must be false for production." + ;; +esac + +case "$(value OC_DOCKER_IMAGE)" in + qsfera/qsfera) + ;; + "") + warn "OC_DOCKER_IMAGE is empty; compose default must be verified before rollout." + ;; + *) + error "OC_DOCKER_IMAGE must be qsfera/qsfera for production." + ;; +esac + +case "$(value OC_DOCKER_TAG)" in + ""|latest|"<"*">") + error "OC_DOCKER_TAG must pin a concrete production release tag." + ;; +esac + +if ! enabled QSFERA; then + error "QSFERA=:qsfera.yml must be enabled." +fi + +if enabled DECOMPOSEDS3; then + require_value DECOMPOSEDS3_ENDPOINT "DECOMPOSEDS3_ENDPOINT" + require_value DECOMPOSEDS3_REGION "DECOMPOSEDS3_REGION" + require_secret DECOMPOSEDS3_ACCESS_KEY + require_secret DECOMPOSEDS3_SECRET_KEY + require_value DECOMPOSEDS3_BUCKET "DECOMPOSEDS3_BUCKET" +fi + +if enabled DECOMPOSEDS3_MINIO; then + error "DECOMPOSEDS3_MINIO is for test/lab installs and must not be enabled for public production." +fi + +if enabled KEYCLOAK; then + require_value KEYCLOAK_DOMAIN "KEYCLOAK_DOMAIN" + require_value KEYCLOAK_REALM "KEYCLOAK_REALM" + require_secret KEYCLOAK_POSTGRES_PASSWORD + require_value KEYCLOAK_ADMIN_USER "KEYCLOAK_ADMIN_USER" + require_secret KEYCLOAK_ADMIN_PASSWORD +fi + +if [ -z "$(value OC_CONFIG_DIR)" ] || [ -z "$(value OC_DATA_DIR)" ]; then + warn "OC_CONFIG_DIR and OC_DATA_DIR are not both set; docker volumes are harder to back up and restore." +fi + +if [ "$errors" -gt 0 ]; then + echo "Production env validation failed: $errors error(s), $warnings warning(s)." >&2 + exit 1 +fi + +echo "Production env validation passed: $warnings warning(s)." diff --git a/Server/docker-compose.rpi5.yml b/Server/docker-compose.rpi5.yml new file mode 100644 index 00000000..c83f2757 --- /dev/null +++ b/Server/docker-compose.rpi5.yml @@ -0,0 +1,35 @@ +services: + qsfera-cloud-server: + container_name: qsfera-cloud-server + image: ${QSFERA_RPI_IMAGE:-qsfera-cloud-server:rpi5-local} + platform: linux/arm64 + build: + context: . + dockerfile: Dockerfile + args: + TARGETARCH: arm64 + restart: unless-stopped + entrypoint: + - /bin/sh + command: + - -c + - qsfera init || true; exec qsfera server + environment: + OC_LOG_LEVEL: ${OC_LOG_LEVEL:-info} + FRONTEND_CHECK_FOR_UPDATES: ${FRONTEND_CHECK_FOR_UPDATES:-false} + OC_CONFIG_DIR: /etc/qsfera + OC_BASE_DATA_PATH: /var/lib/qsfera + OC_URL: ${OC_URL:?OC_URL must be set in .env} + OC_INSECURE: ${OC_INSECURE:-false} + PROXY_TLS: ${PROXY_TLS:-true} + ports: + - "127.0.0.1:${QSFERA_RPI_HOST_PORT:-9200}:9200" + volumes: + - ${QSFERA_RPI_CONFIG_PATH:?QSFERA_RPI_CONFIG_PATH must be set in .env}:/etc/qsfera + - ${QSFERA_RPI_DATA_PATH:?QSFERA_RPI_DATA_PATH must be set in .env}:/var/lib/qsfera + healthcheck: + test: ["CMD-SHELL", "curl -kfsS https://127.0.0.1:9200/status.php >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s diff --git a/Server/docs/raspberry-pi-5-docker.md b/Server/docs/raspberry-pi-5-docker.md new file mode 100644 index 00000000..f7316284 --- /dev/null +++ b/Server/docs/raspberry-pi-5-docker.md @@ -0,0 +1,107 @@ +# Raspberry Pi 5 Docker Run + +This server repository contains a Dockerized QSfera path for Raspberry Pi 5 and other +64-bit ARM Linux hosts. + +Files used for this deployment: + +- `Dockerfile` +- `docker-compose.rpi5.yml` +- `.env.example` + +## What This Runs + +- QSfera server from this `Server` source tree. +- QSfera config persisted under `/etc/qsfera` inside the container. +- QSfera data persisted under `/var/lib/qsfera` inside the container. +- Host-side config and data paths controlled by `QSFERA_RPI_CONFIG_PATH` and + `QSFERA_RPI_DATA_PATH`. +- Host-side backend port bound only to `127.0.0.1:9200`; external access should go + through the reverse proxy and HTTPS domain. + +## Prerequisites On Raspberry Pi 5 + +1. Install Docker Engine using the official Docker docs: `https://docs.docker.com/engine/install/debian/`. +2. Install the Docker Compose plugin using the official Docker docs: `https://docs.docker.com/compose/install/`. +3. Use a 64-bit OS. The compose file sets `platform: linux/arm64`, and the Dockerfile builds with `TARGETARCH=arm64`. +4. Put `QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` on durable storage, not on a nearly full SD-card root filesystem. +5. Set `OC_URL` to the public HTTPS URL used by clients and the reverse proxy. + +## Start + +Run from the `Server` directory: + +```bash +cp .env.example .env +docker compose -f docker-compose.rpi5.yml up -d --build +``` + +## Stop + +```bash +docker compose -f docker-compose.rpi5.yml down +``` + +## Logs + +```bash +docker compose -f docker-compose.rpi5.yml logs -f qsfera-cloud-server +``` + +## Update After Pulling New Code + +```bash +docker compose -f docker-compose.rpi5.yml up -d --build +``` + +## Verify + +Local backend status endpoint: + +```bash +curl -k https://127.0.0.1:9200/status.php +``` + +External reverse-proxied status endpoint: + +```bash +curl https://qsfera.example.com/status.php +``` + +## Data Location + +Docker bind mounts: + +- host config: `${QSFERA_RPI_CONFIG_PATH}` +- container config: `/etc/qsfera` +- host data: `${QSFERA_RPI_DATA_PATH}` +- container data: `/var/lib/qsfera` + +For the current Raspberry Pi deployment inspected on 2026-06-08, the existing +container uses: + +- host config: `/mnt/data/qsfera/cloud-server/config` +- host data: `/mnt/data/qsfera/cloud-server/data` +- public URL: `https://qsfera.kusoft.xyz` +- loopback port: `127.0.0.1:9200` + +## Reverse Proxy + +The server is intentionally bound to loopback. Put Caddy, Nginx, Traefik, or another +reverse proxy in front of it for external HTTPS access. + +The inspected Raspberry Pi uses Caddy with this QSfera route: + +```caddyfile +qsfera.kusoft.xyz { + encode zstd gzip + handle { + reverse_proxy https://127.0.0.1:9200 { + transport http { + tls_insecure_skip_verify + versions 1.1 + } + } + } +} +```