diff --git a/App/Composition/CompositionMenuTree.swift b/App/Composition/CompositionMenuTree.swift index 3ecacfb76..bae8235e4 100644 --- a/App/Composition/CompositionMenuTree.swift +++ b/App/Composition/CompositionMenuTree.swift @@ -17,12 +17,9 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: final class CompositionMenuTree: NSObject { // This class exists to expose the struct-defined menu to Objective-C and to act as an image picker delegate. + @FoilDefaultStorage(Settings.imageHostingProvider) private var imageHostingProvider @FoilDefaultStorage(Settings.imgurUploadMode) private var imgurUploadMode - fileprivate var imgurUploadsEnabled: Bool { - return imgurUploadMode != .off - } - let textView: UITextView /// The textView's class will have some responder chain methods swizzled. @@ -331,19 +328,8 @@ fileprivate let rootItems = [ original line: MenuItem(title: "[img]", action: { $0.showSubmenu(imageItems) }), */ MenuItem(title: "[img]", action: { tree in - // If Imgur uploads are enabled in settings, show the full image submenu - // Otherwise, only allow pasting URLs - if tree.imgurUploadsEnabled { - tree.showSubmenu(imageItems) - } else { - if UIPasteboard.general.coercedURL == nil { - linkifySelection(tree) - } else { - if let textRange = tree.textView.selectedTextRange { - tree.textView.replace(textRange, withText:("[img]" + UIPasteboard.general.coercedURL!.absoluteString + "[/img]")) - } - } - } + // Image uploads are always enabled now (via Imgur or PostImages) + tree.showSubmenu(imageItems) }), MenuItem(title: "Format", action: { $0.showSubmenu(formattingItems) }), MenuItem(title: "[video]", action: { tree in diff --git a/App/Composition/ImageUploadProvider.swift b/App/Composition/ImageUploadProvider.swift new file mode 100644 index 000000000..bfdeda440 --- /dev/null +++ b/App/Composition/ImageUploadProvider.swift @@ -0,0 +1,61 @@ +// ImageUploadProvider.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation +import Photos +import UIKit + +/// Common protocol for image upload providers (Imgur, PostImages, etc.) +public protocol ImageUploadProvider { + /// Upload a UIImage + @discardableResult + func upload(_ image: UIImage, completion: @escaping (Result) -> Void) -> Progress + + /// Upload a Photos asset + @discardableResult + func upload(_ asset: PHAsset, completion: @escaping (Result) -> Void) -> Progress + + /// Upload from image picker info dictionary + @discardableResult + func upload(_ info: [UIImagePickerController.InfoKey: Any], completion: @escaping (Result) -> Void) -> Progress +} + +/// Standardized response from any image upload provider +public struct ImageUploadResponse { + /// The URL of the uploaded image + public let imageURL: URL + + /// Optional deletion URL/hash (not all providers support this) + public let deleteURL: URL? + + /// Provider-specific metadata + public let metadata: [String: Any] + + public init(imageURL: URL, deleteURL: URL? = nil, metadata: [String: Any] = [:]) { + self.imageURL = imageURL + self.deleteURL = deleteURL + self.metadata = metadata + } +} + +/// Errors that can occur during image upload +public enum ImageUploadProviderError: LocalizedError { + case unsupportedImageFormat + case uploadFailed(String) + case invalidResponse + case providerUnavailable + + public var errorDescription: String? { + switch self { + case .unsupportedImageFormat: + return "The image format is not supported" + case .uploadFailed(let message): + return "Upload failed: \(message)" + case .invalidResponse: + return "Invalid response from image hosting service" + case .providerUnavailable: + return "Image hosting service is unavailable" + } + } +} \ No newline at end of file diff --git a/App/Composition/UploadImageAttachments.swift b/App/Composition/UploadImageAttachments.swift index e8e8f5e10..0effa5145 100644 --- a/App/Composition/UploadImageAttachments.swift +++ b/App/Composition/UploadImageAttachments.swift @@ -4,7 +4,6 @@ import AwfulSettings import Foundation -import ImgurAnonymousAPI import os import Photos import UIKit @@ -50,15 +49,7 @@ func uploadImages(attachedTo richText: NSAttributedString, completion: @escaping let progress = Progress(totalUnitCount: 1) // Check if we need authentication before proceeding - if ImgurAuthManager.shared.needsAuthentication { - DispatchQueue.main.async { - completion(nil, ImageUploadError.authenticationRequired) - } - return progress - } - - // Check if token needs refresh - if ImgurAuthManager.shared.currentUploadMode == "Imgur Account" && ImgurAuthManager.shared.checkTokenExpiry() { + if ImageUploadManager.shared.needsAuthentication { DispatchQueue.main.async { completion(nil, ImageUploadError.authenticationRequired) } @@ -78,14 +69,6 @@ func uploadImages(attachedTo richText: NSAttributedString, completion: @escaping let localerCopy = localCopy.mutableCopy() as! NSMutableAttributedString let uploadProgress = uploadImages(fromSources: tags.map { $0.source }, completion: { (urls, error) in if let error = error { - // If we get an authentication-related error from Imgur, clear the token and report it as auth error - if let imgurError = error as? ImgurUploader.Error, imgurError == .invalidClientID { - ImgurAuthManager.shared.logout() // Clear the token as it may be invalid - return DispatchQueue.main.async { - completion(nil, ImageUploadError.authenticationFailed) - } - } - return DispatchQueue.main.async { completion(nil, error) } @@ -132,10 +115,10 @@ private func uploadImages(fromSources sources: [ImageTag.Source], completion: @e switch source { case .image(let image): - ImgurUploader.shared.upload(image, completion: { result in + ImageUploadManager.shared.upload(image, completion: { result in switch result { case .success(let response): - uploadComplete(response.link, error: nil) + uploadComplete(response.imageURL, error: nil) case .failure(let error): logger.error("Could not upload UIImage: \(error)") uploadComplete(nil, error: error) @@ -149,10 +132,10 @@ private func uploadImages(fromSources sources: [ImageTag.Source], completion: @e break } - ImgurUploader.shared.upload(asset, completion: { result in + ImageUploadManager.shared.upload(asset, completion: { result in switch result { case .success(let response): - uploadComplete(response.link, error: nil) + uploadComplete(response.imageURL, error: nil) case .failure(let error): logger.error("Could not upload PHAsset: \(error)") uploadComplete(nil, error: error) diff --git a/App/Extensions/ImageUploadManager.swift b/App/Extensions/ImageUploadManager.swift new file mode 100644 index 000000000..19783bdd0 --- /dev/null +++ b/App/Extensions/ImageUploadManager.swift @@ -0,0 +1,224 @@ +// ImageUploadManager.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation +import AwfulSettings +import ImgurAnonymousAPI +@_exported import PostImagesAPI +import Photos +import UIKit +import os + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ImageUploadManager") + +/// Manages image uploads using the configured provider (Imgur or PostImages) +public final class ImageUploadManager { + + public static let shared = ImageUploadManager() + + @FoilDefaultStorage(Settings.imageHostingProvider) private var imageHostingProvider + @FoilDefaultStorage(Settings.imgurUploadMode) private var imgurUploadMode + + private init() {} + + /// Get the current upload provider based on settings + private var currentProvider: ImageUploadProvider { + switch imageHostingProvider { + case .postImages: + logger.debug("Using PostImages provider") + return PostImagesUploadProviderAdapter() + + case .imgur: + logger.debug("Using Imgur provider (mode: \(self.imgurUploadMode.rawValue))") + return ImgurUploadProviderAdapter() + } + } + + /// Check if authentication is needed before uploading + public var needsAuthentication: Bool { + switch imageHostingProvider { + case .postImages: + return false + case .imgur: + return imgurUploadMode == .account && !ImgurAuthManager.shared.isAuthenticated + } + } + + /// Upload a UIImage + @discardableResult + public func upload(_ image: UIImage, completion: @escaping (Result) -> Void) -> Progress { + if needsAuthentication { + let progress = Progress(totalUnitCount: 1) + progress.completedUnitCount = 1 + DispatchQueue.main.async { + completion(.failure(ImageUploadProviderError.providerUnavailable)) + } + return progress + } + + return currentProvider.upload(image, completion: completion) + } + + /// Upload a Photos asset + @discardableResult + public func upload(_ asset: PHAsset, completion: @escaping (Result) -> Void) -> Progress { + if needsAuthentication { + let progress = Progress(totalUnitCount: 1) + progress.completedUnitCount = 1 + DispatchQueue.main.async { + completion(.failure(ImageUploadProviderError.providerUnavailable)) + } + return progress + } + + return currentProvider.upload(asset, completion: completion) + } + + /// Upload from image picker info dictionary + @discardableResult + public func upload(_ info: [UIImagePickerController.InfoKey: Any], completion: @escaping (Result) -> Void) -> Progress { + if needsAuthentication { + let progress = Progress(totalUnitCount: 1) + progress.completedUnitCount = 1 + DispatchQueue.main.async { + completion(.failure(ImageUploadProviderError.providerUnavailable)) + } + return progress + } + + return currentProvider.upload(info, completion: completion) + } +} + +// MARK: - Provider Adapters + +/// Adapter to make ImgurUploader conform to ImageUploadProvider +private class ImgurUploadProviderAdapter: ImageUploadProvider { + + func upload(_ image: UIImage, completion: @escaping (Result) -> Void) -> Progress { + return ImgurUploader.shared.upload(image) { result in + switch result { + case .success(let response): + let uploadResponse = ImageUploadResponse( + imageURL: response.link, + deleteURL: nil, // Could be constructed from delete hash if needed + metadata: [ + "id": response.id, + "postLimit": response.postLimit as Any, + "rateLimit": response.rateLimit as Any + ] + ) + completion(.success(uploadResponse)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func upload(_ asset: PHAsset, completion: @escaping (Result) -> Void) -> Progress { + return ImgurUploader.shared.upload(asset) { result in + switch result { + case .success(let response): + let uploadResponse = ImageUploadResponse( + imageURL: response.link, + deleteURL: nil, + metadata: [ + "id": response.id, + "postLimit": response.postLimit as Any, + "rateLimit": response.rateLimit as Any + ] + ) + completion(.success(uploadResponse)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func upload(_ info: [UIImagePickerController.InfoKey : Any], completion: @escaping (Result) -> Void) -> Progress { + return ImgurUploader.shared.upload(info) { result in + switch result { + case .success(let response): + let uploadResponse = ImageUploadResponse( + imageURL: response.link, + deleteURL: nil, + metadata: [ + "id": response.id, + "postLimit": response.postLimit as Any, + "rateLimit": response.rateLimit as Any + ] + ) + completion(.success(uploadResponse)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} + +/// Adapter to make PostImagesUploader conform to ImageUploadProvider +private class PostImagesUploadProviderAdapter: ImageUploadProvider { + + private let uploader = PostImagesUploader() + + func upload(_ image: UIImage, completion: @escaping (Result) -> Void) -> Progress { + return uploader.upload(image) { result in + switch result { + case .success(let response): + let uploadResponse = ImageUploadResponse( + imageURL: response.imageURL, + deleteURL: nil, // PostImages doesn't provide deletion URLs + metadata: [ + "directLink": response.directLink as Any, + "viewerLink": response.viewerLink as Any, + "thumbnailLink": response.thumbnailLink as Any + ] + ) + completion(.success(uploadResponse)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func upload(_ asset: PHAsset, completion: @escaping (Result) -> Void) -> Progress { + return uploader.upload(asset) { result in + switch result { + case .success(let response): + let uploadResponse = ImageUploadResponse( + imageURL: response.imageURL, + deleteURL: nil, + metadata: [ + "directLink": response.directLink as Any, + "viewerLink": response.viewerLink as Any, + "thumbnailLink": response.thumbnailLink as Any + ] + ) + completion(.success(uploadResponse)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func upload(_ info: [UIImagePickerController.InfoKey : Any], completion: @escaping (Result) -> Void) -> Progress { + return uploader.upload(info) { result in + switch result { + case .success(let response): + let uploadResponse = ImageUploadResponse( + imageURL: response.imageURL, + deleteURL: nil, + metadata: [ + "directLink": response.directLink as Any, + "viewerLink": response.viewerLink as Any, + "thumbnailLink": response.thumbnailLink as Any + ] + ) + completion(.success(uploadResponse)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index b32003490..714d7380c 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -205,6 +205,9 @@ 2D921269292F588100B16011 /* platinum-member.png in Resources */ = {isa = PBXBuildFile; fileRef = 2D921268292F588100B16011 /* platinum-member.png */; }; 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */; }; 2DD8209C25DDD9BF0015A90D /* CopyImageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */; }; + 3052202A2E49020900390B6D /* PostImagesAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 305220292E49020900390B6D /* PostImagesAPI */; }; + 3052202C2E4903C500390B6D /* ImageUploadProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3052202B2E4903C500390B6D /* ImageUploadProvider.swift */; }; + 3052202E2E4903ED00390B6D /* ImageUploadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3052202D2E4903ED00390B6D /* ImageUploadManager.swift */; }; 306F740B2D90AA01000717BC /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 306F740A2D90AA01000717BC /* KeychainAccess */; }; 30E0C51D2E35C89D0030DC0A /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */; }; 30E0C51E2E35C89D0030DC0A /* SmiliePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */; }; @@ -520,8 +523,9 @@ 2D921268292F588100B16011 /* platinum-member.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "platinum-member.png"; sourceTree = ""; }; 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+FontDesign.swift"; sourceTree = ""; }; 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CopyImageActivity.swift; sourceTree = ""; }; + 3052202B2E4903C500390B6D /* ImageUploadProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadProvider.swift; sourceTree = ""; }; + 3052202D2E4903ED00390B6D /* ImageUploadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadManager.swift; sourceTree = ""; }; 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; - 30E0C5172E35C89D0030DC0A /* SmilieData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieData.swift; sourceTree = ""; }; 30E0C5182E35C89D0030DC0A /* SmilieGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieGridItem.swift; sourceTree = ""; }; 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmiliePickerView.swift; sourceTree = ""; }; 30E0C51A2E35C89D0030DC0A /* SmilieSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieSearchViewModel.swift; sourceTree = ""; }; @@ -602,6 +606,7 @@ 1C5345212B8BF48F001DA96A /* ScrollViewDelegateMultiplexer in Frameworks */, 1C67313C2B55CE0600A8CF6F /* AwfulSettingsUI in Frameworks */, 1CB0B9AB2AEF668500678615 /* NukeExtensions in Frameworks */, + 3052202A2E49020900390B6D /* PostImagesAPI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -935,6 +940,7 @@ 1C8A8CFD1A3C28E900E4F6A4 /* Extensions */ = { isa = PBXGroup; children = ( + 3052202D2E4903ED00390B6D /* ImageUploadManager.swift */, 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */, 1C4EAD5D1BC0622D0008BE54 /* AwfulCore.swift */, 1C3E180E224C558500BD88E5 /* FLAnimatedImageView+Nuke.swift */, @@ -1098,6 +1104,7 @@ 1CF2462016DD957300C75E05 /* Composition */ = { isa = PBXGroup; children = ( + 3052202B2E4903C500390B6D /* ImageUploadProvider.swift */, 1C2C1F0D1CE16FE200CD27DD /* CloseBBcodeTagCommand.swift */, 1C16FB9F1CB492C600C88BD1 /* ComposeField.swift */, 1C16FBA31CB4A41C00C88BD1 /* ComposeTextView.swift */, @@ -1321,6 +1328,7 @@ 1C53451D2B8BF3A7001DA96A /* PSMenuItem */, 1C5345202B8BF48F001DA96A /* ScrollViewDelegateMultiplexer */, 306F740A2D90AA01000717BC /* KeychainAccess */, + 305220292E49020900390B6D /* PostImagesAPI */, ); productName = Awful; productReference = 1D6058910D05DD3D006BFB54 /* AwfulDebug.app */; @@ -1388,6 +1396,7 @@ 1C6B2A98272F992E00671F0C /* XCRemoteSwiftPackageReference "Nuke" */, 2D265F8D292CB447001336ED /* XCRemoteSwiftPackageReference "lottie-ios" */, 306F74092D90AA01000717BC /* XCRemoteSwiftPackageReference "KeychainAccess" */, + 305220282E49020900390B6D /* XCLocalSwiftPackageReference "PostImagesAPI" */, ); productRefGroup = 19C28FACFE9D520D11CA2CBB /* Products */; projectDirPath = ""; @@ -1541,7 +1550,6 @@ 83410EF219A582B8002CD019 /* DateFormatters.swift in Sources */, 1C273A9E21B316DB002875A9 /* LoadMoreFooter.swift in Sources */, 1C2C1F0E1CE16FE200CD27DD /* CloseBBcodeTagCommand.swift in Sources */, - 30E0C51C2E35C89D0030DC0A /* SmilieData.swift in Sources */, 30E0C51D2E35C89D0030DC0A /* AnimatedImageView.swift in Sources */, 30E0C51E2E35C89D0030DC0A /* SmiliePickerView.swift in Sources */, 30E0C51F2E35C89D0030DC0A /* SmilieSearchViewModel.swift in Sources */, @@ -1591,6 +1599,7 @@ 1C220E3D2B815AFC00DA92B0 /* Bundle+.swift in Sources */, 1CD0C54F1BE674D700C3AC80 /* PostsPageRefreshSpinnerView.swift in Sources */, 1CEB5BFF19AB9C1700C82C30 /* InAppActionViewController.swift in Sources */, + 3052202C2E4903C500390B6D /* ImageUploadProvider.swift in Sources */, 1CA3D6FC2D98A7E400D70964 /* OEmbedFetcher.swift in Sources */, 1C16FBAA1CB5D38700C88BD1 /* CompositionInputAccessoryView.swift in Sources */, 1C9AEBCE210C3BAF00C9A567 /* main.swift in Sources */, @@ -1659,6 +1668,7 @@ 1C16FBA81CB5C62C00C88BD1 /* TextAttachment.swift in Sources */, 1C453EDE2336B694007AC6CD /* UITextView+Selections.swift in Sources */, 1C16FBD71CBAA00200C88BD1 /* PostsPageTopBar.swift in Sources */, + 3052202E2E4903ED00390B6D /* ImageUploadManager.swift in Sources */, 1CC256B31A3876F7003FA7A8 /* CompositionViewController.swift in Sources */, 1C16FBFC1CBF0F6B00C88BD1 /* ThreadTagPickerCell.swift in Sources */, 1CC256BF1A3AC9C0003FA7A8 /* CompositionMenuTree.swift in Sources */, @@ -1903,6 +1913,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 305220282E49020900390B6D /* XCLocalSwiftPackageReference "PostImagesAPI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = PostImagesAPI; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 1C453F262338457A007AC6CD /* XCRemoteSwiftPackageReference "Stencil" */ = { isa = XCRemoteSwiftPackageReference; @@ -2036,6 +2053,10 @@ package = 2D265F8D292CB447001336ED /* XCRemoteSwiftPackageReference "lottie-ios" */; productName = Lottie; }; + 305220292E49020900390B6D /* PostImagesAPI */ = { + isa = XCSwiftPackageProductDependency; + productName = PostImagesAPI; + }; 306F740A2D90AA01000717BC /* KeychainAccess */ = { isa = XCSwiftPackageProductDependency; package = 306F74092D90AA01000717BC /* XCRemoteSwiftPackageReference "KeychainAccess" */; diff --git a/AwfulSettings/Sources/AwfulSettings/Migration.swift b/AwfulSettings/Sources/AwfulSettings/Migration.swift index aab32a848..b07b4faf6 100644 --- a/AwfulSettings/Sources/AwfulSettings/Migration.swift +++ b/AwfulSettings/Sources/AwfulSettings/Migration.swift @@ -22,6 +22,7 @@ extension SettingsMigration { keepSidebarOpen(defaults) alternateAppTheme(defaults) forumSpecificThemes(defaults) + migrateImageHostingProvider(defaults) } static func yosposStyle(_ defaults: UserDefaults) { @@ -95,6 +96,29 @@ extension SettingsMigration { defaults.removeObject(forKey: oldKey) } } + + /// Migrate all users to PostImages.org to encourage its use + static func migrateImageHostingProvider(_ defaults: UserDefaults) { + // Check if user has already been migrated + let migrationKey = "did_migrate_to_postimages_v1" + if defaults.bool(forKey: migrationKey) { + return + } + + // Check current Imgur mode to handle the "Off" case + let imgurMode = defaults.string(forKey: Settings.imgurUploadMode.key) + + // If they had "Off", set Imgur mode to anonymous for when they switch back + if imgurMode == "Off" { + defaults.set("Anonymous", forKey: Settings.imgurUploadMode.key) + } + + // Move everyone to PostImages.org by default (they can switch back if they want) + defaults.set("PostImages", forKey: Settings.imageHostingProvider.key) + + // Mark migration as complete + defaults.set(true, forKey: migrationKey) + } } // MARK: - User defaults to Core Data diff --git a/AwfulSettings/Sources/AwfulSettings/Settings.swift b/AwfulSettings/Sources/AwfulSettings/Settings.swift index 658defc3d..e09a0879a 100644 --- a/AwfulSettings/Sources/AwfulSettings/Settings.swift +++ b/AwfulSettings/Sources/AwfulSettings/Settings.swift @@ -70,6 +70,9 @@ public enum Settings { /// Mode for Imgur image uploads (Off, Anonymous, or with Account) public static let imgurUploadMode = Setting(key: "imgur_upload_mode", default: ImgurUploadMode.default) + + /// Which image hosting provider to use + public static let imageHostingProvider = Setting(key: "image_hosting_provider", default: ImageHostingProvider.default) /// What percentage to multiply the default post font size by. Stored as percentage points, i.e. default is `100` aka "100% size" aka the default. public static let fontScale = Setting(key: "font_scale", default: 100.0) @@ -158,11 +161,50 @@ public enum BuiltInTheme: String, UserDefaultsSerializable { /// The upload mode for Imgur images. public enum ImgurUploadMode: String, CaseIterable, UserDefaultsSerializable { // These raw values are persisted in user defaults, so don't change them willy nilly. - case off = "Off" case anonymous = "Anonymous" case account = "Imgur Account" - static var `default`: Self { .off } + static var `default`: Self { .anonymous } + + public init(storedValue: String) { + // Migrate "Off" to anonymous, since we're removing the off option + if storedValue == "Off" { + self = .anonymous + } else { + self = Self(rawValue: storedValue) ?? .default + } + } + + public var storedValue: String { + return rawValue + } +} + +/// The image hosting provider to use for uploads +public enum ImageHostingProvider: String, CaseIterable, UserDefaultsSerializable { + // These raw values are persisted in user defaults, so don't change them willy nilly. + case imgur = "Imgur" + case postImages = "PostImages" + + static var `default`: Self { .postImages } + + public var displayName: String { + switch self { + case .imgur: + return "Imgur" + case .postImages: + return "Postimages.org" + } + } + + public var requiresAuthentication: Bool { + switch self { + case .imgur: + return false // Can be used anonymously or with account + case .postImages: + return false // Always anonymous + } + } } /// The default browser set by the user via `UserDefaults` and `Settings.defaultBrowser`. diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index ad4d94f76..14f8a375a 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "\"Anonymous\" submits images to Imgur without a user account. Imgur may delete these uploads without warning. Using an Imgur account is recommended." : { - - }, "[timg] Large Images" : { }, @@ -111,11 +108,20 @@ }, "Hide Sidebar in Landscape" : { + }, + "Image Hosting" : { + + }, + "Imgur" : { + }, "Imgur Account" : { }, - "Imgur Uploads" : { + "Imgur may delete anonymous images whenever they feel like it. Consider using postimages.org for better reliability." : { + + }, + "Imgur Mode" : { }, "Links" : { @@ -144,6 +150,12 @@ }, "Post feedback, bug reports, and feature suggestions. Do not contact anyone who works for Something Awful about this app." : { + }, + "PostImages.org" : { + + }, + "Postimages.org doesn't delete old images, but downscales images to 1280px. Maximum file size is 32MB." : { + }, "Posting" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index ab34cb906..567f49f00 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -36,6 +36,7 @@ public struct SettingsView: View { @AppStorage(Settings.automaticTimg) private var timgLargeImages @AppStorage(Settings.useNewSmiliePicker) private var useNewSmiliePicker @AppStorage("imgur_upload_mode") private var imgurUploadMode: String = "Off" + @AppStorage("image_hosting_provider") private var imageHostingProvider: String = "Imgur" let appIconDataSource: AppIconDataSource let avatarURL: URL? @@ -163,22 +164,33 @@ public struct SettingsView: View { Section { Toggle("[timg] Large Images", bundle: .module, isOn: $timgLargeImages) Toggle("New Smilie Picker", bundle: .module, isOn: $useNewSmiliePicker) - Picker("Imgur Uploads", bundle: .module, selection: $imgurUploadMode) { - Text("Off").tag("Off") - Text("Imgur Account").tag("Imgur Account") - Text("Anonymous").tag("Anonymous") + Picker("Image Hosting", bundle: .module, selection: $imageHostingProvider) { + Text("PostImages.org").tag("PostImages") + Text("Imgur").tag("Imgur") } - .onChange(of: imgurUploadMode) { newValue in - if newValue != "Imgur Account" { - clearImgurCredentials() + if imageHostingProvider == "Imgur" { + Picker("Imgur Mode", bundle: .module, selection: $imgurUploadMode) { + Text("Anonymous").tag("Anonymous") + Text("Imgur Account").tag("Imgur Account") + } + .onChange(of: imgurUploadMode) { newValue in + if newValue != "Imgur Account" { + clearImgurCredentials() + } } } } header: { Text("Posting", bundle: .module) .header() } footer: { - Text("\"Anonymous\" submits images to Imgur without a user account. Imgur may delete these uploads without warning. Using an Imgur account is recommended.", bundle: .module) - .footer() + Group { + if imageHostingProvider == "Imgur" { + Text("Imgur may delete anonymous images whenever they feel like it. Consider using postimages.org for better reliability.", bundle: .module) + } else { + Text("Postimages.org doesn't delete old images, but downscales images to 1280px. Maximum file size is 32MB.", bundle: .module) + } + } + .footer() } .section() diff --git a/PostImagesAPI/.gitignore b/PostImagesAPI/.gitignore new file mode 100644 index 000000000..1f24ecbdc --- /dev/null +++ b/PostImagesAPI/.gitignore @@ -0,0 +1,10 @@ +# Swift Package Manager +.build/ +.swiftpm/ +DerivedData/ +/*.xcodeproj +xcuserdata/ +Package.resolved + +# macOS +.DS_Store \ No newline at end of file diff --git a/PostImagesAPI/Package.swift b/PostImagesAPI/Package.swift new file mode 100644 index 000000000..48ce351b4 --- /dev/null +++ b/PostImagesAPI/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "PostImagesAPI", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "PostImagesAPI", + targets: ["PostImagesAPI"]), + ], + dependencies: [], + targets: [ + .target( + name: "PostImagesAPI", + dependencies: []), + .testTarget( + name: "PostImagesAPITests", + dependencies: ["PostImagesAPI"]), + ] +) \ No newline at end of file diff --git a/PostImagesAPI/README.md b/PostImagesAPI/README.md new file mode 100644 index 000000000..e65e70373 --- /dev/null +++ b/PostImagesAPI/README.md @@ -0,0 +1,30 @@ +# PostImagesAPI + +A Swift client for uploading images to PostImages.org. + +This package provides a simple interface for uploading images to PostImages.org without requiring authentication. It supports uploading UIImages, PHAssets, and images from UIImagePickerController. + +## Features + +- Anonymous image uploads (no authentication required) +- Full-size image uploads (no automatic thumbnailing) +- Support for UIImage, PHAsset, and UIImagePickerController +- Progress tracking and cancellation + +## Usage + +```swift +import PostImagesAPI + +let uploader = PostImagesUploader() + +// Upload a UIImage +uploader.upload(image) { result in + switch result { + case .success(let response): + print("Image uploaded to: \(response.imageURL)") + case .failure(let error): + print("Upload failed: \(error)") + } +} +``` \ No newline at end of file diff --git a/PostImagesAPI/Sources/PostImagesAPI/PostImagesUploader.swift b/PostImagesAPI/Sources/PostImagesAPI/PostImagesUploader.swift new file mode 100644 index 000000000..cfb8b8aac --- /dev/null +++ b/PostImagesAPI/Sources/PostImagesAPI/PostImagesUploader.swift @@ -0,0 +1,396 @@ +// PostImagesUploader.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation +import Photos +import UIKit +import os + +private let logger = Logger(subsystem: "com.awfulapp.Awful.PostImagesAPI", category: "PostImagesUploader") + +/// A client for uploading images to PostImages.org +public final class PostImagesUploader { + + private let session: URLSession + private let queue: OperationQueue + + public init() { + let config = URLSessionConfiguration.ephemeral + config.httpAdditionalHeaders = [ + "User-Agent": "Awful iOS App" + ] + self.session = URLSession(configuration: config) + + self.queue = OperationQueue() + self.queue.name = "com.awfulapp.PostImagesAPI" + self.queue.maxConcurrentOperationCount = 2 + } + + /// Upload a UIImage to PostImages + public func upload(_ image: UIImage, completion: @escaping (Result) -> Void) -> Progress { + let progress = Progress(totalUnitCount: 100) + + // Try to preserve PNG format for images with transparency, otherwise use JPEG for better compression + let (imageData, filename, mimeType): (Data?, String, String) + if let pngData = image.pngData() { + // Check if image has alpha channel (transparency) + let hasAlpha = image.cgImage?.alphaInfo != .none && image.cgImage?.alphaInfo != .noneSkipLast && image.cgImage?.alphaInfo != .noneSkipFirst + + if hasAlpha { + // Keep as PNG to preserve transparency + imageData = pngData + filename = "image.png" + mimeType = "image/png" + } else if let jpegData = image.jpegData(compressionQuality: 0.9) { + // Convert to JPEG for better compression if no transparency + imageData = jpegData + filename = "image.jpg" + mimeType = "image/jpeg" + } else { + // Fallback to PNG + imageData = pngData + filename = "image.png" + mimeType = "image/png" + } + } else { + imageData = nil + filename = "image.jpg" + mimeType = "image/jpeg" + } + + guard let data = imageData else { + DispatchQueue.main.async { + completion(.failure(PostImagesError.invalidImageData)) + } + progress.completedUnitCount = 100 + return progress + } + + Task { + do { + progress.completedUnitCount = 10 + let response = try await uploadImageData(data, filename: filename, mimeType: mimeType, progress: progress) + DispatchQueue.main.async { + completion(.success(response)) + } + } catch { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + progress.completedUnitCount = 100 + } + + return progress + } + + /// Upload a Photos asset to PostImages + public func upload(_ asset: PHAsset, completion: @escaping (Result) -> Void) -> Progress { + let progress = Progress(totalUnitCount: 100) + + Task { + do { + progress.completedUnitCount = 5 + + // Request image data from the asset + let options = PHImageRequestOptions() + options.isSynchronous = false + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + + let (imageData, dataUTI) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, String?), Error>) in + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in + if let error = info?[PHImageErrorKey] as? Error { + continuation.resume(throwing: error) + } else if let data = data { + continuation.resume(returning: (data, dataUTI)) + } else { + continuation.resume(throwing: PostImagesError.assetLoadFailed) + } + } + } + + progress.completedUnitCount = 20 + + // Determine filename and MIME type based on the UTI + let (filename, mimeType): (String, String) + if let uti = dataUTI { + if uti.contains("png") { + filename = "image.png" + mimeType = "image/png" + } else if uti.contains("gif") { + filename = "image.gif" + mimeType = "image/gif" + } else if uti.contains("heic") || uti.contains("heif") { + // Convert HEIC/HEIF to JPEG as PostImages might not support it + filename = "image.jpg" + mimeType = "image/jpeg" + } else { + // Default to JPEG + filename = "image.jpg" + mimeType = "image/jpeg" + } + } else { + // Default to JPEG if no UTI available + filename = "image.jpg" + mimeType = "image/jpeg" + } + + let response = try await uploadImageData(imageData, filename: filename, mimeType: mimeType, progress: progress) + DispatchQueue.main.async { + completion(.success(response)) + } + } catch { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + progress.completedUnitCount = 100 + } + + return progress + } + + /// Upload from UIImagePickerController info dictionary + public func upload(_ info: [UIImagePickerController.InfoKey: Any], completion: @escaping (Result) -> Void) -> Progress { + // Try to get a PHAsset first (for better quality) + if let asset = info[.phAsset] as? PHAsset { + return upload(asset, completion: completion) + } + + // Fall back to UIImage + if let image = (info[.editedImage] ?? info[.originalImage]) as? UIImage { + return upload(image, completion: completion) + } + + let progress = Progress(totalUnitCount: 1) + progress.completedUnitCount = 1 + DispatchQueue.main.async { + completion(.failure(PostImagesError.noImageInPickerInfo)) + } + return progress + } + + // MARK: - Private Methods + + // PostImages.org file size limit - 32MB as confirmed by testing + private static let maxFileSize = 32 * 1024 * 1024 // 32MB + + private func uploadImageData(_ data: Data, filename: String, mimeType: String = "image/jpeg", progress: Progress) async throws -> PostImagesResponse { + // Check file size limit + if data.count > Self.maxFileSize { + logger.warning("Image size \(data.count) bytes exceeds PostImages limit of \(Self.maxFileSize) bytes") + throw PostImagesError.fileTooLarge(sizeInBytes: data.count, maxSizeInBytes: Self.maxFileSize) + } + + let boundary = UUID().uuidString + + var request = URLRequest(url: URL(string: "https://postimages.org/json")!) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // Build multipart form data + var body = Data() + + // Add parameters + let parameters = [ + "mode": "punbb", + "lang": "english", + "code": "hotlink", + "content": "", + "adult": "", + "optsize": "0", // No resizing, upload full image + "upload_session": UUID().uuidString, + "numfiles": "1", + "gallery": "", + "ui": generateUIString(), + "upload_referer": "https://awfulapp.com", + "forumurl": "https://forums.somethingawful.com" + ] + + for (key, value) in parameters { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + // Add image data + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n".data(using: .utf8)!) + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + + request.httpBody = body + + progress.completedUnitCount = 30 + + logger.debug("Uploading image to PostImages (\(data.count) bytes)") + + let (responseData, response) = try await session.data(for: request) + + progress.completedUnitCount = 90 + + guard let httpResponse = response as? HTTPURLResponse else { + throw PostImagesError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + logger.error("PostImages upload failed with status: \(httpResponse.statusCode)") + throw PostImagesError.uploadFailed("HTTP \(httpResponse.statusCode)") + } + + // Parse JSON response + guard let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else { + logger.error("Failed to parse PostImages response") + throw PostImagesError.invalidResponse + } + + logger.debug("PostImages response: \(String(describing: json))") + + // Extract the viewer URL from the response + guard let status = json["status"] as? String, status == "OK" else { + let error = json["error"] as? String ?? "Unknown error" + throw PostImagesError.uploadFailed(error) + } + + guard let viewerUrlString = json["url"] as? String else { + throw PostImagesError.invalidResponse + } + + // Now we need to make a second request to /mod to get the BBCode with the direct image link + // This mimics what postimages.js does on line 278-289 + let modParams = [ + "to": viewerUrlString, + "mode": "punbb", + "hash": "1", + "lang": "english", + "code": "hotlink", // We want hotlink code for direct image URL + "content": "", + "forumurl": "https://forums.somethingawful.com", + "areaid": UUID().uuidString, + "errors": "0", + "dz": "1" + ] + + var modUrlComponents = URLComponents(string: "https://postimages.org/mod")! + modUrlComponents.queryItems = modParams.map { URLQueryItem(name: $0.key, value: $0.value) } + + guard let modUrl = modUrlComponents.url else { + throw PostImagesError.invalidResponse + } + + var modRequest = URLRequest(url: modUrl) + modRequest.httpMethod = "GET" + + logger.debug("Getting BBCode from PostImages /mod endpoint") + + let (modData, modResponse) = try await session.data(for: modRequest) + + guard let modHttpResponse = modResponse as? HTTPURLResponse, + modHttpResponse.statusCode == 200 else { + logger.error("Failed to get BBCode from PostImages") + throw PostImagesError.invalidResponse + } + + // The response is BBCode text, not JSON + guard let bbcode = String(data: modData, encoding: .utf8) else { + throw PostImagesError.invalidResponse + } + + logger.debug("BBCode response: \(bbcode)") + + // Extract the direct image URL from the BBCode + // BBCode format is typically [img]https://i.postimg.cc/xxxxx/image.jpg[/img] + let imgPattern = #"\[img\](.*?)\[/img\]"# + guard let regex = try? NSRegularExpression(pattern: imgPattern, options: .caseInsensitive), + let match = regex.firstMatch(in: bbcode, range: NSRange(bbcode.startIndex..., in: bbcode)), + let urlRange = Range(match.range(at: 1), in: bbcode) else { + // If we can't parse BBCode, fall back to the viewer URL + logger.warning("Could not extract image URL from BBCode, using viewer URL") + return PostImagesResponse( + imageURL: URL(string: viewerUrlString)!, + directLink: nil, + viewerLink: viewerUrlString, + thumbnailLink: nil + ) + } + + let directImageUrl = String(bbcode[urlRange]) + + guard let imageUrl = URL(string: directImageUrl) else { + throw PostImagesError.invalidResponse + } + + logger.info("Successfully uploaded image to PostImages: \(imageUrl.absoluteString)") + + return PostImagesResponse( + imageURL: imageUrl, + directLink: directImageUrl, + viewerLink: viewerUrlString, + thumbnailLink: json["thumb_link"] as? String + ) + } + + private func generateUIString() -> String { + // Use constant values instead of real device info for privacy + // These are generic iOS device values that won't identify users + var ui = "" + ui += "2" // Retina display scale (most iOS devices) + ui += "375" // Generic iPhone width + ui += "667" // Generic iPhone height + ui += "true" // cookies enabled + ui += "en_US" // Generic locale + ui += "en_US" // Generic locale + ui += Date().description // Current date is fine + ui += "Awful iOS" + return ui + } +} + +/// Response from PostImages upload +public struct PostImagesResponse { + /// The main image URL (direct link to full image) + public let imageURL: URL + + /// Direct link to the image file + public let directLink: String? + + /// Link to the PostImages viewer page + public let viewerLink: String? + + /// Link to thumbnail (if available) + public let thumbnailLink: String? +} + +/// Errors specific to PostImages uploads +public enum PostImagesError: LocalizedError { + case invalidImageData + case assetLoadFailed + case noImageInPickerInfo + case invalidResponse + case uploadFailed(String) + case fileTooLarge(sizeInBytes: Int, maxSizeInBytes: Int) + + public var errorDescription: String? { + switch self { + case .invalidImageData: + return "Could not convert image to data" + case .assetLoadFailed: + return "Failed to load image from photo library" + case .noImageInPickerInfo: + return "No image found in picker selection" + case .invalidResponse: + return "Invalid response from PostImages" + case .uploadFailed(let message): + return "PostImages upload failed: \(message)" + case .fileTooLarge(let size, let maxSize): + let sizeMB = Double(size) / (1024 * 1024) + let maxSizeMB = Double(maxSize) / (1024 * 1024) + return String(format: "Image is too large (%.1fMB). Maximum size is %.0fMB", sizeMB, maxSizeMB) + } + } +} \ No newline at end of file