RadrootsAppleMediaPreparation.swift (11003B)
1 import CryptoKit 2 import Foundation 3 import ImageIO 4 import UniformTypeIdentifiers 5 6 public enum RadrootsAppleMediaPreparationError: Error, Equatable, Sendable { 7 case invalidRequest 8 case unavailable 9 case preparationFailure 10 } 11 12 extension RadrootsAppleMediaPreparationError: LocalizedError { 13 public var errorDescription: String? { 14 switch self { 15 case .invalidRequest: "The media preparation request is invalid." 16 case .unavailable: "Media preparation is unavailable." 17 case .preparationFailure: "The media could not be prepared." 18 } 19 } 20 } 21 22 public struct RadrootsAppleImagePreparationRequest: Sendable, Equatable, Hashable { 23 public let source: RadrootsBackgroundTransferLocalFile 24 public let maximumInputBytes: Int 25 public let maximumOutputBytes: Int 26 public let maximumPixelCount: Int 27 public let maximumDimension: Int 28 29 public init( 30 source: RadrootsBackgroundTransferLocalFile, maximumInputBytes: Int = 40 * 1024 * 1024, 31 maximumOutputBytes: Int = 10 * 1024 * 1024, maximumPixelCount: Int = 40_000_000, maximumDimension: Int = 4096 32 ) throws { 33 guard (1 ... (40 * 1024 * 1024)).contains(maximumInputBytes), 34 (1 ... (10 * 1024 * 1024)).contains(maximumOutputBytes), 35 (1 ... 40_000_000).contains(maximumPixelCount), (1 ... 8192).contains(maximumDimension) 36 else { throw RadrootsAppleMediaPreparationError.invalidRequest } 37 do { try RadrootsBackgroundTransferValidation.validateLocalFile(source) } catch { 38 throw RadrootsAppleMediaPreparationError.invalidRequest 39 } 40 self.source = source 41 self.maximumInputBytes = maximumInputBytes 42 self.maximumOutputBytes = maximumOutputBytes 43 self.maximumPixelCount = maximumPixelCount 44 self.maximumDimension = maximumDimension 45 } 46 } 47 48 public struct RadrootsApplePreparedImage: Sendable, Equatable, Hashable, CustomDebugStringConvertible { 49 public let file: RadrootsStagedBlobReference 50 public let sha256: String 51 public let width: UInt32 52 public let height: UInt32 53 54 public init( 55 file: RadrootsStagedBlobReference, sha256: String, width: UInt32, height: UInt32 56 ) throws { 57 guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, width > 0, 58 height > 0, file.sizeBytes > 0, 59 file.mediaType == "image/png" 60 else { throw RadrootsAppleMediaPreparationError.invalidRequest } 61 self.file = file 62 self.sha256 = sha256 63 self.width = width 64 self.height = height 65 } 66 67 public var debugDescription: String { 68 "RadrootsApplePreparedImage(sha256: \(sha256), sizeBytes: \(file.sizeBytes), " 69 + "width: \(width), height: \(height))" 70 } 71 } 72 73 public actor RadrootsAppleMediaPreparer { 74 private let roots: RadrootsAppleFileRoots 75 private let resolver: RadrootsAppleBackgroundTransferFileResolver 76 private let fileManager: FileManager 77 private let protectedData: RadrootsProtectedDataProvider 78 79 public init( 80 roots: RadrootsAppleFileRoots, fileManager: FileManager = .default, 81 protectedData: RadrootsProtectedDataProvider = .available 82 ) { 83 self.roots = roots 84 resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) 85 self.fileManager = fileManager 86 self.protectedData = protectedData 87 } 88 89 /// Prepares a single-frame JPEG, PNG or HEIF/HEIC raster with at most 90 /// eight-bit source components. Source axes are limited to 32,768 pixels; 91 /// the existing request limits and a 512 MiB raster working-byte estimate 92 /// apply before decoding. One request decodes at a time per preparer. 93 /// The returned PNG contains oriented standard-sRGB pixels, without source 94 /// location, device, comment or camera-profile metadata. 95 public func prepareImage( 96 _ request: RadrootsAppleImagePreparationRequest 97 ) async throws -> RadrootsApplePreparedImage { 98 // One actor-owned, non-suspending decode at a time. Drain native temporary 99 // objects before another queued request can allocate its raster buffers. 100 do { return try autoreleasepool { try prepareValidatedImage(request) } } catch is CancellationError { 101 throw CancellationError() 102 } catch let error as RadrootsAppleMediaPreparationError { 103 throw error 104 } catch { throw RadrootsAppleMediaPreparationError.preparationFailure } 105 } 106 107 private func prepareValidatedImage( 108 _ request: RadrootsAppleImagePreparationRequest 109 ) throws -> RadrootsApplePreparedImage { 110 try Task.checkCancellation() 111 try requireProtectedData() 112 let sourceData = try readSource(request) 113 let normalizedImage = try RadrootsAppleImageDecode.normalizedImage(sourceData, request: request) 114 try Task.checkCancellation() 115 116 let temporaryURL = roots.temporaryRoot.appendingPathComponent( 117 "media_preparation", isDirectory: true 118 ).appendingPathComponent( 119 "\(UUID().uuidString.lowercased()).png" 120 ).standardizedFileURL 121 defer { 122 if fileManager.fileExists(atPath: temporaryURL.path) { 123 try? fileManager.removeItem(at: temporaryURL) 124 } 125 } 126 try encodePNG(normalizedImage, at: temporaryURL) 127 try Task.checkCancellation() 128 let outputSize = try Self.fileSize(at: temporaryURL) 129 guard outputSize > 0, outputSize <= request.maximumOutputBytes else { 130 throw RadrootsAppleMediaPreparationError.invalidRequest 131 } 132 let digest = try RadrootsAppleFileDigest.sha256(at: temporaryURL) 133 let staged = try RadrootsStagedBlobReference( 134 blobID: digest, sizeBytes: outputSize, mediaType: "image/png", filenameHint: "\(digest).png" 135 ) 136 try Task.checkCancellation() 137 try requireProtectedData() 138 try Task.checkCancellation() 139 let relative = "media_preparation/" + temporaryURL.lastPathComponent 140 let bytes = try RadrootsGovernedFileReader.read( 141 root: roots.temporaryRoot, relativePath: relative, maximumBytes: request.maximumOutputBytes 142 ) 143 guard bytes.count == outputSize, RadrootsAppleFileDigest.sha256(bytes) == digest else { 144 throw RadrootsAppleMediaPreparationError.preparationFailure 145 } 146 try Task.checkCancellation() 147 try RadrootsAppleFileAccess(roots: roots, fileManager: fileManager).installStagedBlob(bytes, reference: staged) 148 let stagedURL = try roots.stagedBlobURL(for: staged) 149 #if os(iOS) 150 try fileManager.setAttributes( 151 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], 152 ofItemAtPath: stagedURL.path 153 ) 154 #endif 155 return try RadrootsApplePreparedImage( 156 file: staged, sha256: digest, width: UInt32(normalizedImage.width), 157 height: UInt32(normalizedImage.height) 158 ) 159 } 160 161 public func blossomUploadRequest( 162 preparedImage: RadrootsApplePreparedImage, remoteURL: URL, authorization: String, 163 networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS, 164 identifier: RadrootsBackgroundTransferIdentifier = .generated() 165 ) throws -> RadrootsBackgroundTransferRequest { 166 do { 167 let preparedData = try resolver.read( 168 .stagedBlob(preparedImage.file), maximumBytes: preparedImage.file.sizeBytes 169 ) 170 guard preparedData.count == preparedImage.file.sizeBytes, 171 RadrootsAppleFileDigest.sha256(preparedData) == preparedImage.sha256 172 else { throw RadrootsAppleMediaPreparationError.invalidRequest } 173 return try RadrootsBackgroundTransferRequest( 174 identifier: identifier, remoteURL: remoteURL, method: .put, 175 operation: .upload(source: .stagedBlob(preparedImage.file)), 176 headers: [ 177 "Authorization": authorization, "Content-Type": "image/png", 178 "X-SHA-256": preparedImage.sha256, 179 "Accept": "application/json", "Accept-Encoding": "identity" 180 ], 181 metadata: ["purpose": "blossom_upload", "sha256": preparedImage.sha256], 182 networkPolicy: networkPolicy, 183 responsePolicy: .boundedJSON(), expectedSourceSHA256: preparedImage.sha256 184 ) 185 } catch let error as RadrootsAppleMediaPreparationError { throw error 186 } catch let error as RadrootsBackgroundTransferError { 187 throw error 188 } catch { throw RadrootsAppleMediaPreparationError.preparationFailure } 189 } 190 191 private func readSource(_ request: RadrootsAppleImagePreparationRequest) throws -> Data { 192 do { 193 return try resolver.read(request.source, maximumBytes: request.maximumInputBytes) 194 } catch { 195 throw RadrootsAppleMediaPreparationError.invalidRequest 196 } 197 } 198 199 private func encodePNG(_ image: CGImage, at url: URL) throws { 200 try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) 201 #if os(iOS) 202 try fileManager.setAttributes( 203 [.protectionKey: FileProtectionType.complete], 204 ofItemAtPath: url.deletingLastPathComponent().path 205 ) 206 #endif 207 guard let destination = CGImageDestinationCreateWithURL( 208 url as CFURL, UTType.png.identifier as CFString, 1, nil 209 ) else { 210 throw RadrootsAppleMediaPreparationError.preparationFailure 211 } 212 CGImageDestinationAddImage(destination, image, [:] as CFDictionary) 213 guard CGImageDestinationFinalize(destination) else { 214 throw RadrootsAppleMediaPreparationError.preparationFailure 215 } 216 } 217 218 private func requireProtectedData() throws { 219 guard protectedData.currentState() == .available else { 220 throw RadrootsAppleMediaPreparationError.unavailable 221 } 222 } 223 224 private static func fileSize(at url: URL) throws -> Int { 225 guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { 226 throw RadrootsAppleMediaPreparationError.preparationFailure 227 } 228 return size 229 } 230 } 231 232 enum RadrootsAppleFileDigest { 233 static func sha256(_ data: Data) -> String { 234 CryptoKit.SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() 235 } 236 237 static func sha256(at url: URL) throws -> String { 238 let handle = try FileHandle(forReadingFrom: url) 239 defer { try? handle.close() } 240 var hasher = CryptoKit.SHA256() 241 while true { 242 let chunk = try handle.read(upToCount: 64 * 1024) ?? Data() 243 if chunk.isEmpty { 244 break 245 } 246 hasher.update(data: chunk) 247 } 248 return hasher.finalize().map { String(format: "%02x", $0) }.joined() 249 } 250 }