Skip to content

Commit

Permalink
Use allow and block lists for uploading files and images (#3556)
Browse files Browse the repository at this point in the history
  • Loading branch information
laevandus authored Jan 14, 2025
1 parent 75aa44f commit f3fccb0
Show file tree
Hide file tree
Showing 9 changed files with 427 additions and 31 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## StreamChat
### ✅ Added
- Use `AppSettings.fileUploadConfig` and `AppSettings.imageUploadConfig` for blocking attachment uploads [#3556](https://github.com/GetStream/stream-chat-swift/pull/3556)
- Add `FilterKey.disabled` and `ChatChannel.isDisabled` [#3546](https://github.com/GetStream/stream-chat-swift/pull/3546)
- Add `ImageAttachmentPayload.file` for setting `file_size` and `mime_type` for image attachments [#3548](https://github.com/GetStream/stream-chat-swift/pull/3548)
### 🐞 Fixed
Expand All @@ -16,6 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### 🔄 Changed
- Deprecate `ImageAttachmentPayload.init(title:imageRemoteURL:originalWidth:originalHeight:extraData:)` in favor of `ImageAttachmentPayload.init(title:imageRemoteURL:file:originalWidth:originalHeight:extraData:)` [#3548](https://github.com/GetStream/stream-chat-swift/pull/3548)

## StreamChatUI
### 🔄 Changed
- Set supported media types based on `AppSettings` in `ComposerVC.filePickerVC` [#3556](https://github.com/GetStream/stream-chat-swift/pull/3556)

# [4.69.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.69.0)
_December 18, 2024_

Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,8 @@ public class ChatClient {
case let .success(payload):
let appSettings = payload.asModel()
self?.appSettings = appSettings
try? self?.backgroundWorker(of: AttachmentQueueUploader.self)
.setAppSettings(appSettings)
completion?(.success(appSettings))
case let .failure(error):
completion?(.failure(error))
Expand Down Expand Up @@ -614,6 +616,8 @@ public class ChatClient {
attachmentPostProcessor: config.uploadedAttachmentPostProcessor
)
]
try? backgroundWorker(of: AttachmentQueueUploader.self)
.setAppSettings(appSettings)
}

func completeConnectionIdWaiters(connectionId: String?) {
Expand Down
6 changes: 6 additions & 0 deletions Sources/StreamChat/Database/DTOs/AttachmentDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,12 @@ extension ClientError {
super.init("There is no `AttachmentDTO` instance in the DB matching id: \(id).")
}
}

final class AttachmentUploadBlocked: ClientError {
init(id: AttachmentId, attachmentType: AttachmentType, pathExtension: String) {
super.init("`AttachmentDTO` with \(id) and type \(attachmentType) and path extension \(pathExtension) is blocked on the Stream dashboard.")
}
}

final class AttachmentEditing: ClientError {
init(id: AttachmentId, reason: String) {
Expand Down
71 changes: 71 additions & 0 deletions Sources/StreamChat/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//

import Foundation
import MobileCoreServices

/// A type representing the app settings.
public struct AppSettings {
Expand Down Expand Up @@ -57,3 +58,73 @@ extension AppSettingsPayload.UploadConfigPayload {
)
}
}

// MARK: - Validation

extension AppSettings.UploadConfig {
// MARK: - UTI Validation

/// Returns an array of allowed UTI identifiers based on allowed mime types and file extensions.
public var allowedUTITypes: [String] {
allowedMimeTypes.compactMap { $0.utiType(mime: true) } +
allowedFileExtensions.compactMap { $0.utiType(mime: false) }
}

/// Returns an array of blocked UTI identifiers based on allowed mime types and file extensions.
public var blockedUTITypes: [String] {
blockedMimeTypes.compactMap { $0.utiType(mime: true) } +
blockedFileExtensions.compactMap { $0.utiType(mime: false) }
}

// MARK: - URL Validation

func isAllowed(localURL: URL) -> Bool {
guard !localURL.pathExtension.isEmpty else { return true }

if !allowedFileExtensions.isEmpty || !blockedFileExtensions.isEmpty {
if !isAllowed(pathExtension: localURL.pathExtension.lowercased()) {
return false
}
}
if !allowedMimeTypes.isEmpty || !blockedMimeTypes.isEmpty {
let mimeType = AttachmentFileType(ext: localURL.pathExtension).mimeType.lowercased()
if !isAllowed(mimeType: mimeType) {
return false
}
}
return true
}

private func isAllowed(pathExtension: String) -> Bool {
let isBlocked = blockedFileExtensions.contains { blocked in
blocked.drop(while: { $0 == Character(".") }).caseInsensitiveCompare(pathExtension) == .orderedSame
}
guard !isBlocked else { return false }
guard !allowedFileExtensions.isEmpty else { return true }
return allowedFileExtensions.contains { allowed in
allowed.drop(while: { $0 == Character(".") }).caseInsensitiveCompare(pathExtension) == .orderedSame
}
}

private func isAllowed(mimeType: String) -> Bool {
let isBlocked = blockedMimeTypes.contains { blocked in
blocked.caseInsensitiveCompare(mimeType) == .orderedSame
}
guard !isBlocked else { return false }
guard !allowedMimeTypes.isEmpty else { return true }
return allowedMimeTypes.contains { allowed in
allowed.caseInsensitiveCompare(mimeType) == .orderedSame
}
}
}

private extension String {
func utiType(mime: Bool) -> String? {
let string = mime ? self : String(drop(while: { $0 == Character(".") }))
return UTTypeCreatePreferredIdentifierForTag(
mime ? kUTTagClassMIMEType : kUTTagClassFilenameExtension,
string as CFString,
nil
)?.takeRetainedValue() as? String
}
}
111 changes: 81 additions & 30 deletions Sources/StreamChat/Workers/Background/AttachmentQueueUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ class AttachmentQueueUploader: Worker {
private let attachmentUpdater = AnyAttachmentUpdater()
private let attachmentStorage = AttachmentStorage()
private var continuations = [AttachmentId: CheckedContinuation<UploadedAttachment, Error>]()
private let continuationsQueue = DispatchQueue(label: "co.getStream.ChatClient.AttachmentQueueUploader")
private let queue = DispatchQueue(label: "co.getStream.ChatClient.AttachmentQueueUploader", target: .global(qos: .utility))
private var fileUploadConfig: AppSettings.UploadConfig?
private var imageUploadConfig: AppSettings.UploadConfig?

var minSignificantUploadingProgressChange: Double = 0.05

Expand All @@ -43,6 +45,13 @@ class AttachmentQueueUploader: Worker {

startObserving()
}

func setAppSettings(_ appSettings: AppSettings?) {
queue.async { [weak self] in
self?.fileUploadConfig = appSettings?.fileUploadConfig
self?.imageUploadConfig = appSettings?.imageUploadConfig
}
}

// MARK: - Private

Expand Down Expand Up @@ -76,42 +85,54 @@ class AttachmentQueueUploader: Worker {
}

private func uploadAttachment(with id: AttachmentId) {
prepareAttachmentForUpload(with: id) { [weak self] attachment in
guard let attachment = attachment else {
self?.removePendingAttachment(with: id, result: .failure(ClientError.AttachmentDoesNotExist(id: id)))
return
}

self?.apiClient.uploadAttachment(
attachment,
progress: {
prepareAttachmentForUpload(with: id) { [weak self] result in
switch result {
case .failure(let error):
if error is ClientError.AttachmentDoesNotExist {
self?.removePendingAttachment(with: id, result: .failure(error))
} else {
self?.updateAttachmentIfNeeded(
attachmentId: id,
uploadedAttachment: nil,
newState: .uploading(progress: $0),
completion: {}
)
},
completion: { result in
self?.updateAttachmentIfNeeded(
attachmentId: id,
uploadedAttachment: result.value,
newState: result.error == nil ? .uploaded : .uploadingFailed,
newState: .uploadingFailed,
completion: {
self?.removePendingAttachment(with: id, result: result)
self?.removePendingAttachment(with: id, result: .failure(error))
}
)
}
)
case .success(let attachment):
self?.apiClient.uploadAttachment(
attachment,
progress: {
self?.updateAttachmentIfNeeded(
attachmentId: id,
uploadedAttachment: nil,
newState: .uploading(progress: $0),
completion: {}
)
},
completion: { result in
self?.updateAttachmentIfNeeded(
attachmentId: id,
uploadedAttachment: result.value,
newState: result.error == nil ? .uploaded : .uploadingFailed,
completion: {
self?.removePendingAttachment(with: id, result: result)
}
)
}
)
}
}
}

private func prepareAttachmentForUpload(with id: AttachmentId, completion: @escaping (AnyChatMessageAttachment?) -> Void) {
private func prepareAttachmentForUpload(with id: AttachmentId, completion: @escaping (Result<AnyChatMessageAttachment, Error>) -> Void) {
let attachmentStorage = self.attachmentStorage
var model: AnyChatMessageAttachment?
var attachmentLocalURL: URL?
database.write { session in
guard let attachment = session.attachment(id: id) else {
completion(nil)
return
throw ClientError.AttachmentDoesNotExist(id: id)
}

if let temporaryURL = attachment.localURL {
Expand All @@ -122,14 +143,44 @@ class AttachmentQueueUploader: Worker {
log.error("Could not copy attachment to local storage: \(error.localizedDescription)", subsystems: .offlineSupport)
}
}

let model = attachment.asAnyModel()

attachmentLocalURL = attachment.localURL
model = attachment.asAnyModel()
} completion: { [weak self] error in
DispatchQueue.main.async {
completion(model)
if let model {
// Attachment uploading state should be validated after preparing the local file (for ensuring the local file persists for retry)
if let attachmentLocalURL, self?.isAllowedToUpload(model.type, localURL: attachmentLocalURL) == false {
completion(
.failure(
ClientError.AttachmentUploadBlocked(
id: id,
attachmentType: model.type,
pathExtension: attachmentLocalURL.pathExtension
)
)
)
} else {
completion(.success(model))
}
} else if let error {
completion(.failure(error))
} else {
completion(.failure(ClientError.Unknown("Incorrect completion handling in AttachmentQueueUploader")))
}
}
}
}

private func isAllowedToUpload(_ attachmentType: AttachmentType, localURL: URL) -> Bool {
switch attachmentType {
case .image:
return queue.sync { imageUploadConfig?.isAllowed(localURL: localURL) ?? true }
case .audio, .file, .unknown, .video, .voiceRecording:
return queue.sync { fileUploadConfig?.isAllowed(localURL: localURL) ?? true }
default:
return true
}
}

private func removePendingAttachment(with id: AttachmentId, result: Result<UploadedAttachment, Error>) {
_pendingAttachmentIDs.mutate { $0.remove(id) }
Expand Down Expand Up @@ -311,7 +362,7 @@ extension AttachmentQueueUploader {
for attachmentId: AttachmentId,
continuation: CheckedContinuation<UploadedAttachment, Error>
) {
continuationsQueue.async {
queue.async {
self.continuations[attachmentId] = continuation
}
}
Expand All @@ -320,7 +371,7 @@ extension AttachmentQueueUploader {
for attachmentId: AttachmentId,
result: Result<UploadedAttachment, Error>
) {
continuationsQueue.async {
queue.async {
guard let continuation = self.continuations.removeValue(forKey: attachmentId) else { return }
continuation.resume(with: result)
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/StreamChatUI/Composer/ComposerVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,12 @@ open class ComposerVC: _ViewController,

/// The view controller for selecting file attachments.
open private(set) lazy var filePickerVC: UIViewController = {
let picker = UIDocumentPickerViewController(documentTypes: ["public.item"], in: .import)
let documentTypes: [String] = {
let availableTypes = ["public.item"]
let allowed = channelController?.client.appSettings?.fileUploadConfig.allowedUTITypes ?? []
return allowed.isEmpty ? availableTypes : allowed
}()
let picker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import)
picker.delegate = self
picker.allowsMultipleSelection = true
return picker
Expand Down
4 changes: 4 additions & 0 deletions StreamChat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@
4F5151982BC407ED001B7152 /* UserList_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5151972BC407ED001B7152 /* UserList_Tests.swift */; };
4F51519A2BC57C40001B7152 /* MessageState_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5151992BC57C40001B7152 /* MessageState_Tests.swift */; };
4F51519C2BC66FBE001B7152 /* Task+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F51519B2BC66FBE001B7152 /* Task+Extensions.swift */; };
4F6A77042D2FD0A00019CAF8 /* AppSettings_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6A77032D2FD09A0019CAF8 /* AppSettings_Tests.swift */; };
4F6AD5E42CABEAB6007E769C /* KeyPath+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6AD5E32CABEAB0007E769C /* KeyPath+Extensions.swift */; };
4F6AD5E52CABEAB6007E769C /* KeyPath+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6AD5E32CABEAB0007E769C /* KeyPath+Extensions.swift */; };
4F6B84102D008D6E005645B0 /* MemberUpdatePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6B840F2D008D5F005645B0 /* MemberUpdatePayload.swift */; };
Expand Down Expand Up @@ -3211,6 +3212,7 @@
4F5151992BC57C40001B7152 /* MessageState_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageState_Tests.swift; sourceTree = "<group>"; };
4F51519B2BC66FBE001B7152 /* Task+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Extensions.swift"; sourceTree = "<group>"; };
4F5758F02BB45B2F00D89A94 /* ChannelList_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelList_Tests.swift; sourceTree = "<group>"; };
4F6A77032D2FD09A0019CAF8 /* AppSettings_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings_Tests.swift; sourceTree = "<group>"; };
4F6AD5E32CABEAB0007E769C /* KeyPath+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyPath+Extensions.swift"; sourceTree = "<group>"; };
4F6B840F2D008D5F005645B0 /* MemberUpdatePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberUpdatePayload.swift; sourceTree = "<group>"; };
4F73F3972B91BD3000563CD9 /* MessageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageState.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7249,6 +7251,7 @@
A364D0A027D0C8690029857A /* Models */ = {
isa = PBXGroup;
children = (
4F6A77032D2FD09A0019CAF8 /* AppSettings_Tests.swift */,
8A5D3EF824AF749200E2FE35 /* ChannelId_Tests.swift */,
84FD350727FD8BE300D68D85 /* ChatChannel_Tests.swift */,
64C80614262EDA9600B1F7AD /* ChatMessage_Tests.swift */,
Expand Down Expand Up @@ -12012,6 +12015,7 @@
8A0D649D24E579F70017A3C0 /* GuestUserTokenPayload_Tests.swift in Sources */,
AD9490622BF66D1E00E69224 /* ThreadsRepository_Mock.swift in Sources */,
8A0C3BE224C1F74200CAFD19 /* MessageEvents_Tests.swift in Sources */,
4F6A77042D2FD0A00019CAF8 /* AppSettings_Tests.swift in Sources */,
7990503224CEEAA600689CDC /* MessageDTO_Tests.swift in Sources */,
88D85DAB252F3C2A00AE1030 /* MemberListController_Tests.swift in Sources */,
88DA577E2631D73800FA8C53 /* ChannelMuteDTO_Tests.swift in Sources */,
Expand Down
Loading

0 comments on commit f3fccb0

Please sign in to comment.