AeroToss v1.0.0: аудит безопасности, багфиксы, подпись APK
Безопасность: - Сервер bind на loopback (127.0.0.1) вместо 0.0.0.0 - Лимит соединений: Semaphore(10) для защиты от DoS - Валидация fileSize: max 10 ГБ, reject при превышении - Валидация пути файла: canonicalPath must startWith downloadsDir - FileUtils.resolveUniqueFile: max counter 1000 - FileUtils.deleteIfExists: исправлен TOCTOU race - Скрытие путей в ошибках (не泄漏 internal paths) - Трансфер-история ограничена 50 записями - Таймаут сокета увеличен до 60 сек - compareAndSet для защиты от double-bind - require(file.exists()) в sendFile Сборка: - Android: APK подписан debug keystore (self-signed) - Android: конвертирован из library в application - macOS: AeroToss.app (113 МБ) + JAR (28 МБ) - LICENSE: GPLv3 Тесты: все 71+ проходят
This commit is contained in:
@@ -32,6 +32,7 @@ local.properties
|
|||||||
/captures
|
/captures
|
||||||
.externalNativeBuild/
|
.externalNativeBuild/
|
||||||
.cxx/
|
.cxx/
|
||||||
|
*.keystore
|
||||||
|
|
||||||
# === OS ===
|
# === OS ===
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
For the complete license text, see https://www.gnu.org/licenses/gpl-3.0.txt
|
||||||
+39
-1
@@ -5,7 +5,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.application)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -71,7 +71,36 @@ android {
|
|||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
applicationId = "com.aerotoss"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "aerotoss123"
|
||||||
|
keyAlias = "aerotoss"
|
||||||
|
keyPassword = "aerotoss123"
|
||||||
|
}
|
||||||
|
create("release") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "aerotoss123"
|
||||||
|
keyAlias = "aerotoss"
|
||||||
|
keyPassword = "aerotoss123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -89,5 +118,14 @@ android {
|
|||||||
compose.desktop {
|
compose.desktop {
|
||||||
application {
|
application {
|
||||||
mainClass = "com.aerotoss.MainKt"
|
mainClass = "com.aerotoss.MainKt"
|
||||||
|
|
||||||
|
nativeDistributions {
|
||||||
|
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg)
|
||||||
|
packageName = "AeroToss"
|
||||||
|
packageVersion = "1.0.0"
|
||||||
|
description = "Кроссплатформенное приложение для передачи файлов"
|
||||||
|
vendor = "AeroToss"
|
||||||
|
licenseFile = file("../LICENSE")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class CompositeAndroidDiscovery(
|
|||||||
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
||||||
override val devices: Flow<List<Device>> = _devices.asStateFlow()
|
override val devices: Flow<List<Device>> = _devices.asStateFlow()
|
||||||
|
|
||||||
private var scope: CoroutineScope? = null
|
@Volatile private var scope: CoroutineScope? = null
|
||||||
private var discoveryJob: kotlinx.coroutines.Job? = null
|
@Volatile private var discoveryJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
override fun startDiscovery(servicePort: Int) {
|
override fun startDiscovery(servicePort: Int) {
|
||||||
if (discoveryJob != null) return
|
if (discoveryJob != null) return
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import kotlinx.coroutines.*
|
|||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
import java.net.InetAddress
|
||||||
import java.net.ServerSocket
|
import java.net.ServerSocket
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -36,6 +38,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val activeJobs = ConcurrentHashMap<String, Job>()
|
private val activeJobs = ConcurrentHashMap<String, Job>()
|
||||||
private val running = AtomicBoolean(false)
|
private val running = AtomicBoolean(false)
|
||||||
|
private val connectionLimiter = Semaphore(10)
|
||||||
|
|
||||||
private val downloadsDir: File = run {
|
private val downloadsDir: File = run {
|
||||||
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||||
@@ -46,12 +49,11 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
override fun getServerPort(): Int = serverSocket?.localPort ?: 0
|
override fun getServerPort(): Int = serverSocket?.localPort ?: 0
|
||||||
|
|
||||||
fun startServer(port: Int = 0): Int {
|
fun startServer(port: Int = 0): Int {
|
||||||
if (running.get()) return serverSocket?.localPort ?: 0
|
if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val socket = ServerSocket(port)
|
val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress())
|
||||||
serverSocket = socket
|
serverSocket = socket
|
||||||
running.set(true)
|
|
||||||
|
|
||||||
serverThread = Thread {
|
serverThread = Thread {
|
||||||
while (running.get() && !socket.isClosed) {
|
while (running.get() && !socket.isClosed) {
|
||||||
@@ -76,20 +78,33 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleIncomingConnection(socket: Socket) {
|
private suspend fun handleIncomingConnection(socket: Socket) {
|
||||||
|
if (!connectionLimiter.tryAcquire()) {
|
||||||
|
try { socket.close() } catch (_: Exception) {}
|
||||||
|
return
|
||||||
|
}
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val progressId = UUID.randomUUID().toString()
|
val progressId = UUID.randomUUID().toString()
|
||||||
var actualFile: File? = null
|
var actualFile: File? = null
|
||||||
var requestFileName: String = ""
|
var requestFileName: String = ""
|
||||||
try {
|
try {
|
||||||
socket.use { sock ->
|
socket.use { sock ->
|
||||||
sock.soTimeout = 30_000
|
sock.soTimeout = 60_000
|
||||||
val input = DataInputStream(sock.getInputStream())
|
val input = DataInputStream(sock.getInputStream())
|
||||||
val output = DataOutputStream(sock.getOutputStream())
|
val output = DataOutputStream(sock.getOutputStream())
|
||||||
|
|
||||||
val requestJson = input.readUTF()
|
val requestJson = input.readUTF()
|
||||||
val request = Json.decodeFromString<TransferRequest>(requestJson)
|
val request = try {
|
||||||
|
Json.decodeFromString<TransferRequest>(requestJson)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
requestFileName = request.fileName
|
requestFileName = request.fileName
|
||||||
|
|
||||||
|
if (request.fileSize <= 0 || request.fileSize > FileUtils.MAX_FILE_SIZE) {
|
||||||
|
output.writeBoolean(false)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
val progress = TransferProgress(
|
val progress = TransferProgress(
|
||||||
id = progressId,
|
id = progressId,
|
||||||
request = request,
|
request = request,
|
||||||
@@ -108,6 +123,10 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
||||||
|
if (!FileUtils.validateFilePath(file, downloadsDir)) {
|
||||||
|
output.writeBoolean(false)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
actualFile = file
|
actualFile = file
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
var bytesWritten = 0L
|
var bytesWritten = 0L
|
||||||
@@ -159,8 +178,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
e.printStackTrace()
|
|
||||||
actualFile?.let { FileUtils.deleteIfExists(it) }
|
actualFile?.let { FileUtils.deleteIfExists(it) }
|
||||||
val current = _incomingTransfers.value.find { it.id == progressId }
|
val current = _incomingTransfers.value.find { it.id == progressId }
|
||||||
if (current != null && current.state != TransferState.COMPLETED &&
|
if (current != null && current.state != TransferState.COMPLETED &&
|
||||||
@@ -168,14 +186,19 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
) {
|
) {
|
||||||
updateIncomingById(progressId, current.copy(
|
updateIncomingById(progressId, current.copy(
|
||||||
state = TransferState.FAILED,
|
state = TransferState.FAILED,
|
||||||
error = e.message ?: "Unknown error"
|
error = "Transfer failed"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
connectionLimiter.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow<TransferProgress> {
|
override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow<TransferProgress> {
|
||||||
|
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" }
|
||||||
|
require(file.length() <= FileUtils.MAX_FILE_SIZE) { "File exceeds maximum size" }
|
||||||
|
|
||||||
val requestId = UUID.randomUUID().toString()
|
val requestId = UUID.randomUUID().toString()
|
||||||
val request = TransferRequest(
|
val request = TransferRequest(
|
||||||
fileName = file.name,
|
fileName = file.name,
|
||||||
@@ -260,11 +283,10 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
e.printStackTrace()
|
|
||||||
updateOutgoingById(requestId, initialProgress.copy(
|
updateOutgoingById(requestId, initialProgress.copy(
|
||||||
state = TransferState.FAILED,
|
state = TransferState.FAILED,
|
||||||
error = e.message ?: "Unknown error"
|
error = "Transfer failed"
|
||||||
))
|
))
|
||||||
} finally {
|
} finally {
|
||||||
activeJobs.remove(requestId)
|
activeJobs.remove(requestId)
|
||||||
@@ -294,19 +316,19 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
|
|
||||||
private fun updateIncoming(progress: TransferProgress) {
|
private fun updateIncoming(progress: TransferProgress) {
|
||||||
_incomingTransfers.update { list ->
|
_incomingTransfers.update { list ->
|
||||||
list.filter { it.id != progress.id }.plus(progress)
|
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateIncomingById(id: String, progress: TransferProgress) {
|
private fun updateIncomingById(id: String, progress: TransferProgress) {
|
||||||
_incomingTransfers.update { list ->
|
_incomingTransfers.update { list ->
|
||||||
list.filter { it.id != id }.plus(progress)
|
list.filter { it.id != id }.plus(progress).takeLast(50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateOutgoing(progress: TransferProgress) {
|
private fun updateOutgoing(progress: TransferProgress) {
|
||||||
_outgoingTransfers.update { list ->
|
_outgoingTransfers.update { list ->
|
||||||
list.filter { it.id != progress.id }.plus(progress)
|
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class AeroTossManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
|
suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
|
||||||
|
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" }
|
||||||
return transfer.sendFile(file, target.hostAddress, target.port)
|
return transfer.sendFile(file, target.hostAddress, target.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import com.aerotoss.model.Device
|
|||||||
import com.aerotoss.model.TransferState
|
import com.aerotoss.model.TransferState
|
||||||
import com.aerotoss.ui.components.TransferProgressBar
|
import com.aerotoss.ui.components.TransferProgressBar
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -24,8 +23,10 @@ fun SendScreen(
|
|||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
|
val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
|
||||||
var selectedFile by remember { mutableStateOf<File?>(null) }
|
var selectedFilePath by remember { mutableStateOf<String?>(null) }
|
||||||
|
var selectedFileName by remember { mutableStateOf<String?>(null) }
|
||||||
var isSending by remember { mutableStateOf(false) }
|
var isSending by remember { mutableStateOf(false) }
|
||||||
|
var showFilePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -54,36 +55,42 @@ fun SendScreen(
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedFile == null) {
|
if (selectedFilePath == null) {
|
||||||
Text(
|
Text(
|
||||||
"Выберите файл для отправки",
|
"Выберите файл для отправки",
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Button(
|
Button(onClick = { showFilePicker = true }) {
|
||||||
onClick = {
|
|
||||||
selectedFile = File("/tmp/test_file.txt").apply {
|
|
||||||
writeText("Тестовый файл AeroToss ${System.currentTimeMillis()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Выбрать файл")
|
Text("Выбрать файл")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showFilePicker) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Выберите файл через системный диалог",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
selectedFilePath = "/tmp/manual_file.txt"
|
||||||
|
selectedFileName = "manual_file.txt"
|
||||||
|
showFilePicker = false
|
||||||
|
}) {
|
||||||
|
Text("Загрузить тестовый файл")
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"Файл: ${selectedFile?.name}",
|
"Файл: ${selectedFileName}",
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
"Размер: ${formatSize(selectedFile?.length() ?: 0)}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,37 +106,42 @@ fun SendScreen(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedButton(onClick = {
|
OutlinedButton(onClick = {
|
||||||
selectedFile = null
|
selectedFilePath = null
|
||||||
|
selectedFileName = null
|
||||||
}) {
|
}) {
|
||||||
Text("Отправить ещё")
|
Text("Отправить ещё")
|
||||||
}
|
}
|
||||||
} else if (outgoingTransfer?.state == TransferState.FAILED) {
|
} else if (outgoingTransfer?.state == TransferState.FAILED) {
|
||||||
Text(
|
Text(
|
||||||
"Ошибка: ${outgoingTransfer?.error}",
|
"Ошибка отправки",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedButton(onClick = {
|
OutlinedButton(onClick = {
|
||||||
selectedFile = null
|
selectedFilePath = null
|
||||||
|
selectedFileName = null
|
||||||
}) {
|
}) {
|
||||||
Text("Попробовать снова")
|
Text("Попробовать снова")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (!isSending && selectedFile != null && targetDevice != null) {
|
if (!isSending && selectedFilePath != null && targetDevice != null) {
|
||||||
isSending = true
|
isSending = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
manager.sendFile(selectedFile!!, targetDevice).collect {}
|
val file = java.io.File(selectedFilePath!!)
|
||||||
|
if (file.exists()) {
|
||||||
|
manager.sendFile(file, targetDevice).collect {}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isSending && selectedFile != null && targetDevice != null
|
enabled = !isSending && selectedFilePath != null && targetDevice != null
|
||||||
) {
|
) {
|
||||||
if (isSending) {
|
if (isSending) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@@ -145,10 +157,3 @@ fun SendScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatSize(bytes: Long): String = when {
|
|
||||||
bytes >= 1_073_741_824 -> "%.1f ГБ".format(bytes / 1_073_741_824.0)
|
|
||||||
bytes >= 1_048_576 -> "%.1f МБ".format(bytes / 1_048_576.0)
|
|
||||||
bytes >= 1024 -> "%.1f КБ".format(bytes / 1024.0)
|
|
||||||
else -> "$bytes Б"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.aerotoss.util
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object FileUtils {
|
object FileUtils {
|
||||||
|
private const val MAX_RESOLVE_COUNTER = 1000
|
||||||
|
const val MAX_FILE_SIZE = 10L * 1024 * 1024 * 1024 // 10 GB
|
||||||
|
|
||||||
fun sanitizeFileName(fileName: String): String {
|
fun sanitizeFileName(fileName: String): String {
|
||||||
val sanitized = fileName
|
val sanitized = fileName
|
||||||
.replace(Regex("[/\\\\]"), "_")
|
.replace(Regex("[/\\\\]"), "_")
|
||||||
@@ -13,6 +16,14 @@ object FileUtils {
|
|||||||
return if (sanitized.isEmpty()) "unnamed_file" else sanitized
|
return if (sanitized.isEmpty()) "unnamed_file" else sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun validateFilePath(file: File, allowedDir: File): Boolean {
|
||||||
|
return try {
|
||||||
|
file.canonicalPath.startsWith(allowedDir.canonicalPath)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun resolveUniqueFile(dir: File, fileName: String): File {
|
fun resolveUniqueFile(dir: File, fileName: String): File {
|
||||||
val sanitized = sanitizeFileName(fileName)
|
val sanitized = sanitizeFileName(fileName)
|
||||||
val file = File(dir, sanitized)
|
val file = File(dir, sanitized)
|
||||||
@@ -24,15 +35,19 @@ object FileUtils {
|
|||||||
var candidate = File(dir, "${name}_${counter}.${ext}")
|
var candidate = File(dir, "${name}_${counter}.${ext}")
|
||||||
while (candidate.exists()) {
|
while (candidate.exists()) {
|
||||||
counter++
|
counter++
|
||||||
|
if (counter > MAX_RESOLVE_COUNTER) {
|
||||||
|
return File(dir, "${name}_${System.currentTimeMillis()}.${ext}")
|
||||||
|
}
|
||||||
candidate = File(dir, "${name}_${counter}.${ext}")
|
candidate = File(dir, "${name}_${counter}.${ext}")
|
||||||
}
|
}
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteIfExists(file: File): Boolean {
|
fun deleteIfExists(file: File): Boolean {
|
||||||
if (file.exists()) {
|
return try {
|
||||||
return file.delete()
|
file.delete()
|
||||||
}
|
} catch (_: Exception) {
|
||||||
return false
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import kotlinx.coroutines.*
|
|||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
import java.net.InetAddress
|
||||||
import java.net.ServerSocket
|
import java.net.ServerSocket
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -35,20 +37,20 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val activeJobs = ConcurrentHashMap<String, Job>()
|
private val activeJobs = ConcurrentHashMap<String, Job>()
|
||||||
private val running = AtomicBoolean(false)
|
private val running = AtomicBoolean(false)
|
||||||
|
private val connectionLimiter = Semaphore(10)
|
||||||
|
|
||||||
private val downloadsDir: File = File(System.getProperty("user.home"), "AeroTossDownloads").apply {
|
private val downloadsDir: File = File(System.getProperty("user.home"), "Downloads/AeroToss").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getServerPort(): Int = serverSocket?.localPort ?: 0
|
override fun getServerPort(): Int = serverSocket?.localPort ?: 0
|
||||||
|
|
||||||
fun startServer(port: Int = 0): Int {
|
fun startServer(port: Int = 0): Int {
|
||||||
if (running.get()) return serverSocket?.localPort ?: 0
|
if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val socket = ServerSocket(port)
|
val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress())
|
||||||
serverSocket = socket
|
serverSocket = socket
|
||||||
running.set(true)
|
|
||||||
|
|
||||||
serverThread = Thread {
|
serverThread = Thread {
|
||||||
while (running.get() && !socket.isClosed) {
|
while (running.get() && !socket.isClosed) {
|
||||||
@@ -73,20 +75,33 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleIncomingConnection(socket: Socket) {
|
private suspend fun handleIncomingConnection(socket: Socket) {
|
||||||
|
if (!connectionLimiter.tryAcquire()) {
|
||||||
|
try { socket.close() } catch (_: Exception) {}
|
||||||
|
return
|
||||||
|
}
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val progressId = UUID.randomUUID().toString()
|
val progressId = UUID.randomUUID().toString()
|
||||||
var actualFile: File? = null
|
var actualFile: File? = null
|
||||||
var requestFileName: String = ""
|
var requestFileName: String = ""
|
||||||
try {
|
try {
|
||||||
socket.use { sock ->
|
socket.use { sock ->
|
||||||
sock.soTimeout = 30_000
|
sock.soTimeout = 60_000
|
||||||
val input = DataInputStream(sock.getInputStream())
|
val input = DataInputStream(sock.getInputStream())
|
||||||
val output = DataOutputStream(sock.getOutputStream())
|
val output = DataOutputStream(sock.getOutputStream())
|
||||||
|
|
||||||
val requestJson = input.readUTF()
|
val requestJson = input.readUTF()
|
||||||
val request = Json.decodeFromString<TransferRequest>(requestJson)
|
val request = try {
|
||||||
|
Json.decodeFromString<TransferRequest>(requestJson)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
requestFileName = request.fileName
|
requestFileName = request.fileName
|
||||||
|
|
||||||
|
if (request.fileSize < 0 || request.fileSize > FileUtils.MAX_FILE_SIZE) {
|
||||||
|
output.writeBoolean(false)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
val progress = TransferProgress(
|
val progress = TransferProgress(
|
||||||
id = progressId,
|
id = progressId,
|
||||||
request = request,
|
request = request,
|
||||||
@@ -105,6 +120,10 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
||||||
|
if (!FileUtils.validateFilePath(file, downloadsDir)) {
|
||||||
|
output.writeBoolean(false)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
actualFile = file
|
actualFile = file
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
var bytesWritten = 0L
|
var bytesWritten = 0L
|
||||||
@@ -157,7 +176,6 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
|
||||||
actualFile?.let { FileUtils.deleteIfExists(it) }
|
actualFile?.let { FileUtils.deleteIfExists(it) }
|
||||||
val current = _incomingTransfers.value.find { it.id == progressId }
|
val current = _incomingTransfers.value.find { it.id == progressId }
|
||||||
if (current != null && current.state != TransferState.COMPLETED &&
|
if (current != null && current.state != TransferState.COMPLETED &&
|
||||||
@@ -165,14 +183,19 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
) {
|
) {
|
||||||
updateIncomingById(progressId, current.copy(
|
updateIncomingById(progressId, current.copy(
|
||||||
state = TransferState.FAILED,
|
state = TransferState.FAILED,
|
||||||
error = e.message ?: "Unknown error"
|
error = "Transfer failed"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
connectionLimiter.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow<TransferProgress> {
|
override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow<TransferProgress> {
|
||||||
|
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" }
|
||||||
|
require(file.length() <= FileUtils.MAX_FILE_SIZE) { "File exceeds maximum size" }
|
||||||
|
|
||||||
val requestId = UUID.randomUUID().toString()
|
val requestId = UUID.randomUUID().toString()
|
||||||
val request = TransferRequest(
|
val request = TransferRequest(
|
||||||
fileName = file.name,
|
fileName = file.name,
|
||||||
@@ -251,11 +274,10 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
}
|
}
|
||||||
updateOutgoing(stateFlow.value)
|
updateOutgoing(stateFlow.value)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
e.printStackTrace()
|
|
||||||
stateFlow.value = stateFlow.value.copy(
|
stateFlow.value = stateFlow.value.copy(
|
||||||
state = TransferState.FAILED,
|
state = TransferState.FAILED,
|
||||||
error = e.message ?: "Unknown error"
|
error = "Transfer failed"
|
||||||
)
|
)
|
||||||
updateOutgoing(stateFlow.value)
|
updateOutgoing(stateFlow.value)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -288,19 +310,19 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
|
|
||||||
private fun updateIncoming(progress: TransferProgress) {
|
private fun updateIncoming(progress: TransferProgress) {
|
||||||
_incomingTransfers.update { list ->
|
_incomingTransfers.update { list ->
|
||||||
list.filter { it.id != progress.id }.plus(progress)
|
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateIncomingById(id: String, progress: TransferProgress) {
|
private fun updateIncomingById(id: String, progress: TransferProgress) {
|
||||||
_incomingTransfers.update { list ->
|
_incomingTransfers.update { list ->
|
||||||
list.filter { it.id != id }.plus(progress)
|
list.filter { it.id != id }.plus(progress).takeLast(50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateOutgoing(progress: TransferProgress) {
|
private fun updateOutgoing(progress: TransferProgress) {
|
||||||
_outgoingTransfers.update { list ->
|
_outgoingTransfers.update { list ->
|
||||||
list.filter { it.id != progress.id }.plus(progress)
|
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ class DesktopTransferManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testPortAlreadyInUse() {
|
fun testPortAlreadyInUse() {
|
||||||
val ss = ServerSocket(0)
|
val ss = ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())
|
||||||
val port = ss.localPort
|
val port = ss.localPort
|
||||||
try {
|
try {
|
||||||
val result = manager.startServer(port)
|
val result = manager.startServer(port)
|
||||||
|
|||||||
+1
-1
@@ -3,5 +3,5 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
alias(libs.plugins.compose.multiplatform) apply false
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
alias(libs.plugins.compose.compiler) apply false
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user