diff --git a/Checks/progress-reporting.mm b/Checks/progress-reporting.mm index 2461c20..eef7dc5 100644 --- a/Checks/progress-reporting.mm +++ b/Checks/progress-reporting.mm @@ -37,25 +37,37 @@ static NSString *nativePath(const std::string &path) { return [NSString stringWithUTF8String:path.c_str()]; } -static NSArray *records(const std::string &path) { +static NSArray *records(const std::string &path, int version = 1) { NSString *text = [NSString stringWithContentsOfFile:nativePath(path) encoding:NSUTF8StringEncoding error:nullptr]; require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line"); NSMutableArray *result = [NSMutableArray array]; NSArray *lines = [text componentsSeparatedByString:@"\n"]; - NSSet *keys = [NSSet setWithArray:@[@"version", @"event", @"media_url", @"position_seconds", - @"duration_seconds", @"completion_eligible"]]; + NSSet *keys = [NSSet setWithArray:@[@"version", @"event", version == 2 ? @"media_identity" : @"media_url", + @"position_seconds", @"duration_seconds", @"completion_eligible"]]; for (NSUInteger i = 0; i + 1 < lines.count; ++i) { id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nullptr]; require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record"); NSDictionary *row = object; require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields"); - require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == 1, - "schema version is numeric one"); + require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == version, + "schema version matches the expected source contract"); require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type"); - require([row[@"media_url"] isKindOfClass:[NSString class]] && - ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity"); + if (version == 2) { + NSDictionary *identity = row[@"media_identity"]; + require([identity isKindOfClass:[NSDictionary class]] && + [[NSSet setWithArray:identity.allKeys] isEqual: + [NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] && + [identity[@"provider"] isKindOfClass:[NSString class]] && + [identity[@"media_id"] isKindOfClass:[NSString class]] && + [identity[@"episode"] isKindOfClass:[NSNumber class]], "structured episode consumer identity"); + NSString *key = [NSString stringWithFormat:@"%@:%@:%@", identity[@"provider"], identity[@"media_id"], identity[@"episode"]]; + require(ProgressReporter::validMediaIdentity(key.UTF8String), "canonical episode consumer identity"); + } else { + require([row[@"media_url"] isKindOfClass:[NSString class]] && + ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity"); + } require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] && std::isfinite([row[@"position_seconds"] doubleValue]) && [row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative"); @@ -316,7 +328,7 @@ int main(int argc, char **argv) { reporter.finish(); require(reporter.error().empty(), "AniList progress drains successfully"); } - rows = records(anilistPath); + rows = records(anilistPath, 2); require(rows.count == 2, "AniList initial and closed snapshots"); for (NSDictionary *row in rows) { require([row[@"version"] intValue] == 2 && row[@"media_url"] == nil && diff --git a/Checks/zoko.swift b/Checks/zoko.swift index 7e1a5b5..aed7f35 100644 --- a/Checks/zoko.swift +++ b/Checks/zoko.swift @@ -40,9 +40,13 @@ struct ZokoCheck { try require(source.title == "AniList 1 ยท Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault, "honest fallback title and VTT metadata") try require(source.headers == ZokoResolver.referer && source.subtitles[0].headers == ZokoResolver.referer, "only fixed public Referer scoped to declared CDN") + let semicolonless = try page(config).replacingOccurrences(of: "';", with: "'") + let deployedSource = try ZokoResolver.parse(page: semicolonless, mediaID: "1", episode: 1) + try require(deployedSource.identity == source.identity, "deployed semicolonless static assignment is accepted") let missing = "// Error404

Not found.

We couldn't find anything to play here.

" try expect(.unavailable) { _ = try ZokoResolver.parse(page: missing, mediaID: "1", episode: 27) } - for protected in ["Just a moment", ""] { + for protected in ["Just a moment", "", + "

Sorry, you have been blocked

"] { try expect(.protection) { _ = try ZokoResolver.parse(page: protected, mediaID: "1", episode: 1) } } for malformed in ["", "window.__P='broken';", try page(config) + page(config), String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] { diff --git a/Sources/App/ZokoResolver.swift b/Sources/App/ZokoResolver.swift index 593a161..127e8ba 100644 --- a/Sources/App/ZokoResolver.swift +++ b/Sources/App/ZokoResolver.swift @@ -76,7 +76,7 @@ enum ZokoResolver { static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource { guard page.utf8.count <= bodyLimit, ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed } - let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;"# + let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;?\s*"# let regex = try NSRegularExpression(pattern: pattern) let matches = regex.matches(in: page, range: NSRange(page.startIndex..., in: page)) guard matches.count == 1, let match = matches.first, let range = Range(match.range(at: 1), in: page) else { @@ -84,7 +84,8 @@ enum ZokoResolver { let lower = page.lowercased() let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression) if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable } - if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") || lower.contains("captcha") { throw ZokoError.protection } + if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") || + lower.contains("captcha") || lower.contains("sorry, you have been blocked") { throw ZokoError.protection } throw ZokoError.malformed } guard let encoded = Data(base64Encoded: String(page[range])), !encoded.isEmpty else { throw ZokoError.malformed }