TM-SGNL-iOS/SignalServiceKit/Network/OWSMultipart.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

202 lines
7.7 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
struct OWSMultipartTextPart {
let key: String
let value: String
}
/// Based on AFNetworking's AFURLRequestSerialization.
enum OWSMultipartBody {
static func createMultipartFormBoundary() -> String {
String(format: "Boundary+%016llX", CUnsignedLongLong.random(in: .min ... .max))
}
static func write(inputFile: URL, outputFile: URL, name: String, fileName: String, mimeType: String, boundary: String, textParts: any Sequence<OWSMultipartTextPart>) throws {
guard outputFile.isFileURL else {
throw urlErrorNotAFileUrl
}
// TODO: Audit streamStatus
guard let outputStream = OutputStream(url: outputFile, append: false) else {
throw urlErrorUnreachable
}
let outputStreamDelegate = OWSMultipartStreamDelegate()
outputStream.delegate = outputStreamDelegate
outputStream.schedule(in: .current, forMode: .default)
outputStream.open()
guard outputStream.streamStatus == .open else {
throw urlErrorUnreachable
}
do {
defer {
outputStream.remove(from: .current, forMode: .default)
outputStream.close()
}
var isFirstPart = true
for textPart in textParts {
try write(textPart, boundary: boundary, initialBoundary: isFirstPart, finalBoundary: false, outputStream: outputStream)
isFirstPart = false
}
try writeBodyPart(inputFile: inputFile, name: name, fileName: fileName, mimeType: mimeType, boundary: boundary, initialBoundary: isFirstPart, finalBoundary: true, outputStream: outputStream)
}
guard outputStream.streamStatus == .closed && !outputStreamDelegate.hadError else {
throw URLError(.badURL)
}
}
private static func writeBodyPart(inputFile: URL, name: String, fileName: String, mimeType: String, boundary: String, initialBoundary: Bool, finalBoundary: Bool, outputStream: OutputStream) throws {
let startingBoundary = initialBoundary ? AFMultipartForm.initialBoundary(boundary) : AFMultipartForm.encapsulationBoundary(boundary)
try write(Data(startingBoundary.utf8), outputStream: outputStream)
let headers = stringForHeaders(headersForBody(name: name, fileName: fileName, mimeType: mimeType))
try write(Data(headers.utf8), outputStream: outputStream)
try write(inputFile: inputFile, outputStream: outputStream)
let endingBoundary = finalBoundary ? AFMultipartForm.finalBoundary(boundary) : ""
try write(Data(endingBoundary.utf8), outputStream: outputStream)
}
private static func write(inputFile: URL, outputStream: OutputStream) throws {
guard inputFile.isFileURL else {
throw urlErrorNotAFileUrl
}
guard try inputFile.checkResourceIsReachable() else {
throw urlErrorUnreachable
}
guard let inputStream = InputStream(url: inputFile) else {
throw urlErrorUnreachable
}
let inputStreamDelegate = OWSMultipartStreamDelegate()
inputStream.delegate = inputStreamDelegate
inputStream.schedule(in: .current, forMode: .default)
inputStream.open()
guard inputStream.streamStatus == .open else {
throw urlErrorUnreachable
}
do {
defer {
inputStream.remove(from: .current, forMode: .default)
inputStream.close()
}
try write(inputStream, outputStream: outputStream)
}
guard inputStream.streamStatus == .closed && !inputStreamDelegate.hadError else {
throw URLError(.badURL)
}
}
private static func write(_ inputStream: InputStream, outputStream: OutputStream) throws {
var buffer = Data(count: 8192)
while inputStream.hasBytesAvailable {
guard outputStream.hasSpaceAvailable else {
throw URLError(.badURL)
}
let numberOfBytesRead = buffer.withUnsafeMutableBytes {
inputStream.read($0.baseAddress!, maxLength: $0.count)
}
if numberOfBytesRead < 0 {
throw URLError(.badURL)
}
if numberOfBytesRead == 0 {
return
}
try write(buffer.prefix(numberOfBytesRead), outputStream: outputStream)
}
}
/// Assumes `textPart`'s key and value are safe strings to place into a multi-part form without further escaping.
private static func write(_ textPart: OWSMultipartTextPart, boundary: String, initialBoundary: Bool, finalBoundary: Bool, outputStream: OutputStream) throws {
owsPrecondition(!textPart.value.isEmpty)
owsPrecondition(!textPart.key.isEmpty)
let startingBoundary = initialBoundary ? AFMultipartForm.initialBoundary(boundary) : AFMultipartForm.encapsulationBoundary(boundary)
try write(Data(startingBoundary.utf8), outputStream: outputStream)
let headersString = stringForHeaders([
"Content-Disposition": "form-data; name=\"\(textPart.key)\""
])
try write(Data(headersString.utf8), outputStream: outputStream)
try write(Data(textPart.value.utf8), outputStream: outputStream)
let endingBoundary = finalBoundary ? AFMultipartForm.finalBoundary(boundary) : ""
try write(Data(endingBoundary.utf8), outputStream: outputStream)
}
private static func write(_ data: Data, outputStream: OutputStream) throws {
var dataToWrite = data
while !dataToWrite.isEmpty {
let bytesWritten = dataToWrite.withUnsafeBytes { bufferPtr in
outputStream.write(bufferPtr.baseAddress!, maxLength: bufferPtr.count)
}
if bytesWritten < 1 {
throw URLError(.badURL)
}
dataToWrite = dataToWrite.dropFirst(bytesWritten)
}
}
private static func headersForBody(name: String, fileName: String, mimeType: String) -> KeyValuePairs<String, String> {
[
"Content-Disposition": "form-data; name=\"\(name)\"; filename=\"\(fileName)\"",
"Content-Type": mimeType,
]
}
/// This function assumes the keys and values are safe character sets for use in headers and does no escaping.
private static func stringForHeaders(_ headers: any Sequence<(key: String, value: String)>) -> String {
var result = String()
for (field, value) in headers {
result += "\(field): \(value)\r\n"
}
result += "\r\n"
return result
}
private static let urlErrorNotAFileUrl = URLError(.badURL, userInfo: [NSLocalizedFailureReasonErrorKey: NSLocalizedString("Expected URL to be a file URL", tableName: "AFNetworking", comment: "")])
private static let urlErrorUnreachable = URLError(.badURL, userInfo: [NSLocalizedFailureReasonErrorKey: NSLocalizedString("File URL not reachable.", tableName: "AFNetworking", comment: "")])
}
private class OWSMultipartStreamDelegate: NSObject, StreamDelegate {
@Atomic var hadError = false
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
if eventCode == .errorOccurred {
hadError = true
}
}
}
private enum AFMultipartForm {
@inlinable
static func initialBoundary(_ boundary: String) -> String {
"--\(boundary)\r\n"
}
@inlinable
static func encapsulationBoundary(_ boundary: String) -> String {
"\r\n--\(boundary)\r\n"
}
@inlinable
static func finalBoundary(_ boundary: String) -> String {
"\r\n--\(boundary)--\r\n"
}
}