Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import com.infomaniak.mail.data.models.draft.ScheduleDraftResult
import com.infomaniak.mail.data.models.draft.SendDraftResult
import com.infomaniak.mail.data.models.extensions.computeFirstAndLastName
import com.infomaniak.mail.data.models.extensions.getJsonRequestBody
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.data.models.getMessages.ActivitiesResult
import com.infomaniak.mail.data.models.getMessages.GetMessagesByUidsResult
import com.infomaniak.mail.data.models.getMessages.MessageFlags
Expand Down Expand Up @@ -613,7 +614,7 @@ object ApiRepository : ApiRepositoryCore() {
@OptIn(ManualAuthorizationRequired::class)
val headers = HttpUtils.getHeaders(contentType = null).newBuilder()
.set("Authorization", "Bearer $userApiToken")
.addUnsafeNonAscii("x-ws-attachment-filename", attachment.name)
.addUnsafeNonAscii("x-ws-attachment-filename", attachment.safeName)
.add("x-ws-attachment-mime-type", attachment.mimeType)
.add("x-ws-attachment-disposition", "attachment")
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.AttachableMimeTypeUtils
import com.infomaniak.mail.utils.LocalStorageUtils
import com.infomaniak.mail.utils.Utils
import com.infomaniak.mail.utils.resolveContainedFileName
import com.infomaniak.mail.utils.toSafeFileName
import java.io.File

val Attachable.downloadUrl get() = ApiRoutes.resource(resource!!)

val Attachable.safeMimeType get() = if (mimeType == Utils.MIMETYPE_UNKNOWN) name.guessMimeType() else mimeType
val Attachable.safeName get() = name.toSafeFileName()

val Attachable.safeMimeType get() = if (mimeType == Utils.MIMETYPE_UNKNOWN) safeName.guessMimeType() else mimeType

fun Attachable.getFileTypeFromMimeType(): AttachmentType = AttachableMimeTypeUtils.getFileTypeFromMimeType(safeMimeType)

Expand All @@ -45,24 +49,24 @@ fun Attachable.hasUsableCache(
): Boolean = when (this) {
is Attachment -> {
val cachedFile = file ?: getCacheFile(context, userId, mailboxId)
cachedFile.length() > 0 && cachedFile.canRead()
cachedFile != null && cachedFile.length() > 0 && cachedFile.canRead()
}
is SwissTransferFile -> false
}

fun Attachable.isInlineCachedFile(context: Context): Boolean = when (this) {
is Attachment -> getCacheFile(context).exists() && disposition == AttachmentDisposition.INLINE
is Attachment -> getCacheFile(context)?.exists() == true && disposition == AttachmentDisposition.INLINE
is SwissTransferFile -> false
}

fun Attachable.getCacheFile(
context: Context,
userId: Int = AccountUtils.currentUserId,
mailboxId: Int = AccountUtils.currentMailboxId,
): File = when (this) {
): File? = when (this) {
is Attachment -> {
val cacheFolder = LocalStorageUtils.getAttachmentsCacheDir(context, extractPathFromResource(), userId, mailboxId)
File(cacheFolder, name)
cacheFolder?.resolveContainedFileName(safeName)
}
is SwissTransferFile -> File("")
}
3 changes: 2 additions & 1 deletion app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import com.infomaniak.mail.utils.SharedUtils.Companion.updateSignatures
import com.infomaniak.mail.utils.Utils
import com.infomaniak.mail.utils.Utils.EML_CONTENT_TYPE
import com.infomaniak.mail.utils.coroutineContext
import com.infomaniak.mail.utils.toSafeFileName
import com.infomaniak.mail.utils.extensions.MergedContactDictionary
import com.infomaniak.mail.utils.extensions.allFailed
import com.infomaniak.mail.utils.extensions.appContext
Expand Down Expand Up @@ -856,7 +857,7 @@ class MainViewModel @Inject constructor(
val userBearerToken = AccountUtils.currentUser?.apiToken?.accessToken
DownloadManagerUtils.launchDownload(
url = downloadUrl,
name = filename,
name = filename.toSafeFileName(),
userAgent = HttpUtils.getUserAgent,
userBearerToken = userBearerToken,
onError = { resourceStringId ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.infomaniak.mail.R
import com.infomaniak.mail.data.models.Attachable
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.databinding.ItemAttachmentBinding
import com.infomaniak.mail.ui.main.thread.AttachmentAdapter.AttachmentViewHolder
import com.infomaniak.mail.utils.Utils.runCatchingRealm
Expand Down Expand Up @@ -55,7 +56,7 @@ class AttachmentAdapter(

if (shouldDisplayCloseButton) {
attachmentCloseButton.apply {
contentDescription = context.getString(R.string.contentDescriptionButtonDelete, attachment.name)
contentDescription = context.getString(R.string.contentDescriptionButtonDelete, attachment.safeName)
setOnClickListener {
val index = attachments.indexOf(attachment)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ abstract class MessageWebViewClient(
if (url?.scheme.equals(CID_SCHEME, ignoreCase = true)) {
val cid = url.schemeSpecificPart
return cidDictionary[cid]?.let { attachment ->
val cacheFile = attachment.getCacheFile(context)
val cacheFile = attachment.getCacheFile(context) ?: return@runCatchingRealm null

val data = if (attachment.hasUsableCache(context, cacheFile)) {
cacheFile.inputStream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import com.infomaniak.mail.utils.extensions.AttachmentExt.findSpecificAttachment
import com.infomaniak.mail.utils.extensions.appContext
import com.infomaniak.mail.utils.extensions.htmlToText
import com.infomaniak.mail.utils.extensions.valueOrEmpty
import com.infomaniak.mail.utils.toSafeFileName
import com.infomaniak.mail.utils.uploadAttachmentsWithMutex
import dagger.hilt.android.lifecycle.HiltViewModel
import io.realm.kotlin.MutableRealm
Expand Down Expand Up @@ -803,18 +804,19 @@ class NewMessageViewModel @Inject constructor(
private suspend fun importAttachment(uri: Uri, availableSpace: Long): Pair<Attachment?, Boolean> {

val (fileName, fileSize) = getFileNameAndSize(uri) ?: return null to false
val safeFileName = fileName.toSafeFileName()
val attachment = Attachment()

return LocalStorageUtils.saveAttachmentToUploadDir(
context = appContext,
uri = uri,
fileName = fileName,
fileName = safeFileName,
draftLocalUuid = draftLocalUuid!!,
attachmentLocalUuid = attachment.localUuid,
snackbarManager = snackbarManager,
)?.let { file ->
Pair(
attachment.initLocalValues(fileName, file.length(), file.path.guessMimeType(), file.toUri().toString()),
attachment.initLocalValues(safeFileName, file.length(), file.path.guessMimeType(), file.toUri().toString()),
fileSize > availableSpace,
)
} ?: (null to false)
Expand Down
104 changes: 104 additions & 0 deletions app/src/main/java/com/infomaniak/mail/utils/FileNameUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2026 Infomaniak Network SA
*
* 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 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.mail.utils

import io.sentry.Sentry
import java.io.File
import java.nio.charset.StandardCharsets
import java.text.Normalizer

/** Android’s filesystems limit each path component to 255 UTF-8 bytes.
* 240 leaves 15 bytes for prefixes/suffixes.
* The upload prefix (an Int hash code plus an underscore) needs at most 12 bytes, yielding at most 252 bytes.
* The remaining 3 bytes are conservative headroom. */
private const val MAX_FILE_NAME_SIZE_BYTES = 240
private const val MAX_PRESERVED_EXTENSION_SIZE_BYTES = 32
private const val DEFAULT_FILE_NAME = "attachment"
private val invalidFileNameCharacters = Regex("[\\\\/:*?\"<>|%\\p{Cc}]")

fun String.toSafeFileName(): String {
val normalizedName = Normalizer.normalize(this, Normalizer.Form.NFC)
Comment thread
FabianDevel marked this conversation as resolved.
.replace(invalidFileNameCharacters, "_")
.trim()
.takeUnless { it.isEmpty() || it == "." || it == ".." }
?: DEFAULT_FILE_NAME

return if (normalizedName.utf8Size <= MAX_FILE_NAME_SIZE_BYTES) normalizedName else computeTruncatedFileName(normalizedName)
}

fun File.resolveContainedPath(untrustedPath: String): File? = runCatching {
if (File(untrustedPath).isAbsolute) throw SecurityException("Absolute paths are not allowed")

val canonicalRoot = canonicalFile
val resolvedFile = File(canonicalRoot, untrustedPath).canonicalFile
if (resolvedFile != canonicalRoot && !resolvedFile.toPath().startsWith(canonicalRoot.toPath())) {
throw SecurityException("Resolved path escapes its allowed root")
}

return@runCatching resolvedFile
}.getOrElse {
Sentry.captureException(it)
null
}

fun File.resolveContainedFileName(untrustedName: String): File? {
val resolvedFile = File(this, untrustedName.toSafeFileName())
return if (resolvedFile.parentFile != this) {
Sentry.captureException(SecurityException("Resolved file escapes its allowed directory"))
null
} else {
resolvedFile
}
}

private fun computeTruncatedFileName(normalizedName: String): String {
val extensionStart = normalizedName.lastIndexOf('.').takeIf { it in 1 until normalizedName.lastIndex }
val extension = extensionStart?.let(normalizedName::substring)
?.takeIf { it.utf8Size <= MAX_PRESERVED_EXTENSION_SIZE_BYTES }
.orEmpty()
val fileNameWithoutExtension = normalizedName.dropLast(extension.length)
val truncatedFileName = fileNameWithoutExtension.takeUtf8Bytes(MAX_FILE_NAME_SIZE_BYTES - extension.utf8Size)

return truncatedFileName.ifEmpty { DEFAULT_FILE_NAME } + extension
}

private inline val String.utf8Size: Int get() = toByteArray(StandardCharsets.UTF_8).size

/**
* Compute with utf8 size because Kotlin manipulate utf16 entities
* This avoids character needing 2 bytes like 'é' to count only for 1 in a take(MAX_FILE_NAME_SIZE_BYTES) instead of 2.
*/
private fun String.takeUtf8Bytes(maxBytes: Int): String {
if (maxBytes <= 0) return ""
if (utf8Size <= maxBytes) return this

val result = StringBuilder()
var byteCount = 0
var index = 0
while (index < length) {
val codePoint = codePointAt(index)
val character = String(Character.toChars(codePoint))
val characterSize = character.utf8Size
if (byteCount + characterSize > maxBytes) break

result.append(character)
byteCount += characterSize
index += Character.charCount(codePoint)
}
return result.toString()
}
14 changes: 9 additions & 5 deletions app/src/main/java/com/infomaniak/mail/utils/LocalStorageUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ object LocalStorageUtils {
attachmentPath: String,
userId: Int = AccountUtils.currentUserId,
mailboxId: Int = AccountUtils.currentMailboxId,
): File {
return File(generateRootDir(context.attachmentsCacheRootDir, userId, mailboxId), attachmentPath)
): File? {
val cacheRoot = generateRootDir(context.attachmentsCacheRootDir, userId, mailboxId)
return cacheRoot.resolveContainedPath(attachmentPath)
}

suspend fun downloadThenSaveAttachmentToCacheDir(context: Context, localAttachment: Attachment): Boolean {
Expand All @@ -75,7 +76,10 @@ object LocalStorageUtils {
val attachment = runCatching {
localAttachment.resource?.let { ApiRepository.downloadAttachment(it) }
}.cancellable().getOrNull()
return attachment?.saveAttachmentTo(localAttachment.getCacheFile(context)) == true

val cacheFile = localAttachment.getCacheFile(context) ?: return false

return attachment?.saveAttachmentTo(cacheFile) == true
}

/**
Expand Down Expand Up @@ -128,7 +132,7 @@ object LocalStorageUtils {
return context.contentResolver.openInputStream(uri)?.use { inputStream ->
val attachmentsUploadDir = getAttachmentUploadDir(context, draftLocalUuid, attachmentLocalUuid)
attachmentsUploadDir.mkdirs()
val hashedFileName = "${uri.toString().substringAfter("document/").hashCode()}_$fileName"
val hashedFileName = "${uri.toString().substringAfter("document/").hashCode()}_${fileName.toSafeFileName()}"

return@use getFileToUpload(context, uri, snackbarManager, attachmentsUploadDir, hashedFileName, inputStream)
} ?: run {
Expand All @@ -149,7 +153,7 @@ object LocalStorageUtils {
hashedFileName: String,
inputStream: InputStream,
): File? {
val file = File(attachmentsUploadDir, hashedFileName)
val file = attachmentsUploadDir.resolveContainedFileName(hashedFileName) ?: return null
val isSuccess = runCatching {
FileOutputStream(file).use(inputStream::copyTo)
true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import com.infomaniak.mail.data.models.extensions.getUploadLocalFile
import com.infomaniak.mail.data.models.extensions.hasUsableCache
import com.infomaniak.mail.data.models.extensions.isInlineCachedFile
import com.infomaniak.mail.data.models.extensions.safeMimeType
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.data.models.mailbox.Mailbox
import com.infomaniak.mail.ui.main.SnackbarManager
import com.infomaniak.mail.ui.main.thread.actions.DownloadAttachmentProgressDialogArgs
Expand All @@ -61,8 +62,8 @@ object AttachmentExt {
const val DOWNLOAD_ATTACHMENT_RESULT = "download_attachment_result"

//region Intent
private fun Attachment.saveToDriveIntent(context: Context): Intent {
val fileFromCache = getCacheFile(context)
private fun Attachment.saveToDriveIntent(context: Context): Intent? {
val fileFromCache = getCacheFile(context) ?: return null
val lastModifiedDate = fileFromCache.lastModified()
val uri = FileProvider.getUriForFile(context, context.getString(R.string.ATTACHMENTS_AUTHORITY), fileFromCache)

Expand All @@ -75,8 +76,8 @@ object AttachmentExt {
}
}

private fun Attachment.openWithIntent(context: Context): Intent {
val file = getUploadLocalFile() ?: getCacheFile(context)
private fun Attachment.openWithIntent(context: Context): Intent? {
val file = getUploadLocalFile() ?: getCacheFile(context) ?: return null
val uri = FileProvider.getUriForFile(context, context.getString(R.string.ATTACHMENTS_AUTHORITY), file)

return Intent().apply {
Expand Down Expand Up @@ -113,7 +114,7 @@ object AttachmentExt {
navigateToDownloadProgressDialog: (Attachment, AttachmentIntentType) -> Unit,
snackbarManager: SnackbarManager,
) {
if (openWithIntent(context).hasSupportedApplications(context)) {
if (openWithIntent(context)?.hasSupportedApplications(context) == true) {
executeIntent(context, OPEN_WITH, navigateToDownloadProgressDialog)
} else {
snackbarManager.setValue(context.getString(RCore.string.errorNoSupportingAppFound))
Expand All @@ -123,7 +124,7 @@ object AttachmentExt {
fun Attachment.createDownloadDialogNavArgs(intentType: AttachmentIntentType): Bundle {
return DownloadAttachmentProgressDialogArgs(
attachmentLocalUuid = localUuid,
attachmentName = name,
attachmentName = safeName,
attachmentType = getFileTypeFromMimeType(),
intentType = intentType,
).toBundle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.infomaniak.core.ui.view.extension.setMarginsRelative
import com.infomaniak.mail.R
import com.infomaniak.mail.data.models.Attachable
import com.infomaniak.mail.data.models.extensions.getFileTypeFromMimeType
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.databinding.ViewAttachmentDetailsBinding
import com.infomaniak.core.legacy.R as RCore

Expand Down Expand Up @@ -65,7 +66,7 @@ class AttachmentDetailsView @JvmOverloads constructor(
}

fun setDetails(attachment: Attachable) = with(binding) {
fileName.text = attachment.name
fileName.text = attachment.safeName
fileSize.text = context.formatShortFileSize(attachment.size)
icon.load(attachment.getFileTypeFromMimeType().icon)
}
Expand Down
Loading
Loading