diff --git a/WordPressKit.podspec b/WordPressKit.podspec index 48a0a123..f1cbfadf 100644 --- a/WordPressKit.podspec +++ b/WordPressKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "WordPressKit" - s.version = "4.19-beta.2" + s.version = "4.19-beta.3" s.summary = "WordPressKit offers a clean and simple WordPress.com and WordPress.org API." s.description = <<-DESC diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index 4fe06e61..3fdf04e6 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 14233D0F253DAEF7001C9E2E /* RemotePostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14233D0E253DAEF7001C9E2E /* RemotePostTests.swift */; }; + 14233D35253F6B51001C9E2E /* SHAHasher.m in Sources */ = {isa = PBXBuildFile; fileRef = 14233D34253F6B51001C9E2E /* SHAHasher.m */; }; + 14C42DBD2545EE3B0091712B /* SHAHasher.h in Headers */ = {isa = PBXBuildFile; fileRef = 14233D39253F6B99001C9E2E /* SHAHasher.h */; settings = {ATTRIBUTES = (Public, ); }; }; 1769DEAA24729AFF00F42EFC /* HomepageSettingsServiceRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1769DEA924729AFF00F42EFC /* HomepageSettingsServiceRemote.swift */; }; 17BF9A6C20C7DC3300BF57D2 /* reader-site-search-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 17BF9A6B20C7DC3300BF57D2 /* reader-site-search-success.json */; }; 17BF9A7220C7E18200BF57D2 /* reader-site-search-success-hasmore.json in Resources */ = {isa = PBXBuildFile; fileRef = 17BF9A6D20C7E18100BF57D2 /* reader-site-search-success-hasmore.json */; }; @@ -538,6 +541,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 14233D0E253DAEF7001C9E2E /* RemotePostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePostTests.swift; sourceTree = ""; }; + 14233D34253F6B51001C9E2E /* SHAHasher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SHAHasher.m; sourceTree = ""; }; + 14233D39253F6B99001C9E2E /* SHAHasher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SHAHasher.h; sourceTree = ""; }; 1769DEA924729AFF00F42EFC /* HomepageSettingsServiceRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageSettingsServiceRemote.swift; sourceTree = ""; }; 17BF9A6B20C7DC3300BF57D2 /* reader-site-search-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "reader-site-search-success.json"; sourceTree = ""; }; 17BF9A6D20C7E18100BF57D2 /* reader-site-search-success-hasmore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "reader-site-search-success-hasmore.json"; sourceTree = ""; }; @@ -1165,6 +1171,7 @@ 9AB6D649218727D60008F274 /* PostServiceRemoteRESTRevisionsTest.swift */, 740B23D01F17F6BB00067A2A /* PostServiceRemoteRESTTests.m */, 740B23D11F17F6BB00067A2A /* PostServiceRemoteXMLRPCTests.swift */, + 14233D0E253DAEF7001C9E2E /* RemotePostTests.swift */, ); name = Post; sourceTree = ""; @@ -1881,6 +1888,8 @@ 93C674EF1EE8351E00BFAF05 /* NSMutableDictionary+Helpers.h */, 93C674F01EE8351E00BFAF05 /* NSMutableDictionary+Helpers.m */, 9F4E51FF2088E38200424676 /* ObjectValidation.swift */, + 14233D39253F6B99001C9E2E /* SHAHasher.h */, + 14233D34253F6B51001C9E2E /* SHAHasher.m */, ); name = Utility; sourceTree = ""; @@ -2018,6 +2027,7 @@ 742362E01F1025B400BD0A7F /* RemoteMenuItem.h in Headers */, 7430C9B51F1927C50051B8E6 /* RemoteReaderSiteInfo.h in Headers */, 7430C9A71F1927180051B8E6 /* ReaderTopicServiceRemote.h in Headers */, + 14C42DBD2545EE3B0091712B /* SHAHasher.h in Headers */, 7430C9B71F1927C50051B8E6 /* RemoteReaderTopic.h in Headers */, 7430C9B31F1927C50051B8E6 /* RemoteReaderSite.h in Headers */, 7430C9B11F1927C50051B8E6 /* RemoteReaderPost.h in Headers */, @@ -2576,6 +2586,7 @@ 74650F741F0EA1E200188EDB /* RemoteGravatarProfile.swift in Sources */, 40E7FEB4221063480032834E /* StatsTodayInsight.swift in Sources */, 436D563C2118E18D00CEAA33 /* State.swift in Sources */, + 14233D35253F6B51001C9E2E /* SHAHasher.m in Sources */, 439A44DA2107C93000795ED7 /* RemotePlan_ApiVersion1_3.swift in Sources */, 93BD27811EE73944002BB00B /* WordPressOrgXMLRPCApi.swift in Sources */, 439A44D62107C66A00795ED7 /* JSONDecoderExtension.swift in Sources */, @@ -2654,6 +2665,7 @@ 93188D221F2264E60028ED4D /* TaxonomyServiceRemoteRESTTests.m in Sources */, F194E1232417ED9F00874408 /* AtomicAuthenticationServiceRemoteTests.swift in Sources */, 74FC6F3B1F191BB400112505 /* NotificationSyncServiceRemoteTests.swift in Sources */, + 14233D0F253DAEF7001C9E2E /* RemotePostTests.swift in Sources */, 731BA83821DECD97000FDFCD /* SiteCreationResponseDecodingTests.swift in Sources */, 9A2D0B2B225E0E22009E585F /* JetpackServiceRemoteTests.swift in Sources */, 74FA25F71F1FDA200044BC54 /* MediaServiceRemoteRESTTests.swift in Sources */, diff --git a/WordPressKit/RemotePost.h b/WordPressKit/RemotePost.h index d4fa0ac2..e002ce38 100644 --- a/WordPressKit/RemotePost.h +++ b/WordPressKit/RemotePost.h @@ -11,6 +11,7 @@ extern NSString * const PostStatusDeleted; @interface RemotePost : NSObject - (id)initWithSiteID:(NSNumber *)siteID status:(NSString *)status title:(NSString *)title content:(NSString *)content; +- (NSString *)contentHash; @property (nonatomic, strong) NSNumber *postID; @property (nonatomic, strong) NSNumber *siteID; diff --git a/WordPressKit/RemotePost.m b/WordPressKit/RemotePost.m index ef7960fe..2a57140d 100644 --- a/WordPressKit/RemotePost.m +++ b/WordPressKit/RemotePost.m @@ -1,5 +1,6 @@ #import "RemotePost.h" #import +#import "SHAHasher.h" NSString * const PostStatusDraft = @"draft"; NSString * const PostStatusPending = @"pending"; @@ -47,4 +48,48 @@ - (NSDictionary *)debugProperties { return [NSDictionary dictionaryWithDictionary:debugProperties]; } +/// A hash used to determine if the remote content has changed. +/// +/// This hash must remain constant regardless of iOS version, app restarts or instances used. `Hasher` or NSObject's `hash` were not used for these reasons. +/// +/// `dateModified` is not included within the hash as it is prone to change wihout the content having been changed and is the reason this hash is necessary. +/// +/// `autosave` properties are intentionally omitted as remote autosaves are always discarded in favor of local autosaves (aka `revision`s) +/// +/// - Note: At the time of writing the backend will occasionally create updates that neither come from autosaves or user initiated saves, these will modify the hash +/// and at present are treated as genuine updates as they are triggered by the user changing their posts content. +- (NSString *)contentHash +{ + NSString *hashedContents = [NSString stringWithFormat:@"%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@", + self.postID.stringValue, + self.siteID.stringValue, + self.authorAvatarURL, + self.authorDisplayName, + self.authorEmail, + self.authorURL, + self.authorID.stringValue, + self.date.description, + self.title, + self.URL.absoluteString, + self.shortURL.absoluteString, + self.content, + self.excerpt, + self.slug, + self.suggestedSlug, + self.status, + self.parentID.stringValue, + self.postThumbnailID.stringValue, + self.postThumbnailPath, + self.type, + self.format, + self.commentCount.stringValue, + self.likeCount.stringValue, + [self.tags componentsJoinedByString:@""], + self.pathForDisplayImage, + self.isStickyPost.stringValue, + self.isFeaturedImageChanged ? @"1" : @"2"]; + NSData *hashData = [SHAHasher hashForString:hashedContents]; + return [SHAHasher hexStringFromData:hashData]; +} + @end diff --git a/WordPressKit/SHAHasher.h b/WordPressKit/SHAHasher.h new file mode 100644 index 00000000..f624ee6c --- /dev/null +++ b/WordPressKit/SHAHasher.h @@ -0,0 +1,18 @@ +// +// SHAHasher.h +// WordPressKit +// +// Created by Declan McKenna on 20/10/2020. +// Copyright © 2020 Automattic Inc. All rights reserved. +// +#import + +@interface SHAHasher : NSObject ++ (NSString *)combineHashes:(NSArray*) hashArray; ++ (NSData *)hashForStringArray:(NSArray *) array; ++ (NSData *)hashForString:(NSString *) string; ++ (NSData *)hashForNSInteger:(NSInteger)integer; ++ (NSData *)hashForDouble:(double)dbl; ++ (NSData *)hashForBool:(BOOL)boolean; ++ (NSString *)hexStringFromData:(NSData *)data; +@end diff --git a/WordPressKit/SHAHasher.m b/WordPressKit/SHAHasher.m new file mode 100644 index 00000000..6a8617b5 --- /dev/null +++ b/WordPressKit/SHAHasher.m @@ -0,0 +1,81 @@ +// +// SHAHasher.m +// WordPressKit +// +// Created by Declan McKenna on 20/10/2020. +// Copyright © 2020 Automattic Inc. All rights reserved. +// +#import "SHAHasher.h" +#import +#import +@implementation SHAHasher + ++ (NSString *)combineHashes:(NSArray*) hashArray +{ + NSMutableData *mutableData = [NSMutableData data]; + for (NSData *hash in hashArray) { + [mutableData appendData:hash]; + } + + unsigned char finalDigest[CC_SHA256_DIGEST_LENGTH]; + + CC_SHA256(mutableData.bytes, (CC_LONG)mutableData.length, finalDigest); + + return [self hexStringFromData:[NSData dataWithBytes:finalDigest length:CC_SHA256_DIGEST_LENGTH]]; +} + ++ (NSData *)hashForStringArray:(NSArray *) array { + NSString *joinedArrayString = [array componentsJoinedByString:@""]; + return [self hashForString:joinedArrayString]; +} + ++ (NSData *)hashForString:(NSString *) string { + if (!string) { + return [[NSMutableData dataWithLength:CC_SHA256_DIGEST_LENGTH] copy]; + } + + NSData *encodedBytes = [string dataUsingEncoding:NSUTF8StringEncoding]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + + CC_SHA256(encodedBytes.bytes, (CC_LONG)encodedBytes.length, digest); + + return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH]; +} + ++ (NSData *)hashForNSInteger:(NSInteger)integer { + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + + CC_SHA256(&integer, sizeof(integer), digest); + + return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH]; +} + ++ (NSData *)hashForDouble:(double)dbl { + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + + CC_SHA256(&dbl, sizeof(dbl), digest); + + return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH]; +} + ++ (NSData *)hashForBool:(BOOL)boolean { + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + + CC_SHA256(&boolean, sizeof(boolean), digest); + + return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH]; +} + ++ (NSString *)hexStringFromData:(NSData *)data { + NSMutableString *mutableString = [NSMutableString string]; + + const char *hashBytes = [data bytes]; + + for (int i = 0; i < data.length; i++) { + [mutableString appendFormat:@"%02.2hhx", hashBytes[i]]; + } + + return [mutableString copy]; +} + +@end diff --git a/WordPressKit/WordPressKit.h b/WordPressKit/WordPressKit.h index 1a334e47..d634ca0f 100644 --- a/WordPressKit/WordPressKit.h +++ b/WordPressKit/WordPressKit.h @@ -57,3 +57,4 @@ FOUNDATION_EXPORT const unsigned char WordPressKitVersionString[]; #import #import +#import diff --git a/WordPressKitTests/RemotePostTests.swift b/WordPressKitTests/RemotePostTests.swift new file mode 100644 index 00000000..8f7f2ee7 --- /dev/null +++ b/WordPressKitTests/RemotePostTests.swift @@ -0,0 +1,84 @@ +// +// RemotePostTests.swift +// WordPressKitTests +// +// Created by Declan McKenna on 19/10/2020. +// Copyright © 2020 Automattic Inc. All rights reserved. +// + +import XCTest +@testable import WordPressKit + +class RemotePostTests: XCTestCase { + + func testHashWithNilValues() { + XCTAssertEqual(RemotePost().contentHash(), RemotePost().contentHash()) + } + + func testHashWithNilValuesIsPersistent() { + let expectedHash = "72bcdd41f59ecd51f66ada667342a04765ff8f17997a4d48ea708e6eabbf5580" + XCTAssertEqual(RemotePost().contentHash(), expectedHash) + } + + func testRemotePostHashIsSameForSameContent() { + let post = noNullPropertyPost + let identicalPost = noNullPropertyPost + XCTAssertEqual(post.contentHash(), identicalPost.contentHash()) + } + + func testRemotePostHashDiffersForDifferentContent() { + let post = noNullPropertyPost + let modifiedPost = noNullPropertyPost + modifiedPost.tags.append("new tag") + XCTAssertNotEqual(post.contentHash(), modifiedPost.contentHash()) + } + + func testRemotePostHashIsPersistent() { + let post = noNullPropertyPost + let expectedHash = "729a3df7c916699c5efb548dc4f53f43dec11d5516dd63ff6787c81904d464f1" + XCTAssertEqual(post.contentHash(), expectedHash) + } + + func testAutosaveDoesntAlterHash() { + let post = RemotePost() + let hash = RemotePost().contentHash() + post.autosave = RemotePostAutosave() + let autosavedPostHash = post.contentHash() + XCTAssertEqual(hash, autosavedPostHash) + } +} + +private extension RemotePostTests { + /// Remote post with all properties set to non null to ensure hash is consistent for all fields + var noNullPropertyPost: RemotePost { + let remotePost = RemotePost() + remotePost.postID = 90210 + remotePost.siteID = 101 + remotePost.authorAvatarURL = "www.test.com" + remotePost.authorDisplayName = "jk" + remotePost.authorEmail = "omg@somuchtestdata.com" + remotePost.authorURL = "swiftdec.com" + remotePost.authorID = 9001 + remotePost.date = Date(timeIntervalSince1970: 0) + remotePost.title = "Lorem Ipsum" + remotePost.url = URL(string: "lemonparty.com") + remotePost.shortURL = URL(string: "www.why.com") + remotePost.content = "Dolor Sit Amet" + remotePost.excerpt = "...." + remotePost.slug = "lorem-ipsum" + remotePost.suggestedSlug = "~!!" + remotePost.status = "draft" + remotePost.parentID = 42 + remotePost.postThumbnailID = 420 + remotePost.postThumbnailPath = "Arakis" + remotePost.type = "" + remotePost.format = "" + remotePost.commentCount = 666 + remotePost.likeCount = 555 + remotePost.tags = ["lorem,ipsum,test"] + remotePost.pathForDisplayImage = "!.com" + remotePost.isStickyPost = true + remotePost.isFeaturedImageChanged = false + return remotePost + } +}