@bota.dev/react-native-app-sdk 2.0.0-beta.1 → 2.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -4
- package/android/build.gradle +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +86 -3
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
- package/generated/codegen-contract.json +751 -32
- package/ios/BotaDeviceSDK.mm +114 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
- package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
- package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
- package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
- package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
- package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
- package/ios/BotaUploadV2Backend.mm +49 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
- package/ios/UploadBackend/UploadCoordinator.swift +84 -0
- package/ios/UploadBackend/UploadHTTP.swift +82 -0
- package/ios/UploadBackend/UploadJournal.swift +187 -0
- package/ios/UploadBackend/UploadModels.swift +273 -0
- package/ios/UploadBackend/UploadOperation.swift +391 -0
- package/lib/commonjs/BotaClient.js +16 -2
- package/lib/commonjs/BotaClient.js.map +1 -1
- package/lib/commonjs/ble/constants.js +5 -1
- package/lib/commonjs/ble/constants.js.map +1 -1
- package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
- package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
- package/lib/commonjs/client.js +183 -28
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/clientPresence.js +26 -0
- package/lib/commonjs/clientPresence.js.map +1 -0
- package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
- package/lib/commonjs/index.js +34 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +23 -0
- package/lib/commonjs/managers/DeviceManager.js.map +1 -1
- package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
- package/lib/commonjs/managers/RecordingManager.js +83 -102
- package/lib/commonjs/managers/RecordingManager.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/commonjs/sdkIdentity.js +10 -0
- package/lib/commonjs/sdkIdentity.js.map +1 -0
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/commonjs/uploadRecovery.js +68 -0
- package/lib/commonjs/uploadRecovery.js.map +1 -0
- package/lib/module/BotaClient.js +16 -2
- package/lib/module/BotaClient.js.map +1 -1
- package/lib/module/ble/constants.js +4 -0
- package/lib/module/ble/constants.js.map +1 -1
- package/lib/module/ble/deviceDiagnostics.js +234 -0
- package/lib/module/ble/deviceDiagnostics.js.map +1 -0
- package/lib/module/client.js +183 -28
- package/lib/module/client.js.map +1 -1
- package/lib/module/clientPresence.js +22 -0
- package/lib/module/clientPresence.js.map +1 -0
- package/lib/module/encryptedUploadV2Backend.js +196 -0
- package/lib/module/encryptedUploadV2Backend.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +23 -0
- package/lib/module/managers/DeviceManager.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +380 -0
- package/lib/module/managers/NativeUploadQueue.js.map +1 -0
- package/lib/module/managers/RecordingManager.js +83 -102
- package/lib/module/managers/RecordingManager.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
- package/lib/module/sdkIdentity.js +6 -0
- package/lib/module/sdkIdentity.js.map +1 -0
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/module/uploadRecovery.js +64 -0
- package/lib/module/uploadRecovery.js.map +1 -0
- package/lib/typescript/BotaClient.d.ts +2 -0
- package/lib/typescript/BotaClient.d.ts.map +1 -1
- package/lib/typescript/ble/constants.d.ts +4 -0
- package/lib/typescript/ble/constants.d.ts.map +1 -1
- package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
- package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
- package/lib/typescript/client.d.ts +24 -3
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/clientPresence.d.ts +26 -0
- package/lib/typescript/clientPresence.d.ts.map +1 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +13 -4
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +5 -1
- package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
- package/lib/typescript/managers/RecordingManager.d.ts +3 -8
- package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
- package/lib/typescript/managers/types.d.ts +6 -1
- package/lib/typescript/managers/types.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
- package/lib/typescript/models/Device.d.ts +42 -0
- package/lib/typescript/models/Device.d.ts.map +1 -1
- package/lib/typescript/models/Recording.d.ts +47 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/models/Status.d.ts +5 -1
- package/lib/typescript/models/Status.d.ts.map +1 -1
- package/lib/typescript/sdkIdentity.d.ts +3 -0
- package/lib/typescript/sdkIdentity.d.ts.map +1 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/uploadRecovery.d.ts +27 -0
- package/lib/typescript/uploadRecovery.d.ts.map +1 -0
- package/package.json +1 -1
- package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
- package/src/BotaClient.ts +17 -3
- package/src/ble/constants.ts +5 -0
- package/src/ble/deviceDiagnostics.ts +238 -0
- package/src/client.ts +189 -18
- package/src/clientPresence.ts +33 -0
- package/src/encryptedUploadV2Backend.ts +171 -0
- package/src/index.ts +24 -0
- package/src/managers/DeviceManager.ts +23 -0
- package/src/managers/NativeUploadQueue.ts +330 -0
- package/src/managers/RecordingManager.ts +83 -116
- package/src/managers/types.ts +7 -1
- package/src/managers/uploadRecoveryMetadata.ts +59 -0
- package/src/models/Device.ts +43 -0
- package/src/models/Recording.ts +52 -0
- package/src/models/Status.ts +5 -1
- package/src/sdkIdentity.ts +3 -0
- package/src/specs/NativeBotaDeviceSDK.ts +66 -0
- package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
- package/src/uploadRecovery.ts +66 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
package dev.bota.sdk.reactnative.upload
|
|
2
|
+
|
|
3
|
+
import dev.bota.sdk.EncryptedUploadV2ContextExchange
|
|
4
|
+
import dev.bota.sdk.EncryptedUploadV2Material
|
|
5
|
+
import dev.bota.sdk.EncryptedUploadV2SecurityPolicy
|
|
6
|
+
import dev.bota.sdk.EncryptedUploadV2TransferEvidence
|
|
7
|
+
import dev.bota.sdk.reactnative.BotaDeviceSDKEncryptedUploadV2Materials
|
|
8
|
+
import java.time.Instant
|
|
9
|
+
import java.util.UUID
|
|
10
|
+
import kotlinx.coroutines.NonCancellable
|
|
11
|
+
import kotlinx.coroutines.delay
|
|
12
|
+
import kotlinx.coroutines.sync.Mutex
|
|
13
|
+
import kotlinx.coroutines.sync.withLock
|
|
14
|
+
import kotlinx.coroutines.withContext
|
|
15
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
16
|
+
import okhttp3.Request
|
|
17
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
|
|
20
|
+
internal class NativeUpload(
|
|
21
|
+
private val journal: UploadJournal,
|
|
22
|
+
private val http: UploadHttp,
|
|
23
|
+
private val credentials: suspend (UploadOperation) -> String,
|
|
24
|
+
private val readNonce: suspend (String) -> ByteArray,
|
|
25
|
+
private val now: () -> Long = System::currentTimeMillis,
|
|
26
|
+
private val wait: suspend (Long, UploadOperation) -> Unit = { milliseconds, op ->
|
|
27
|
+
op.check(); delay(milliseconds); op.check()
|
|
28
|
+
},
|
|
29
|
+
) {
|
|
30
|
+
private val operations = mutableMapOf<String, Attempt>()
|
|
31
|
+
private val usedOperations = mutableSetOf<String>()
|
|
32
|
+
private val journalOwners = mutableSetOf<String>()
|
|
33
|
+
|
|
34
|
+
suspend fun prepare(text: String): Map<String, Any> {
|
|
35
|
+
val input = UploadInput.parse(text)
|
|
36
|
+
val attempt = synchronized(this) {
|
|
37
|
+
demand(input.operationId !in usedOperations && input.journalKey !in journalOwners, "BUSY")
|
|
38
|
+
usedOperations.add(input.operationId)
|
|
39
|
+
journalOwners.add(input.journalKey)
|
|
40
|
+
Attempt(UploadOperation(input)).also { operations[input.operationId] = it }
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return attempt.prepare()
|
|
44
|
+
} catch (error: Exception) {
|
|
45
|
+
cancel(input.operationId)
|
|
46
|
+
synchronized(this) { journalOwners.remove(input.journalKey); operations.remove(input.operationId) }
|
|
47
|
+
if (error is UploadFailure) throw error
|
|
48
|
+
fail("NATIVE")
|
|
49
|
+
} finally {
|
|
50
|
+
attempt.preparing = false
|
|
51
|
+
if (attempt.cancelled) synchronized(this) {
|
|
52
|
+
journalOwners.remove(input.journalKey)
|
|
53
|
+
operations.remove(input.operationId)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fun cancel(operationId: String) {
|
|
59
|
+
val attempt = synchronized(this) {
|
|
60
|
+
usedOperations.add(operationId)
|
|
61
|
+
operations[operationId]
|
|
62
|
+
} ?: return
|
|
63
|
+
attempt.cancelled = true
|
|
64
|
+
attempt.operation.cancel()
|
|
65
|
+
synchronized(attempt.operation) {
|
|
66
|
+
attempt.operation.registrationId?.let { BotaDeviceSDKEncryptedUploadV2Materials.remove(it) }
|
|
67
|
+
attempt.operation.registrationId = null
|
|
68
|
+
}
|
|
69
|
+
if (!attempt.preparing) synchronized(this) {
|
|
70
|
+
journalOwners.remove(attempt.input.journalKey)
|
|
71
|
+
operations.remove(operationId)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fun cancelAll() { synchronized(this) { operations.keys.toList() }.forEach(::cancel) }
|
|
76
|
+
|
|
77
|
+
suspend fun complete(operationId: String) {
|
|
78
|
+
val attempt = synchronized(this) { operations[operationId] } ?: fail("NOT_COMPLETE")
|
|
79
|
+
attempt.callbacks.withLock {
|
|
80
|
+
attempt.operation.active {
|
|
81
|
+
// Only the parent calls complete after the SDK's successful receipt/CONFIRM result.
|
|
82
|
+
demand(attempt.receiptDelivered && !attempt.preparing, "NOT_COMPLETE")
|
|
83
|
+
journal.remove(attempt.input, attempt.pointer ?: fail("NOT_COMPLETE"))
|
|
84
|
+
}
|
|
85
|
+
cancel(operationId)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private inner class Attempt(val operation: UploadOperation) {
|
|
90
|
+
val input = operation.input
|
|
91
|
+
val callbacks = Mutex()
|
|
92
|
+
@Volatile var preparing = true
|
|
93
|
+
@Volatile var cancelled = false
|
|
94
|
+
var pointer: UploadPointer? = null
|
|
95
|
+
var receiptDelivered = false
|
|
96
|
+
private var session: JSONObject? = null
|
|
97
|
+
private var uploadCiphertext = true
|
|
98
|
+
private var sendManifest = true
|
|
99
|
+
private var receipt: ByteArray? = null
|
|
100
|
+
private var importedPriorSha256: String? = null
|
|
101
|
+
private var authorizationNonceSha256: String? = null
|
|
102
|
+
private var recoveryNonceBase64: String? = null
|
|
103
|
+
private val base = input.scope.apiBasePath
|
|
104
|
+
private val contexts = "$base/devices/${input.scope.deviceId}/encrypted-upload-v2/contexts"
|
|
105
|
+
private fun sessions() = "$base/recordings/${pointer!!.recordingId}/encrypted-upload-v2/sessions"
|
|
106
|
+
private fun sessionPath() = "${sessions()}/${pointer!!.sessionId}"
|
|
107
|
+
|
|
108
|
+
private fun save(value: UploadPointer, nonceHash: String? = authorizationNonceSha256, recoveryNonce: String? = null) {
|
|
109
|
+
journal.save(input, JournalEntry(input.identitySha256, value, importedPriorSha256 = importedPriorSha256,
|
|
110
|
+
authorizationNonceSha256 = nonceHash, recoveryNonceBase64 = recoveryNonce))
|
|
111
|
+
pointer = value
|
|
112
|
+
authorizationNonceSha256 = nonceHash
|
|
113
|
+
recoveryNonceBase64 = recoveryNonce
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private suspend fun request(path: String, body: JSONObject? = null, retain: Boolean = false,
|
|
117
|
+
beforeDispatch: () -> Unit = {}): JSONObject {
|
|
118
|
+
operation.check()
|
|
119
|
+
val token = credentials(operation)
|
|
120
|
+
operation.check()
|
|
121
|
+
val headers = mutableMapOf("Authorization" to "Bearer $token",
|
|
122
|
+
"Accept" to "application/json", "Content-Type" to "application/json")
|
|
123
|
+
if (input.scope.organizationId.isNotEmpty()) headers["X-Organization-Id"] = input.scope.organizationId
|
|
124
|
+
val result = http.execute(UploadRequest(input.scope.apiOrigin, path, if (body == null) "GET" else "POST", headers, body),
|
|
125
|
+
operation, retain, beforeDispatch)
|
|
126
|
+
if (!retain) operation.check()
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private suspend fun createRequest(path: String, body: JSONObject, phase: String): JSONObject {
|
|
131
|
+
val previous = pointer?.let { JournalEntry(input.identitySha256, it, importedPriorSha256 = importedPriorSha256,
|
|
132
|
+
authorizationNonceSha256 = authorizationNonceSha256) }
|
|
133
|
+
val marker = JournalEntry(input.identitySha256, pointer, phase, importedPriorSha256, authorizationNonceSha256)
|
|
134
|
+
var marked = false
|
|
135
|
+
try {
|
|
136
|
+
return request(path, body, true) {
|
|
137
|
+
demand(journal.load(input) == previous, "IDENTITY_CONFLICT")
|
|
138
|
+
journal.save(input, marker)
|
|
139
|
+
marked = true
|
|
140
|
+
}
|
|
141
|
+
} catch (error: UploadFailure) {
|
|
142
|
+
if (marked && error.code in setOf("BOTA_UPLOAD_HTTP_401", "BOTA_UPLOAD_HTTP_403")) {
|
|
143
|
+
// A retained rejection settles its originating journal even after cancellation.
|
|
144
|
+
synchronized(this@NativeUpload) {
|
|
145
|
+
demand(preparing && operations[input.operationId] === this && input.journalKey in journalOwners, "IDENTITY_CONFLICT")
|
|
146
|
+
journal.rollbackRejectedCreate(input, marker, previous)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
operation.check()
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private suspend fun <T> retry(block: suspend () -> T): T {
|
|
155
|
+
for (attempt in 0..2) {
|
|
156
|
+
operation.check()
|
|
157
|
+
try { return block().also { operation.check() } }
|
|
158
|
+
catch (error: UploadFailure) {
|
|
159
|
+
operation.check()
|
|
160
|
+
val status = error.code.removePrefix("BOTA_UPLOAD_HTTP_").toIntOrNull()
|
|
161
|
+
if (attempt == 2 || !(error.code == "BOTA_UPLOAD_TRANSPORT" || status == 429 || (status != null && status >= 500))) throw error
|
|
162
|
+
wait(250, operation)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
fail("NATIVE")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private suspend fun nonce(): String {
|
|
169
|
+
operation.check()
|
|
170
|
+
val bytes = readNonce(input.operationId)
|
|
171
|
+
operation.check()
|
|
172
|
+
demand(bytes.size == 16, "INVALID_DOCUMENT")
|
|
173
|
+
return encoded(bytes)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
suspend fun prepare(): Map<String, Any> {
|
|
177
|
+
operation.check()
|
|
178
|
+
val saved = journal.load(input)
|
|
179
|
+
if (saved != null) {
|
|
180
|
+
demand(saved.phase == "ready", "UNKNOWN_OUTCOME")
|
|
181
|
+
pointer = saved.pointer
|
|
182
|
+
importedPriorSha256 = saved.importedPriorSha256
|
|
183
|
+
authorizationNonceSha256 = saved.authorizationNonceSha256
|
|
184
|
+
recoveryNonceBase64 = saved.recoveryNonceBase64
|
|
185
|
+
} else if (input.prior != null) {
|
|
186
|
+
input.validateCheckpoint(input.prior)
|
|
187
|
+
importedPriorSha256 = input.prior.fingerprint()
|
|
188
|
+
operation.active { save(input.prior) }
|
|
189
|
+
}
|
|
190
|
+
input.validateCheckpoint(pointer)
|
|
191
|
+
if (pointer == null) {
|
|
192
|
+
val body = JSONObject().put("device_id", input.scope.deviceId).put("end_user_id", input.scope.endUserId)
|
|
193
|
+
.put("upload_method", "ble").put("streaming", false).put("metadata", JSONObject()
|
|
194
|
+
.put("started_at", Instant.ofEpochMilli(input.recording.startedAtMs).toString())
|
|
195
|
+
.put("ended_at", Instant.ofEpochMilli(input.recording.startedAtMs + input.recording.durationMs).toString()))
|
|
196
|
+
// No automatic retry: an unknown initial outcome must remain durable across process restart.
|
|
197
|
+
withContext(NonCancellable) {
|
|
198
|
+
val response = createRequest("$base/recordings", body, "recording_create_unknown")
|
|
199
|
+
val id = UploadPointer.parse(JSONObject().put("recordingId", response.string("id")))
|
|
200
|
+
save(id)
|
|
201
|
+
}
|
|
202
|
+
operation.check()
|
|
203
|
+
}
|
|
204
|
+
if (pointer!!.sessionId == null) {
|
|
205
|
+
val authNonce = nonce()
|
|
206
|
+
val body = JSONObject().put("device_id", input.scope.deviceId).put("binding_generation", input.scope.bindingGeneration)
|
|
207
|
+
.put("recording_uuid", input.recording.uuid).put("recording_generation", input.recording.generation)
|
|
208
|
+
.put("storage_format", "bota_enc_v2").put("channel", "ble").put("capabilities_base64", encoded(input.capability))
|
|
209
|
+
.put("auth_nonce_base64", authNonce).put("ciphertext_length", input.recording.ciphertextLength)
|
|
210
|
+
.put("ciphertext_sha256", input.recording.ciphertextSha256)
|
|
211
|
+
withContext(NonCancellable) {
|
|
212
|
+
val created = createRequest(sessions(), body, "session_create_unknown")
|
|
213
|
+
save(responsePointer(created), digest(opaque(authNonce, 16)))
|
|
214
|
+
operation.check()
|
|
215
|
+
session = created
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
val visited = mutableSetOf(pointer!!.sessionId!!)
|
|
219
|
+
var replacements = 0
|
|
220
|
+
while (true) {
|
|
221
|
+
val status = request(sessionPath())
|
|
222
|
+
validateIdentity(status)
|
|
223
|
+
val state = status.string("state")
|
|
224
|
+
val expiry = timestamp(status.string("expires_at"))
|
|
225
|
+
val expired = state == "expired" || (state in setOf("created", "staging", "staged") && expiry <= now())
|
|
226
|
+
val authNonce = recoveryNonceBase64 ?: if (expired || (authorizationNonceSha256 != null && state in setOf("created", "staging", "staged"))) nonce() else null
|
|
227
|
+
val nonceHash = authNonce?.let { digest(opaque(it, 16)) }
|
|
228
|
+
val nonceChanged = authorizationNonceSha256 != null && nonceHash != null && nonceHash != authorizationNonceSha256
|
|
229
|
+
if (recoveryNonceBase64 == null && !expired && !nonceChanged) { applyStatus(status); session = status; break }
|
|
230
|
+
demand(input.flags and 0x37f == 0x37fL, "EXPIRED")
|
|
231
|
+
demand(replacements < 3, "RECOVERY_LIMIT")
|
|
232
|
+
val parent = pointer!!
|
|
233
|
+
val path = sessionPath() + "/recover"
|
|
234
|
+
val body = JSONObject().put("owner_revision", parent.ownerRevision).put("auth_nonce_base64", authNonce)
|
|
235
|
+
.put("capabilities_base64", encoded(input.capability))
|
|
236
|
+
if (!expired) body.put("reason", "nonce_changed")
|
|
237
|
+
// Retrying a lost response must use the nonce of the original request.
|
|
238
|
+
save(parent, recoveryNonce = authNonce)
|
|
239
|
+
retry {
|
|
240
|
+
withContext(NonCancellable) {
|
|
241
|
+
val recovered = responsePointer(request(path, body, true))
|
|
242
|
+
demand(recovered.ownerRevision!! > parent.ownerRevision!! && recovered.sessionId !in visited, "IDENTITY_CONFLICT")
|
|
243
|
+
save(recovered, nonceHash)
|
|
244
|
+
visited.add(recovered.sessionId!!)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
replacements++
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
operation.check()
|
|
251
|
+
val selected = session!!
|
|
252
|
+
val policy = selected.string("policy")
|
|
253
|
+
val nativePolicy = when (policy) {
|
|
254
|
+
"legacy_allowed" -> EncryptedUploadV2SecurityPolicy.LegacyAllowed
|
|
255
|
+
"v2_preferred" -> EncryptedUploadV2SecurityPolicy.V2Preferred
|
|
256
|
+
"v2_required" -> EncryptedUploadV2SecurityPolicy.V2Required
|
|
257
|
+
else -> fail("INVALID_DOCUMENT")
|
|
258
|
+
}
|
|
259
|
+
timestamp(selected.string("expires_at"))
|
|
260
|
+
val authorization = opaque(selected.string("authorization_base64"), 408, selected.string("authorization_sha256"))
|
|
261
|
+
val material = EncryptedUploadV2Material(
|
|
262
|
+
materialId = UUID.randomUUID().toString(), recordingId = input.recording.uuid,
|
|
263
|
+
uploadSessionId = UUID.fromString(pointer!!.sessionId), ownerRevision = pointer!!.ownerRevision!!.toUInt(),
|
|
264
|
+
policy = nativePolicy, authorization = authorization,
|
|
265
|
+
stagingRequest = { evidence -> callbacks.withLock { staging(evidence) } },
|
|
266
|
+
submitManifest = { bytes, evidence -> callbacks.withLock { manifest(bytes, evidence) } },
|
|
267
|
+
finalize = { evidence -> callbacks.withLock { finalize(evidence) } },
|
|
268
|
+
completionReceipt = { evidence -> callbacks.withLock {
|
|
269
|
+
verifyEvidence(evidence)
|
|
270
|
+
val bytes = receipt ?: fail("NOT_COMPLETE")
|
|
271
|
+
receiptDelivered = true
|
|
272
|
+
bytes.copyOf()
|
|
273
|
+
} },
|
|
274
|
+
cancel = { this@NativeUpload.cancel(input.operationId) },
|
|
275
|
+
uploadContext = { nonce -> context(nonce) },
|
|
276
|
+
shouldUploadCiphertext = { evidence -> callbacks.withLock { verifyEvidence(evidence); uploadCiphertext } },
|
|
277
|
+
reconcileStaging = { bytes, evidence -> callbacks.withLock { reconcileStaging(bytes, evidence) } },
|
|
278
|
+
)
|
|
279
|
+
return operation.active {
|
|
280
|
+
val registration = BotaDeviceSDKEncryptedUploadV2Materials.register(material)
|
|
281
|
+
operation.registrationId = registration
|
|
282
|
+
mapOf("profile" to "encrypted_upload_v2", "uploadSessionId" to pointer!!.sessionId!!,
|
|
283
|
+
"ownerRevision" to pointer!!.ownerRevision!!, "securityPolicy" to policy,
|
|
284
|
+
"materialRegistrationId" to registration, "recordingId" to pointer!!.recordingId)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private fun responsePointer(value: JSONObject): UploadPointer {
|
|
289
|
+
demand(value.string("profile") == "encrypted_upload_v2", "IDENTITY_CONFLICT")
|
|
290
|
+
return UploadPointer.parse(JSONObject().put("recordingId", pointer!!.recordingId)
|
|
291
|
+
.put("sessionId", value.string("session_id")).put("ownerRevision", value.integer("owner_revision", 1, Int.MAX_VALUE.toLong())))
|
|
292
|
+
}
|
|
293
|
+
private fun validateIdentity(value: JSONObject) {
|
|
294
|
+
demand(responsePointer(value) == pointer && value.string("channel") == "ble" &&
|
|
295
|
+
value.integer("ciphertext_length", 1) == input.recording.ciphertextLength &&
|
|
296
|
+
value.string("ciphertext_sha256") == input.recording.ciphertextSha256, "IDENTITY_CONFLICT")
|
|
297
|
+
}
|
|
298
|
+
private fun applyStatus(value: JSONObject) {
|
|
299
|
+
validateIdentity(value)
|
|
300
|
+
when (value.string("state")) {
|
|
301
|
+
"staging" -> { uploadCiphertext = true; sendManifest = true }
|
|
302
|
+
"staged" -> { uploadCiphertext = false; sendManifest = true }
|
|
303
|
+
"ready", "processing", "published" -> { uploadCiphertext = false; sendManifest = false }
|
|
304
|
+
else -> fail("STATE")
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
private fun verifyEvidence(evidence: EncryptedUploadV2TransferEvidence) {
|
|
308
|
+
operation.check()
|
|
309
|
+
demand(evidence.ciphertextLength == input.recording.ciphertextLength.toULong() &&
|
|
310
|
+
evidence.ciphertextSha256.contentEquals(hex(input.recording.ciphertextSha256)), "INVALID_DOCUMENT")
|
|
311
|
+
}
|
|
312
|
+
private suspend fun reconcileStaging(bytes: ByteArray, evidence: EncryptedUploadV2TransferEvidence): Boolean {
|
|
313
|
+
verifyEvidence(evidence)
|
|
314
|
+
if (!uploadCiphertext) return false
|
|
315
|
+
return try { retry { manifest(bytes, evidence) }; false }
|
|
316
|
+
catch (error: UploadFailure) {
|
|
317
|
+
if (error.code == "BOTA_UPLOAD_STAGING_MISSING") true else throw error
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
private suspend fun staging(evidence: EncryptedUploadV2TransferEvidence): Request {
|
|
321
|
+
verifyEvidence(evidence)
|
|
322
|
+
demand(uploadCiphertext, "STATE")
|
|
323
|
+
demand(timestamp(session!!.string("expires_at")) > now(), "EXPIRED")
|
|
324
|
+
val target = request(sessionPath() + "/staging-url", JSONObject().put("owner_revision", pointer!!.ownerRevision))
|
|
325
|
+
operation.check()
|
|
326
|
+
demand(timestamp(session!!.string("expires_at")) > now(), "EXPIRED")
|
|
327
|
+
return sanitized("INVALID_DOCUMENT") {
|
|
328
|
+
demand(target.string("method") == "PUT", "INVALID_DOCUMENT")
|
|
329
|
+
val url = target.string("url").toHttpUrl()
|
|
330
|
+
demand(url.isHttps && url.username.isEmpty() && url.password.isEmpty() && url.fragment == null, "INVALID_DOCUMENT")
|
|
331
|
+
val headers = target.getJSONObject("headers")
|
|
332
|
+
val builder = Request.Builder().url(url)
|
|
333
|
+
headers.keys().forEach { key ->
|
|
334
|
+
val value = headers.opt(key) as? String ?: fail("INVALID_DOCUMENT")
|
|
335
|
+
demand(!key.equals("Authorization", true) && !key.equals("Cookie", true), "INVALID_DOCUMENT")
|
|
336
|
+
builder.header(key, value)
|
|
337
|
+
}
|
|
338
|
+
// SDK replaces this bodyless template with its verified native ciphertext source.
|
|
339
|
+
builder.put(ByteArray(0).toRequestBody(null)).build()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
private suspend fun manifest(bytes: ByteArray, evidence: EncryptedUploadV2TransferEvidence) {
|
|
343
|
+
verifyEvidence(evidence)
|
|
344
|
+
demand(bytes.size == 580 && evidence.manifestLength.toInt() == 580 &&
|
|
345
|
+
digest(bytes) == evidence.manifestSha256.joinToString("") { "%02x".format(it.toInt() and 255) }, "INVALID_DOCUMENT")
|
|
346
|
+
if (!sendManifest) return
|
|
347
|
+
request(sessionPath() + "/manifest", JSONObject().put("owner_revision", pointer!!.ownerRevision)
|
|
348
|
+
.put("manifest_base64", encoded(bytes)).put("manifest_sha256", digest(bytes)))
|
|
349
|
+
uploadCiphertext = false
|
|
350
|
+
sendManifest = false
|
|
351
|
+
}
|
|
352
|
+
private suspend fun finalize(evidence: EncryptedUploadV2TransferEvidence) {
|
|
353
|
+
verifyEvidence(evidence)
|
|
354
|
+
for (poll in 0 until 60) {
|
|
355
|
+
val status = retry { request(sessionPath()) }
|
|
356
|
+
applyStatus(status)
|
|
357
|
+
if (status.string("state") == "published") {
|
|
358
|
+
demand(status.integer("plaintext_length") == input.recording.plaintextLength, "INVALID_DOCUMENT")
|
|
359
|
+
receipt = opaque(status.string("completion_receipt_base64"), 336, status.string("completion_receipt_sha256"))
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
wait(2000, operation)
|
|
363
|
+
}
|
|
364
|
+
fail("PENDING")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private suspend fun context(nonce: ByteArray): EncryptedUploadV2ContextExchange {
|
|
368
|
+
operation.check()
|
|
369
|
+
demand(nonce.size == 16, "INVALID_DOCUMENT")
|
|
370
|
+
val body = JSONObject().put("nonce_base64", encoded(nonce))
|
|
371
|
+
val initial = retry { request(contexts, body) }
|
|
372
|
+
val id = uuid(initial.string("context_id"))
|
|
373
|
+
val expiry = initial.string("expires_at")
|
|
374
|
+
timestamp(expiry)
|
|
375
|
+
demand(initial.string("state") in setOf("challenge", "pending", "complete"), "INVALID_DOCUMENT")
|
|
376
|
+
val challengeBase64 = initial.string("challenge_base64")
|
|
377
|
+
val challenge = opaque(challengeBase64, 196)
|
|
378
|
+
return EncryptedUploadV2ContextExchange(challenge) { proof ->
|
|
379
|
+
operation.check()
|
|
380
|
+
demand(proof.size in 116..366, "INVALID_DOCUMENT")
|
|
381
|
+
val proofBody = JSONObject().put("proof_base64", encoded(proof))
|
|
382
|
+
var response = retry { request("$contexts/$id/proof", proofBody) }
|
|
383
|
+
for (poll in 0..100) {
|
|
384
|
+
operation.check()
|
|
385
|
+
demand(response.string("context_id") == id && response.string("challenge_base64") == challengeBase64 &&
|
|
386
|
+
response.string("expires_at") == expiry, "IDENTITY_CONFLICT")
|
|
387
|
+
when (response.string("state")) {
|
|
388
|
+
"complete" -> return@EncryptedUploadV2ContextExchange opaque(response.string("result_base64"), 264)
|
|
389
|
+
"pending" -> demand(response.has("result_base64") && response.isNull("result_base64"), "INVALID_DOCUMENT")
|
|
390
|
+
else -> fail("STATE")
|
|
391
|
+
}
|
|
392
|
+
if (poll == 100) break
|
|
393
|
+
wait(250, operation)
|
|
394
|
+
response = retry { request("$contexts/$id") }
|
|
395
|
+
}
|
|
396
|
+
fail("PENDING")
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package dev.bota.sdk.reactnative.upload
|
|
2
|
+
|
|
3
|
+
import java.io.IOException
|
|
4
|
+
import java.util.UUID
|
|
5
|
+
import java.util.concurrent.TimeUnit
|
|
6
|
+
import kotlin.coroutines.resume
|
|
7
|
+
import kotlin.coroutines.resumeWithException
|
|
8
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
9
|
+
import kotlinx.coroutines.NonCancellable
|
|
10
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
11
|
+
import kotlinx.coroutines.withContext
|
|
12
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
13
|
+
import okhttp3.Call
|
|
14
|
+
import okhttp3.Callback
|
|
15
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
16
|
+
import okhttp3.OkHttpClient
|
|
17
|
+
import okhttp3.Request
|
|
18
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
19
|
+
import okhttp3.Response
|
|
20
|
+
import org.json.JSONObject
|
|
21
|
+
|
|
22
|
+
internal class UploadOperation(val input: UploadInput) {
|
|
23
|
+
private var cancelled = false
|
|
24
|
+
private val cancellations = mutableSetOf<() -> Unit>()
|
|
25
|
+
var registrationId: String? = null
|
|
26
|
+
|
|
27
|
+
@Synchronized fun check() { if (cancelled) fail("CANCELLED") }
|
|
28
|
+
@Synchronized fun <T> active(block: () -> T): T { check(); return block() }
|
|
29
|
+
@Synchronized fun onCancel(callback: () -> Unit): () -> Unit {
|
|
30
|
+
check()
|
|
31
|
+
cancellations.add(callback)
|
|
32
|
+
return { synchronized(this) { cancellations.remove(callback) }; Unit }
|
|
33
|
+
}
|
|
34
|
+
fun cancel() {
|
|
35
|
+
val callbacks = synchronized(this) {
|
|
36
|
+
if (cancelled) return
|
|
37
|
+
cancelled = true
|
|
38
|
+
cancellations.toList().also { cancellations.clear() }
|
|
39
|
+
}
|
|
40
|
+
callbacks.forEach { it() }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
internal class CredentialBroker(private val emit: (String, String) -> Unit) {
|
|
45
|
+
private data class Pending(val operation: UploadOperation, val result: CompletableDeferred<String>)
|
|
46
|
+
private val pending = mutableMapOf<String, Pending>()
|
|
47
|
+
|
|
48
|
+
suspend fun request(operation: UploadOperation): String {
|
|
49
|
+
operation.check()
|
|
50
|
+
val requestId = UUID.randomUUID().toString()
|
|
51
|
+
val result = CompletableDeferred<String>()
|
|
52
|
+
val unregister = operation.onCancel { reject(requestId) }
|
|
53
|
+
try {
|
|
54
|
+
operation.active {
|
|
55
|
+
synchronized(this) { pending[requestId] = Pending(operation, result) }
|
|
56
|
+
emit(requestId, operation.input.operationId)
|
|
57
|
+
}
|
|
58
|
+
val token = withTimeoutOrNull(30_000) { result.await() } ?: fail("CREDENTIALS")
|
|
59
|
+
operation.check()
|
|
60
|
+
return token
|
|
61
|
+
} catch (error: Exception) {
|
|
62
|
+
operation.check()
|
|
63
|
+
throw error
|
|
64
|
+
} finally {
|
|
65
|
+
unregister()
|
|
66
|
+
synchronized(this) { pending.remove(requestId) }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fun resolve(requestId: String, token: String) {
|
|
71
|
+
val request = synchronized(this) { pending.remove(requestId) } ?: return
|
|
72
|
+
try {
|
|
73
|
+
request.operation.active {
|
|
74
|
+
demand(token.length in 1..32768 && token.all { it.code in 33..126 }, "CREDENTIALS")
|
|
75
|
+
request.result.complete(token)
|
|
76
|
+
}
|
|
77
|
+
} catch (error: UploadFailure) { request.result.completeExceptionally(error) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fun reject(requestId: String) {
|
|
81
|
+
val request = synchronized(this) { pending.remove(requestId) } ?: return
|
|
82
|
+
request.result.completeExceptionally(UploadFailure("BOTA_UPLOAD_CREDENTIALS"))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
internal class UploadRequest(
|
|
87
|
+
val origin: String, val path: String, val method: String, val headers: Map<String, String>, val body: JSONObject?,
|
|
88
|
+
) {
|
|
89
|
+
override fun toString(): String = "UploadRequest(<redacted>)"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
internal interface UploadHttp {
|
|
93
|
+
suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
|
|
94
|
+
beforeDispatch: () -> Unit = {}): JSONObject
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Dedicated client: no app network recorder, auth interceptor, redirects, or automatic POST retry. */
|
|
98
|
+
internal class BackendUploadHttp(private val client: OkHttpClient = OkHttpClient.Builder()
|
|
99
|
+
.followRedirects(false).followSslRedirects(false).retryOnConnectionFailure(false)
|
|
100
|
+
.connectTimeout(20, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).callTimeout(45, TimeUnit.SECONDS).build()) : UploadHttp {
|
|
101
|
+
override suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
|
|
102
|
+
beforeDispatch: () -> Unit): JSONObject = withContext(if (retain) NonCancellable else kotlin.coroutines.EmptyCoroutineContext) {
|
|
103
|
+
try {
|
|
104
|
+
val nativeRequest = Request.Builder().url(request.origin + request.path).apply {
|
|
105
|
+
request.headers.forEach { (key, value) -> header(key, value) }
|
|
106
|
+
method(request.method, request.body?.toString()?.toRequestBody("application/json".toMediaType()))
|
|
107
|
+
}.build()
|
|
108
|
+
val call = client.newCall(nativeRequest)
|
|
109
|
+
suspendCancellableCoroutine { continuation ->
|
|
110
|
+
var unregister: (() -> Unit)? = null
|
|
111
|
+
continuation.invokeOnCancellation { call.cancel() }
|
|
112
|
+
try {
|
|
113
|
+
operation.active {
|
|
114
|
+
if (!retain) unregister = operation.onCancel { call.cancel() }
|
|
115
|
+
beforeDispatch()
|
|
116
|
+
call.enqueue(object : Callback {
|
|
117
|
+
override fun onFailure(call: Call, e: IOException) {
|
|
118
|
+
unregister?.invoke()
|
|
119
|
+
if (continuation.isActive) continuation.resumeWithException(UploadFailure("BOTA_UPLOAD_TRANSPORT"))
|
|
120
|
+
}
|
|
121
|
+
override fun onResponse(call: Call, response: Response) {
|
|
122
|
+
val value = runCatching {
|
|
123
|
+
response.use {
|
|
124
|
+
if (!response.isSuccessful && response.code != 401 && response.code != 403 && response.code != 409) fail("HTTP_${response.code}")
|
|
125
|
+
val body = response.body ?: fail("INVALID_DOCUMENT")
|
|
126
|
+
demand(body.contentLength() <= 65536, "INVALID_DOCUMENT")
|
|
127
|
+
// Read a bounded body here, before completing the retained request.
|
|
128
|
+
val source = body.source()
|
|
129
|
+
source.request(65537)
|
|
130
|
+
demand(source.buffer.size <= 65536, "INVALID_DOCUMENT")
|
|
131
|
+
val text = source.readUtf8()
|
|
132
|
+
if (response.code == 409 && runCatching {
|
|
133
|
+
JSONObject(text).optJSONObject("error")?.optString("code") == "encrypted_upload_v2_staging_missing"
|
|
134
|
+
}.getOrDefault(false)) fail("STAGING_MISSING")
|
|
135
|
+
if (!response.isSuccessful) fail("HTTP_${response.code}")
|
|
136
|
+
if (text.isEmpty()) JSONObject() else sanitized("INVALID_DOCUMENT") { JSONObject(text) }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
unregister?.invoke()
|
|
140
|
+
if (continuation.isActive) value.fold({ continuation.resume(it) }, {
|
|
141
|
+
continuation.resumeWithException(if (it is UploadFailure) it else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
} catch (error: Exception) {
|
|
147
|
+
unregister?.invoke()
|
|
148
|
+
if (continuation.isActive) continuation.resumeWithException(
|
|
149
|
+
if (error is UploadFailure) error else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (error: Exception) {
|
|
153
|
+
if (!retain) operation.check()
|
|
154
|
+
if (error is UploadFailure) throw error
|
|
155
|
+
fail("TRANSPORT")
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
package dev.bota.sdk.reactnative.upload
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.FileOutputStream
|
|
5
|
+
import java.nio.channels.FileChannel
|
|
6
|
+
import java.nio.file.Files
|
|
7
|
+
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
|
8
|
+
import java.nio.file.StandardCopyOption.ATOMIC_MOVE
|
|
9
|
+
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
|
|
10
|
+
import java.nio.file.StandardOpenOption.READ
|
|
11
|
+
import java.util.UUID
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
|
|
14
|
+
internal data class JournalEntry(val identitySha256: String, val pointer: UploadPointer?, val phase: String = "ready",
|
|
15
|
+
val importedPriorSha256: String? = null, val authorizationNonceSha256: String? = null,
|
|
16
|
+
val recoveryNonceBase64: String? = null) {
|
|
17
|
+
fun json(): JSONObject = (pointer?.json() ?: JSONObject()).put("version", 2)
|
|
18
|
+
.put("identitySha256", identitySha256).put("phase", phase).apply {
|
|
19
|
+
if (importedPriorSha256 != null) put("importedPriorSha256", importedPriorSha256)
|
|
20
|
+
if (authorizationNonceSha256 != null) put("authorizationNonceSha256", authorizationNonceSha256)
|
|
21
|
+
if (recoveryNonceBase64 != null) put("recoveryNonceBase64", recoveryNonceBase64)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
internal interface JournalDurability {
|
|
26
|
+
fun syncFile(stream: FileOutputStream) { stream.fd.sync() }
|
|
27
|
+
fun replace(source: File, target: File) { Files.move(source.toPath(), target.toPath(), ATOMIC_MOVE, REPLACE_EXISTING) }
|
|
28
|
+
fun syncDirectory(directory: File) { FileChannel.open(directory.toPath(), READ).use { it.force(true) } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
internal class UploadJournal(private val root: File, private val durability: JournalDurability = object : JournalDurability {}) {
|
|
32
|
+
private fun directory() {
|
|
33
|
+
val path = root.toPath()
|
|
34
|
+
if (!Files.exists(path, NOFOLLOW_LINKS)) {
|
|
35
|
+
demand(root.mkdir(), "JOURNAL")
|
|
36
|
+
durability.syncDirectory(root.parentFile!!)
|
|
37
|
+
}
|
|
38
|
+
demand(Files.isDirectory(path, NOFOLLOW_LINKS) && !Files.isSymbolicLink(path), "JOURNAL")
|
|
39
|
+
}
|
|
40
|
+
private fun file(key: String): File = File(root, "${hashString(key)}.json")
|
|
41
|
+
|
|
42
|
+
@Synchronized fun load(input: UploadInput): JournalEntry? = sanitized("JOURNAL") {
|
|
43
|
+
directory()
|
|
44
|
+
val file = file(input.journalKey)
|
|
45
|
+
if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return@sanitized null
|
|
46
|
+
demand(Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS) && file.length() <= 4096, "JOURNAL")
|
|
47
|
+
val json = JSONObject(file.readText(Charsets.UTF_8))
|
|
48
|
+
val allowed = setOf("version", "identitySha256", "phase", "recordingId", "sessionId", "ownerRevision", "importedPriorSha256", "authorizationNonceSha256", "recoveryNonceBase64")
|
|
49
|
+
demand(json.keys().asSequence().all { it in allowed }, "JOURNAL")
|
|
50
|
+
val version = json.integer("version", 1, 2)
|
|
51
|
+
val identity = hashString(json.string("identitySha256"))
|
|
52
|
+
demand(identity == (if (version == 1L) input.legacyIdentitySha256 else input.identitySha256), "IDENTITY_CONFLICT")
|
|
53
|
+
val phase = json.string("phase")
|
|
54
|
+
demand(phase in setOf("ready", "recording_create_unknown", "session_create_unknown"), "JOURNAL")
|
|
55
|
+
val pointer = if (json.has("recordingId")) UploadPointer.parse(json) else null
|
|
56
|
+
val imported = if (json.has("importedPriorSha256")) hashString(json.string("importedPriorSha256")) else null
|
|
57
|
+
val nonceHash = if (json.has("authorizationNonceSha256")) hashString(json.string("authorizationNonceSha256")) else null
|
|
58
|
+
demand(nonceHash == null || pointer?.sessionId != null, "JOURNAL")
|
|
59
|
+
val recoveryNonce = if (json.has("recoveryNonceBase64")) json.string("recoveryNonceBase64").also { opaque(it, 16) } else null
|
|
60
|
+
demand(recoveryNonce == null || (phase == "ready" && pointer?.sessionId != null), "JOURNAL")
|
|
61
|
+
demand(if (phase == "recording_create_unknown") pointer == null else pointer != null, "JOURNAL")
|
|
62
|
+
demand(phase != "session_create_unknown" || pointer?.sessionId == null, "JOURNAL")
|
|
63
|
+
if (input.prior != null && pointer != null) {
|
|
64
|
+
demand(input.prior.recordingId == pointer.recordingId &&
|
|
65
|
+
(input.prior.sessionId == null || input.prior.fingerprint() == imported ||
|
|
66
|
+
(input.prior.sessionId == pointer.sessionId && input.prior.ownerRevision == pointer.ownerRevision)),
|
|
67
|
+
"IDENTITY_CONFLICT")
|
|
68
|
+
}
|
|
69
|
+
JournalEntry(input.identitySha256, pointer, phase, imported, nonceHash, recoveryNonce).also {
|
|
70
|
+
if (version == 1L) save(input, it)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Synchronized fun save(input: UploadInput, entry: JournalEntry) = sanitized("JOURNAL") {
|
|
75
|
+
directory()
|
|
76
|
+
demand(entry.identitySha256 == input.identitySha256, "IDENTITY_CONFLICT")
|
|
77
|
+
val target = file(input.journalKey)
|
|
78
|
+
demand(!Files.isSymbolicLink(target.toPath()), "JOURNAL")
|
|
79
|
+
val temporary = File(root, ".${input.journalKey}.${UUID.randomUUID()}.tmp")
|
|
80
|
+
try {
|
|
81
|
+
demand(temporary.createNewFile(), "JOURNAL")
|
|
82
|
+
FileOutputStream(temporary).use { stream ->
|
|
83
|
+
stream.write(entry.json().toString().toByteArray(Charsets.UTF_8))
|
|
84
|
+
durability.syncFile(stream)
|
|
85
|
+
}
|
|
86
|
+
durability.replace(temporary, target)
|
|
87
|
+
durability.syncDirectory(root)
|
|
88
|
+
} finally { if (temporary.exists()) temporary.delete() }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Synchronized fun remove(input: UploadInput, expected: UploadPointer) = sanitized("JOURNAL") {
|
|
92
|
+
val current = load(input) ?: fail("IDENTITY_CONFLICT")
|
|
93
|
+
demand(current.phase == "ready" && current.pointer == expected, "IDENTITY_CONFLICT")
|
|
94
|
+
Files.delete(file(input.journalKey).toPath())
|
|
95
|
+
durability.syncDirectory(root)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Synchronized fun rollbackRejectedCreate(input: UploadInput, marker: JournalEntry, previous: JournalEntry?) = sanitized("JOURNAL") {
|
|
99
|
+
demand(load(input) == marker, "IDENTITY_CONFLICT")
|
|
100
|
+
when (marker.phase) {
|
|
101
|
+
"recording_create_unknown" -> demand(previous == null && marker.pointer == null, "IDENTITY_CONFLICT")
|
|
102
|
+
"session_create_unknown" -> demand(previous?.phase == "ready" && previous.copy(phase = marker.phase) == marker, "IDENTITY_CONFLICT")
|
|
103
|
+
else -> fail("IDENTITY_CONFLICT")
|
|
104
|
+
}
|
|
105
|
+
if (previous == null) {
|
|
106
|
+
Files.delete(file(input.journalKey).toPath())
|
|
107
|
+
durability.syncDirectory(root)
|
|
108
|
+
} else {
|
|
109
|
+
save(input, previous)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|