Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use allow and block lists for uploading files and images #3556

Merged
merged 8 commits into from
Jan 14, 2025
1 change: 1 addition & 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/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to add an entry to StreamChatUI, maybe a # Changed section that we now change the media types based on these configs @laevandus WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, it should be mentioned. Thanks

- 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 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 AttachmentTypeDisallowed: ClientError {
init(id: AttachmentId, attachmentType: AttachmentType) {
super.init("`AttachmentDTO` with \(id) has type \(attachmentType) which is marked as disallowed on the Stream dashboard.")
}
}

final class AttachmentEditing: ClientError {
init(id: AttachmentId, reason: String) {
Expand Down
69 changes: 69 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,71 @@ extension AppSettingsPayload.UploadConfigPayload {
)
}
}

// MARK: - Validation

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

public var allowedUTITypes: [String] {
allowedMimeTypes.compactMap { $0.utiType(mime: true) } +
allowedFileExtensions.compactMap { $0.utiType(mime: false) }
}

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
}
}
101 changes: 70 additions & 31 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,60 +85,90 @@ 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
database.write { session in
var model: AnyChatMessageAttachment?
database.write { [weak self] session in
guard let attachment = session.attachment(id: id) else {
completion(nil)
return
throw ClientError.AttachmentDoesNotExist(id: id)
}

if let temporaryURL = attachment.localURL {
guard self?.isAllowedToUpload(attachment.attachmentType, localURL: temporaryURL) == true else {
throw ClientError.AttachmentTypeDisallowed(id: id, attachmentType: attachment.attachmentType)
}
do {
let localURL = try attachmentStorage.storeAttachment(id: id, temporaryURL: temporaryURL)
attachment.localURL = localURL
} catch {
log.error("Could not copy attachment to local storage: \(error.localizedDescription)", subsystems: .offlineSupport)
}
}

let model = attachment.asAnyModel()

model = attachment.asAnyModel()
} completion: { error in
DispatchQueue.main.async {
completion(model)
if let model {
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 +350,7 @@ extension AttachmentQueueUploader {
for attachmentId: AttachmentId,
continuation: CheckedContinuation<UploadedAttachment, Error>
) {
continuationsQueue.async {
queue.async {
self.continuations[attachmentId] = continuation
}
}
Expand All @@ -320,7 +359,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
21 changes: 18 additions & 3 deletions Sources/StreamChatUI/Composer/ComposerVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -405,26 +405,41 @@ open class ComposerVC: _ViewController,

/// The view controller for selecting image attachments.
open private(set) lazy var mediaPickerVC: UIViewController = {
let mediaTypes: [String] = {
let availableTypes = UIImagePickerController.availableMediaTypes(for: .savedPhotosAlbum) ?? ["public.image"]
let allowed = channelController?.client.appSettings?.imageUploadConfig.allowedUTITypes ?? []
return allowed.isEmpty ? availableTypes : allowed
}()
let picker = UIImagePickerController()
picker.mediaTypes = UIImagePickerController.availableMediaTypes(for: .savedPhotosAlbum) ?? ["public.image"]
picker.mediaTypes = mediaTypes
picker.sourceType = .savedPhotosAlbum
picker.delegate = self
return picker
}()
Copy link
Contributor Author

@laevandus laevandus Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default we get public.image and public.video from the availableMediaTypes. I researched it but I haven't found a way to convert these high level UTIs to their subtypes. If I could do that, I could:

  1. Get the list of all subtypes
  2. Filter all of the subtypes based on imageUploadConfig.blockedUTITypes and imageUploadConfig.allowedUTITypes

Currently I did not figure out how to handle the case where I would create imageUploadConfig with, let's say, blocked mime type of image/jpeg, and then assign UTI types to the picker which would exclude that mime type (aka public.jpeg in UTI terms).

So for now, I am only setting allow list to the picker. Which means that, I could still select a jpeg file and start the upload. Upload fails immediately because AttachmentQueueUploaded checks the block list. In this example, checking the image/jpeg mime type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spent some more time and does not seem feasible. I'll keep it like that and if we end up finding a way, we can change it later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am taking this out because it is a risky change. Did not realise this before. Picker will throw an exception if I would just do public.jpeg


/// The View Controller for taking a picture.
open private(set) lazy var cameraVC: UIViewController = {
let mediaTypes: [String] = {
let availableTypes = UIImagePickerController.availableMediaTypes(for: .camera) ?? ["public.image"]
let allowed = channelController?.client.appSettings?.imageUploadConfig.allowedUTITypes ?? []
return allowed.isEmpty ? availableTypes : allowed
}()
let camera = UIImagePickerController()
camera.sourceType = .camera
camera.modalPresentationStyle = .overFullScreen
camera.mediaTypes = UIImagePickerController.availableMediaTypes(for: .camera) ?? ["public.image"]
camera.mediaTypes = mediaTypes
camera.delegate = self
return camera
}()

/// 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
Loading