diff --git a/Modules/Package.resolved b/Modules/Package.resolved index b36c195bea18..6a70c94bb00e 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "97462f04f7472535df3293d3f7601aaff8a4684769091394d4b1ca5bde9f8ed1", + "originHash" : "61e4f4fbfd341181ca9b893646e997a1b3b76afe110c2500eb4b500401ef1e34", "pins" : [ { "identity" : "alamofire", @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "b6604e26792725b2e8125b3b715e1d4a298441b6", - "version" : "0.19.0" + "revision" : "297fcb55e3694c4f325468386972ac89f414cc70", + "version" : "0.20.0-alpha.0" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 4b1217724507..ec15ba8a087f 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -62,7 +62,7 @@ let package = Package( revision: "b34794c9a3f32312e1593d4a3d120572afa0d010" ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), - .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.19.0"), + .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.20.0-alpha.0"), .package( url: "https://github.com/automattic/wordpress-rs", exact: "0.6.0" diff --git a/Package.resolved b/Package.resolved index e4f561d43c78..bba5f23b413e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e5b9b888f12b9e2adfe5e293101989f58ec52d16d70ef0002c28093d8c2ed39f", + "originHash" : "1bbbb11e671a32eda51b479a3e29fd18839bb400ff80395fba2ed7a8553e9baa", "pins" : [ { "identity" : "alamofire", @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "7180587f49d3c3bfdb34cc3e80b2a9d22a3cd93e", - "version" : "0.18.1" + "revision" : "297fcb55e3694c4f325468386972ac89f414cc70", + "version" : "0.20.0-alpha.0" } }, { diff --git a/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift b/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift new file mode 100644 index 000000000000..91273aab7963 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift @@ -0,0 +1,422 @@ +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +import WordPressShared + +@testable import WordPress + +struct GBKMediaUploadProcessorTests { + + // MARK: - Images + + @Test func imageIsResizedWhenOptimizationEnabled() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = true + settings.maxImageSizeSetting = 200 + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-image-device-photo-gps.jpg") + + let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent) + + guard case .processed(let outputURL, let mimeType, let filename) = result else { + Issue.record("Expected a processed file") + return + } + defer { cleanUp(outputURL) } + let size = try imageSize(at: outputURL) + #expect(max(size.width, size.height) == 200) + #expect(mimeType == "image/jpeg") + #expect(filename.hasPrefix("test-image-device-photo-gps")) + } + + @Test func imageIsUntouchedWhenProcessingWouldBeNoOp() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = false + settings.removeLocationSetting = false + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-image-device-photo-gps.jpg") + + let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent) + + guard case .original = result else { + Issue.record("Expected the original file to pass through") + return + } + } + + @Test func gpsDataIsStrippedWhenRemoveLocationEnabled() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = false + settings.removeLocationSetting = true + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-image-device-photo-gps.jpg") + + let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent) + + guard case .processed(let outputURL, _, _) = result else { + Issue.record("Expected a processed file") + return + } + defer { cleanUp(outputURL) } + #expect(try imageProperties(at: url)[kCGImagePropertyGPSDictionary] != nil) + #expect(try imageProperties(at: outputURL)[kCGImagePropertyGPSDictionary] == nil) + } + + @Test func heicIsConvertedToJPEG() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = false + settings.removeLocationSetting = false + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("iphone-photo.heic") + + let result = try await processor.processFile(at: url, mimeType: "image/heic", filename: url.lastPathComponent) + + guard case .processed(let outputURL, let mimeType, let filename) = result else { + Issue.record("Expected a processed file") + return + } + defer { cleanUp(outputURL) } + #expect(mimeType == "image/jpeg") + #expect(filename.hasSuffix(".jpg") || filename.hasSuffix(".jpeg")) + } + + /// Destination names come from a check-then-act `fileExists` loop, and + /// GutenbergKit processes uploads concurrently, so exports of the same + /// source must not share a directory to race in. + @Test func concurrentExportsOfTheSameFileDoNotCollide() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = true + settings.maxImageSizeSetting = 200 + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-image-device-photo-gps.jpg") + + let outputURLs = try await withThrowingTaskGroup(of: URL.self) { group in + for _ in 0..<8 { + group.addTask { + let result = try await processor.processFile( + at: url, + mimeType: "image/jpeg", + filename: url.lastPathComponent + ) + guard case .processed(let outputURL, _, _) = result else { + throw ProcessingError.expectedProcessedFile + } + return outputURL + } + } + return try await group.reduce(into: [URL]()) { $0.append($1) } + } + defer { outputURLs.forEach(cleanUp) } + + // Every export is its own file, and every one of them survived the + // others finishing rather than being overwritten or swept away. + #expect(Set(outputURLs).count == outputURLs.count) + for outputURL in outputURLs { + #expect(FileManager.default.fileExists(atPath: outputURL.path)) + #expect(max(try imageSize(at: outputURL).width, try imageSize(at: outputURL).height) == 200) + } + } + + /// A failed export must not leave its temporary directory behind: nothing + /// else sweeps it, so an abandoned export would outlive the app session. + /// + /// The video exporter throws after `makeLocalMediaURL` has already created + /// the directory, which is exactly what an implementation without the + /// failure-path cleanup would leak. + @Test func failedExportLeavesNoDirectoryBehind() async throws { + let directory = MediaDirectory.temporary(id: UUID()) + let processor = GBKMediaUploadProcessor( + videoDurationLimit: 1, + allowableFileExtensions: [], + makeMediaSettings: makeSettingsFactory(makeSettings()), + makeExportDirectory: { directory } + ) + let url = try fixtureURL("test-video-device-gps.m4v") + + await #expect(throws: (any Error).self) { + try await processor.processFile(at: url, mimeType: "video/mp4", filename: url.lastPathComponent) + } + + #expect(!FileManager.default.fileExists(atPath: directory.url.path)) + } + + // MARK: - GIFs and other files + + @Test func gifPassesThroughUntouched() async throws { + let processor = makeProcessor(settings: makeSettings()) + let url = try fixtureURL("test-gif.gif") + + let result = try await processor.processFile(at: url, mimeType: "image/gif", filename: url.lastPathComponent) + + guard case .original = result else { + Issue.record("Expected the original file to pass through") + return + } + } + + /// SVG conforms to `UTType.image`, so it reaches the image branch, but + /// ImageIO cannot decode or encode it. It must pass through untouched + /// rather than fail in the exporter. + @Test func svgPassesThroughUntouched() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = true + settings.removeLocationSetting = true + let processor = makeProcessor(settings: settings) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).svg") + try #""# + .write(to: url, atomically: true, encoding: .utf8) + defer { cleanUp(url) } + + let result = try await processor.processFile( + at: url, + mimeType: "image/svg+xml", + filename: url.lastPathComponent + ) + + guard case .original = result else { + Issue.record("Expected the original file to pass through") + return + } + } + + @Test func disallowedFileExtensionThrows() async throws { + let processor = GBKMediaUploadProcessor( + videoDurationLimit: nil, + allowableFileExtensions: ["pdf"], + makeMediaSettings: makeSettingsFactory(makeSettings()) + ) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + try "plain text".write(to: url, atomically: true, encoding: .utf8) + defer { cleanUp(url) } + + await #expect(throws: MediaURLExporter.URLExportError.self) { + try await processor.processFile(at: url, mimeType: "text/plain", filename: url.lastPathComponent) + } + } + + // MARK: - Files without an extension + + /// GutenbergKit names the temp file after the multipart `filename`, which + /// the editor does not guarantee carries an extension (its native inserter + /// derives one from a URL path segment). Such a file resolves to + /// `public.data`, so the reported MIME type has to stand in for the type. + @Test func extensionlessImageIsProcessedUsingReportedMIMEType() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = true + settings.maxImageSizeSetting = 200 + let processor = makeProcessor(settings: settings) + let url = try copyFixtureDroppingExtension("test-image-device-photo-gps.jpg") + defer { cleanUp(url) } + + let result = try await processor.processFile( + at: url, + mimeType: "image/jpeg", + filename: url.lastPathComponent + ) + + guard case .processed(let outputURL, let mimeType, _) = result else { + Issue.record("Expected a processed file") + return + } + defer { cleanUp(outputURL) } + #expect(mimeType == "image/jpeg") + #expect(max(try imageSize(at: outputURL).width, try imageSize(at: outputURL).height) == 200) + } + + /// The fallback only applies when the URL yields no type of its own — a + /// mismatched MIME type must not override what the file actually is. + @Test func fileExtensionWinsOverReportedMIMEType() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = false + settings.removeLocationSetting = false + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-gif.gif") + + let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent) + + // Classified as a GIF from the extension, not as a JPEG from the + // reported type, so it passes through instead of being re-encoded. + guard case .original = result else { + Issue.record("Expected the original file to pass through") + return + } + } + + @Test func extensionlessFileWithUnusableMIMETypeThrows() async throws { + let processor = makeProcessor(settings: makeSettings()) + let url = try copyFixtureDroppingExtension("test-image-device-photo-gps.jpg") + defer { cleanUp(url) } + + await #expect(throws: MediaURLExporter.URLExportError.self) { + try await processor.processFile(at: url, mimeType: "not-a-mime-type", filename: url.lastPathComponent) + } + } + + // MARK: - handlesFile + + /// The invariant the metadata gate rests on: declining a file must mean + /// `processFile` would have returned it unchanged. If this fails, the gate + /// is skipping work that `processFile` would actually have done. + @Test(arguments: [true, false], [true, false]) + func decliningAFileImpliesProcessFileWouldNotTouchIt( + optimizationEnabled: Bool, + removeLocation: Bool + ) async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = optimizationEnabled + settings.removeLocationSetting = removeLocation + settings.maxImageSizeSetting = 200 + let processor = makeProcessor(settings: settings) + + let fixtures: [(filename: String, mimeType: String)] = [ + ("test-image-device-photo-gps.jpg", "image/jpeg"), + ("iphone-photo.heic", "image/heic"), + ("test-gif.gif", "image/gif"), + ("test-video-device-gps.m4v", "video/mp4") + ] + + for fixture in fixtures { + guard !processor.handlesFile(ofType: fixture.mimeType, named: fixture.filename) else { + continue + } + let url = try fixtureURL(fixture.filename) + let result = try await processor.processFile( + at: url, + mimeType: fixture.mimeType, + filename: fixture.filename + ) + guard case .original = result else { + Issue.record("Declined \(fixture.filename) but processFile would have processed it") + return + } + } + } + + @Test func gifIsDeclinedBeforeBeingCopiedToDisk() { + let processor = makeProcessor(settings: makeSettings()) + #expect(!processor.handlesFile(ofType: "image/gif", named: "animation.gif")) + } + + @Test func imagesAndVideosAreAlwaysClaimed() { + let processor = makeProcessor(settings: makeSettings()) + #expect(processor.handlesFile(ofType: "image/jpeg", named: "photo.jpg")) + #expect(processor.handlesFile(ofType: "image/heic", named: "photo.heic")) + #expect(processor.handlesFile(ofType: "video/mp4", named: "clip.mp4")) + } + + /// A document is only worth claiming when there is a restriction to + /// enforce; otherwise `processFile` returns it unchanged. + @Test func documentsAreClaimedOnlyToEnforceAllowedExtensions() { + let unrestricted = makeProcessor(settings: makeSettings()) + #expect(!unrestricted.handlesFile(ofType: "application/pdf", named: "doc.pdf")) + + let restricted = GBKMediaUploadProcessor( + videoDurationLimit: nil, + allowableFileExtensions: ["pdf"], + makeMediaSettings: makeSettingsFactory(makeSettings()) + ) + #expect(restricted.handlesFile(ofType: "application/pdf", named: "doc.pdf")) + } + + /// A part with no `Content-Type` arrives as `text/plain`, and a real + /// `Content-Type` may carry parameters or arbitrary casing. None of those + /// may cause a photo to be mistaken for a document and declined. + @Test( + arguments: [ + "image/jpeg; charset=binary", + "IMAGE/JPEG", + "text/plain", + "application/octet-stream", + "" + ] + ) + func imagesAreClaimedWhateverTheReportedMIMEType(mimeType: String) { + let processor = makeProcessor(settings: makeSettings()) + #expect(processor.handlesFile(ofType: mimeType, named: "photo.jpg")) + } + + /// Nothing decidable from the metadata means the file is claimed, so + /// `processFile` can read the type off the bytes instead. + @Test func unrecognizableMetadataIsClaimed() { + let processor = makeProcessor(settings: makeSettings()) + #expect(processor.handlesFile(ofType: "text/plain", named: "upload")) + } + + // MARK: - Videos + + @Test func videoExceedingDurationLimitThrows() async throws { + let processor = GBKMediaUploadProcessor( + videoDurationLimit: 1, + allowableFileExtensions: [], + makeMediaSettings: makeSettingsFactory(makeSettings()) + ) + let url = try fixtureURL("test-video-device-gps.m4v") + + await #expect(throws: (any Error).self) { + try await processor.processFile(at: url, mimeType: "video/mp4", filename: url.lastPathComponent) + } + } + + // MARK: - Helpers + + private func makeProcessor(settings: MediaSettings) -> GBKMediaUploadProcessor { + GBKMediaUploadProcessor( + videoDurationLimit: nil, + allowableFileExtensions: [], + makeMediaSettings: makeSettingsFactory(settings) + ) + } + + private func makeSettings() -> MediaSettings { + MediaSettings(database: EphemeralKeyValueDatabase()) + } + + private func makeSettingsFactory(_ settings: MediaSettings) -> @Sendable () -> MediaSettings { + nonisolated(unsafe) let settings = settings + return { settings } + } + + private func fixtureURL(_ filename: String) throws -> URL { + let bundle = Bundle(for: BundleAnchor.self) + let name = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + let url = try #require(bundle.url(forResource: name, withExtension: ext)) + return url + } + + /// Copies a fixture to a temporary file with no path extension, mirroring + /// an upload whose multipart `filename` carried none. + private func copyFixtureDroppingExtension(_ filename: String) throws -> URL { + let source = try fixtureURL(filename) + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: false) + try FileManager.default.copyItem(at: source, to: destination) + #expect(destination.pathExtension.isEmpty) + return destination + } + + private func imageProperties(at url: URL) throws -> [CFString: Any] { + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + return properties + } + + private func imageSize(at url: URL) throws -> CGSize { + let properties = try imageProperties(at: url) + let width = try #require(properties[kCGImagePropertyPixelWidth] as? CGFloat) + let height = try #require(properties[kCGImagePropertyPixelHeight] as? CGFloat) + return CGSize(width: width, height: height) + } + + private func cleanUp(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } + + private enum ProcessingError: Error { + case expectedProcessedFile + } +} + +/// Anchor for resolving the test bundle from Swift Testing suites. +private final class BundleAnchor {} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/GBKMediaUploadProcessor.swift b/WordPress/Classes/ViewRelated/NewGutenberg/GBKMediaUploadProcessor.swift new file mode 100644 index 000000000000..8e5bd1090ac9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewGutenberg/GBKMediaUploadProcessor.swift @@ -0,0 +1,362 @@ +import Foundation +import GutenbergKit +import UniformTypeIdentifiers +import WordPressData + +/// Processes media files picked in the GutenbergKit editor before upload, +/// applying the app's Media settings (image optimization, max upload size, +/// image quality, video resolution, and location stripping). +/// +/// Assigned to `GutenbergKit.EditorViewController.mediaUploadDelegate`, which +/// holds it weakly and invokes it off the main actor, so the type is `Sendable` +/// and snapshots the `Blog`-derived values it needs at initialization. +final class GBKMediaUploadProcessor: MediaUploadDelegate, Sendable { + private let videoDurationLimit: TimeInterval? + private let allowableFileExtensions: Set + private let makeMediaSettings: @Sendable () -> MediaSettings + + /// The temporary directory an export is written to. + /// + /// GutenbergKit deletes the processed file after uploading it, so exports + /// go to a temporary directory rather than the uploads directory tracked by + /// `MediaFileManager`. + /// + /// Every export gets its own directory. Destination names come from + /// `URL.incrementalFilename()`, a check-then-act `fileExists` loop with no + /// locking, and uploads are processed concurrently — one task per + /// connection — so sharing a directory lets two exports of the same source + /// name resolve to the same path and clobber each other. A per-export + /// directory removes the shared state instead of racing on it. + private let makeExportDirectory: @Sendable () -> MediaDirectory + + /// Raster image types the WordPress REST API reliably accepts. Other image + /// formats (e.g. HEIC) are converted to JPEG during processing, mirroring + /// `ItemProviderMediaExporter`. + /// + /// - Note: SVG is deliberately absent. It is web-safe, but it is a vector + /// format that ImageIO cannot decode or encode, so it never reaches the + /// exporter — `processFile` returns it unchanged (see below). + private static let webSafeImageTypes: Set = [.png, .jpeg, .gif] + + @MainActor + convenience init(blog: Blog) { + // HEIC isn't supported when uploading an image, so we filter it out, + // mirroring `MediaImportService`. + var allowedFileTypes = blog.allowedFileTypes + allowedFileTypes.remove("heic") + + self.init( + videoDurationLimit: blog.videoDurationLimit, + allowableFileExtensions: allowedFileTypes + ) + } + + init( + videoDurationLimit: TimeInterval?, + allowableFileExtensions: Set, + makeMediaSettings: @escaping @Sendable () -> MediaSettings = { MediaSettings() }, + makeExportDirectory: @escaping @Sendable () -> MediaDirectory = { .temporary(id: UUID()) } + ) { + self.videoDurationLimit = videoDurationLimit + self.allowableFileExtensions = allowableFileExtensions + self.makeMediaSettings = makeMediaSettings + self.makeExportDirectory = makeExportDirectory + } + + // MARK: - MediaUploadDelegate + + /// Whether the file is worth materializing for `processFile`. + /// + /// GutenbergKit calls this from the multipart headers alone, before + /// streaming the upload to a temp file. Returning `false` skips that copy + /// and forwards the original request body to WordPress unchanged, so it is + /// only correct where `processFile` would return `.original` for *any* + /// Media settings — the metadata here cannot answer anything finer. + /// + /// This is a fast path, never a second place the policy lives: every `false` + /// below mirrors a branch of `processFile` that ignores `settings`. + /// Declining is also unrecoverable — the file is never seen again — so + /// anything undecidable from metadata claims the file and decides for real + /// once the bytes are on disk. + func handlesFile(ofType mimeType: String, named filename: String) -> Bool { + // The URL the file will be written to isn't available yet, so classify + // from the reported type alone, falling back to the filename extension + // when it is a placeholder. Both are untrustworthy in ways `processFile` + // can recover from and this cannot, hence the bias toward `true`. + guard let type = Self.type(ofMIMEType: mimeType) ?? Self.type(ofExtensionIn: filename) else { + return true + } + guard let expected = try? Self.expectedExport(of: nil, type: type) else { + return true + } + switch expected { + case .gif: + // Always returned unchanged, whatever the settings. + return false + case .other: + // Claim these only to enforce the site's allowed extensions, which + // `processFile` throws on. Declining would spend a full upload on a + // file the site rejects and surface the server's error instead of + // ours. With no restriction to enforce, there is nothing to do. + return !allowableFileExtensions.isEmpty + case .image, .video: + // An image may be downscaled, stripped, or converted, and a video + // is always exported. Both depend on settings or on the file's + // contents, so decide in `processFile`. + return true + } + } + + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + let sourceType = Self.sourceType(of: url, reportedMIMEType: mimeType) + let expected = try Self.expectedExport(of: url, type: sourceType) + let settings = makeMediaSettings() + + switch expected { + case .gif: + // GIFs are uploaded unchanged; processing would only copy the file. + return .original + case .other: + // Non-media files are uploaded unchanged, but enforce the site's + // allowed file extensions, mirroring `MediaURLExporter.exportURL`. + if let fileExtension = url.typeIdentifierFileExtension, + !MediaImportService.defaultAllowableFileExtensions.contains(fileExtension), + !allowableFileExtensions.isEmpty, + !allowableFileExtensions.contains(fileExtension) + { + throw MediaURLExporter.URLExportError.unsupportedFileType + } + return .original + case .image: + // SVG conforms to `UTType.image`, so it lands here, but ImageIO + // cannot decode or encode it: the export would fail rather than + // produce a file. Upload it unchanged, like a GIF. + if sourceType == .svg { + return .original + } + + // Skip the export when nothing would change the file: no + // downscaling, no location stripping, and no format conversion. + // + // This is narrower than "processing changes nothing". With + // optimization off, `imageQualityForUpload` is still `.high`, so a + // web-safe image that reaches the exporter is re-encoded at that + // quality even though `imageSizeForUpload` leaves its dimensions + // alone. That mirrors `MediaImportService`, which maps the same + // settings the same way. + // + // Skipping the export also skips the exporter's unconditional EXIF + // orientation normalization, so a sideways-shot photo uploads with + // its orientation flag intact rather than rotated into its pixels. + // That is deliberate: the normalization predates WordPress 5.3, + // whose `wp_create_image_subsizes` rotates on the server for every + // site, self-hosted included. Re-encoding here to bake in a + // rotation the server performs anyway would cost a lossy pass on a + // photo the user asked not to optimize. + // + // See WordPress-iOS#12703 and core changeset 46202. + if !settings.imageOptimizationEnabled, + !settings.removeLocationSetting, + let sourceType, + Self.webSafeImageTypes.contains(sourceType) + { + return .original + } + case .video: + // Always process video to apply the export preset, duration + // limit, and location stripping. + break + } + + let exportImageType = Self.exportImageType(for: expected, sourceType: sourceType) + let directory = makeExportDirectory() + + do { + let export = try await makeExporter( + for: url, + expected: expected, + settings: settings, + exportImageType: exportImageType, + directory: directory + ) + .export() + + let mimeType = try Self.mimeType(of: export.url, exportImageType: exportImageType) + return .processed(export.url, mimeType: mimeType, filename: export.url.lastPathComponent) + } catch { + // Nothing else sweeps this directory: GutenbergKit removes only the + // file it is handed, and `MediaFileManager`'s cleanup covers the + // uploads directory alone. On the success path the directory is + // left holding the file GutenbergKit is about to upload, but a + // failure here would otherwise abandon a full-size export — and any + // directory the export already created — for the lifetime of the + // app's container. + try? FileManager.default.removeItem(at: directory.url) + throw error + } + } + + // MARK: - Exporter configuration + + /// Builds an exporter configured from the app's Media settings, mirroring + /// the option mapping in `MediaImportService`. + /// + /// Returns the concrete exporter for the branch rather than + /// `MediaURLExporter`, which re-derives the type from the path extension in + /// `exportURL` and so would reject a file classified via its reported MIME + /// type. `MediaImageExporter` reads the type from the file's contents with + /// `CGImageSourceGetType`, so it handles an extensionless image correctly. + private func makeExporter( + for url: URL, + expected: MediaURLExporter.URLExportExpectation, + settings: MediaSettings, + exportImageType: UTType?, + directory: MediaDirectory + ) -> any MediaExporter { + switch expected { + case .video: + let exporter = MediaVideoExporter(url: url) + exporter.mediaDirectoryType = directory + var options = MediaVideoExporter.Options() + options.stripsGeoLocationIfNeeded = settings.removeLocationSetting + options.exportPreset = settings.maxVideoSizeSetting.videoPreset + options.durationLimit = videoDurationLimit + exporter.options = options + return exporter + case .image, .gif, .other: + // Only images reach the exporter: `.gif` and `.other` return + // `.original` before this point. + let exporter = MediaImageExporter(url: url) + exporter.mediaDirectoryType = directory + var options = MediaImageExporter.Options() + options.maximumImageSize = maximumImageSize(from: settings) + options.stripsGeoLocationIfNeeded = settings.removeLocationSetting + options.imageCompressionQuality = settings.imageQualityForUpload.doubleValue + // `exportImageType` is the destination type `MediaImageExporter` + // writes, and it also determines the output file extension. Left + // nil, the source type is kept. + options.exportImageType = exportImageType?.identifier + exporter.options = options + return exporter + } + } + + private func maximumImageSize(from settings: MediaSettings) -> CGFloat? { + let maxUploadSize = settings.imageSizeForUpload + return maxUploadSize < Int.max ? CGFloat(maxUploadSize) : nil + } + + // MARK: - Type resolution + + /// The type of the file to process. + /// + /// Resolved from the file itself, falling back to the type the editor + /// reported. The URL resolves its type from the path extension alone, and + /// an upload can arrive without one — GutenbergKit names the temp file + /// after the multipart `filename`, which the editor does not guarantee + /// carries an extension (its native inserter derives one from a URL path + /// segment). Such a file resolves to the generic `public.data`, which + /// conforms to no media type, so the export would be rejected outright. + private static func sourceType(of url: URL, reportedMIMEType: String) -> UTType? { + guard let type = url.typeIdentifier.flatMap(UTType.init), type != .data else { + return type(ofMIMEType: reportedMIMEType) + } + return type + } + + /// The type a reported MIME type names, or `nil` when it names nothing + /// usable. + /// + /// `UTType(mimeType:)` matches the bare `type/subtype` only, so the header + /// is normalized first. Two shapes reach us that it would otherwise miss: + /// + /// - Parameters and casing: `Content-Type` may carry parameters + /// (`image/jpeg; charset=binary`) and its casing is not significant + /// (RFC 9110 §8.3). Left as-is, both resolve to a dynamic UTType that + /// conforms to nothing. + /// - Placeholders: GutenbergKit's multipart parser defaults a part with no + /// `Content-Type` to `text/plain` (RFC 7578 §4.4), and it picks the file + /// part by the presence of a `filename` parameter rather than by content + /// type — so a real image can arrive labeled `text/plain`. Treating that + /// as authoritative would classify a photo as a document. + private static func type(ofMIMEType mimeType: String) -> UTType? { + let normalized = mimeType.prefix(while: { $0 != ";" }) + .trimmingCharacters(in: .whitespaces) + .lowercased() + guard !normalized.isEmpty, !placeholderMIMETypes.contains(normalized) else { + return nil + } + return UTType(mimeType: normalized) + } + + /// MIME types that carry no information about the file. `octet-stream` is + /// the generic "unknown bytes" type; `text/plain` is what GutenbergKit's + /// multipart parser substitutes for a part that sent no `Content-Type`. + private static let placeholderMIMETypes: Set = [ + "application/octet-stream", "text/plain" + ] + + /// The type a filename's extension names, for use before the file exists. + /// `processFile` reads the type off the file itself instead. + private static func type(ofExtensionIn filename: String) -> UTType? { + let fileExtension = (filename as NSString).pathExtension.lowercased() + guard !fileExtension.isEmpty else { + return nil + } + return UTType(filenameExtension: fileExtension) + } + + /// Classifies a file the way `MediaURLExporter.expectedExport(with:)` does, + /// but from an already-resolved type so the caller can supply one the URL + /// alone cannot provide. + /// - Parameter url: The file being classified, or `nil` when only the type + /// is known — `handlesFile` runs before the file exists. + private static func expectedExport( + of url: URL?, + type: UTType? + ) throws -> MediaURLExporter.URLExportExpectation { + if let url, !url.isFileURL { + throw MediaURLExporter.URLExportError.invalidFileURL + } + guard let type else { + throw MediaURLExporter.URLExportError.unknownFileUTI + } + if type == .gif { + return .gif + } else if type.conforms(to: .video) || type.conforms(to: .movie) { + return .video + } else if type.conforms(to: .image) { + return .image + } else if type.conforms(to: .content) || type.conforms(to: .zip) { + return .other + } + throw MediaURLExporter.URLExportError.unsupportedFileType + } + + /// The type `MediaImageExporter` should write, or `nil` to keep the source + /// type. Only image exports convert: everything the REST API accepts as-is + /// is left alone, and the rest becomes JPEG. + private static func exportImageType( + for expected: MediaURLExporter.URLExportExpectation, + sourceType: UTType? + ) -> UTType? { + guard case .image = expected, let sourceType else { + return nil + } + return webSafeImageTypes.contains(sourceType) ? nil : .jpeg + } + + /// The MIME type of a finished export. + /// + /// An image export writes `exportImageType` when set, so that value is + /// authoritative and needs no round-trip through the output path. Only the + /// cases that keep the source type — every video, and a web-safe image — + /// fall back to reading the file back. + private static func mimeType(of url: URL, exportImageType: UTType?) throws -> String { + let type = exportImageType ?? url.typeIdentifier.flatMap(UTType.init) + guard let mimeType = type?.preferredMIMEType else { + throw MediaURLExporter.URLExportError.unknownFileUTI + } + return mimeType + } +} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift index 46e498603e0d..4f227a83dcb6 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift @@ -16,6 +16,9 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont private lazy var mediaPickerHelper = GutenbergMediaPickerHelper(context: self, blog: blog) + /// Retains the media upload processor, which the editor holds weakly. + private let mediaUploadProcessor: GBKMediaUploadProcessor + private var keyboardShowObserver: Any? private var keyboardHideObserver: Any? private var keyboardFrame = CGRect.zero @@ -55,10 +58,12 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont dependencies: cachedDependencies, mediaPicker: MediaPickerController(blog: blog) ) + self.mediaUploadProcessor = GBKMediaUploadProcessor(blog: blog) super.init(nibName: nil, bundle: nil) self.editorViewController.delegate = self + self.editorViewController.mediaUploadDelegate = mediaUploadProcessor } required init?(coder aDecoder: NSCoder) {