diff --git a/App/View Controllers/LoginViewController.swift b/App/View Controllers/LoginViewController.swift index 28dee41bb..627492cd8 100644 --- a/App/View Controllers/LoginViewController.swift +++ b/App/View Controllers/LoginViewController.swift @@ -33,12 +33,12 @@ class LoginViewController: ViewController { case awaitingPassword case canAttemptLogin case attemptingLogin - case failedLogin + case failedLogin(Error) } fileprivate var state: State = .awaitingUsername { didSet { - switch(state) { + switch state { case .awaitingUsername, .awaitingPassword: usernameTextField.isEnabled = true passwordTextField.isEnabled = true @@ -54,13 +54,23 @@ class LoginViewController: ViewController { nextBarButtonItem.isEnabled = false forgotPasswordButton.isHidden = true activityIndicator.startAnimating() - case .failedLogin: + case .failedLogin(let error): activityIndicator.stopAnimating() - let alert = UIAlertController(title: "Problem Logging In", message: "Double-check your username and password, then try again.", alertActions: [.ok { + let title: String + let message: String + if let error = error as? ServerError, case .banned = error { + // ServerError.banned has actually helpful info to report here. + title = error.localizedDescription + message = error.failureReason ?? "" + } else { + title = String(localized: "Problem Logging In") + message = String(localized: "Double-check your username and password, then try again.") + } + let alert = UIAlertController(title: title, message: message, alertActions: [.ok { self.state = .canAttemptLogin self.passwordTextField.becomeFirstResponder() }]) - present(alert, animated: true, completion: nil) + present(alert, animated: true) } } } @@ -123,7 +133,9 @@ class LoginViewController: ViewController { } fileprivate func attemptToLogIn() { - assert(state == .canAttemptLogin, "unexpected state") + if case .canAttemptLogin = state { /* yay */ } else { + assertionFailure("unexpected state: \(state)") + } state = .attemptingLogin Task { do { @@ -137,7 +149,7 @@ class LoginViewController: ViewController { completionBlock?(self) } catch { Log.e("Could not log in: \(error)") - state = .failedLogin + state = .failedLogin(error) } } } diff --git a/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift b/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift index 2f8e3f947..5e63edac2 100644 --- a/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift +++ b/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift @@ -100,19 +100,19 @@ public final class ForumsClient { } private var loginCookie: HTTPCookie? { - return baseURL + baseURL .flatMap { urlSession?.configuration.httpCookieStorage?.cookies(for: $0) }? .first { $0.name == "bbuserid" } } /// Whether or not a valid, logged-in session exists. public var isLoggedIn: Bool { - return loginCookie != nil + loginCookie != nil } /// When the valid, logged-in session expires. public var loginCookieExpiryDate: Date? { - return loginCookie?.expiresDate + loginCookie?.expiresDate } enum Error: Swift.Error { @@ -190,13 +190,29 @@ public final class ForumsClient { else { throw Error.missingManagedObjectContext } // Not that we'll parse any JSON from the login attempt, but tacking `json=1` on to `urlString` might avoid pointless server-side rendering. - let (data, _) = try await fetch(method: .post, urlString: "account.php?json=1", parameters: [ + let (data, response) = try await fetch(method: .post, urlString: "account.php?json=1", parameters: [ "action": "login", "username": username, "password" : password, "next": "/index.php?json=1", ]) - let result = try JSONDecoder().decode(IndexScrapeResult.self, from: data) + let result: IndexScrapeResult + do { + result = try JSONDecoder().decode(IndexScrapeResult.self, from: data) + } catch { + // We can fail to decode JSON when the server responds with an error as HTML. We may actually be logged in despite the error (e.g. a banned user can "log in" but do basically nothing). However, subsequent launches will crash because we don't actually store the logged-in user's ID. We can avoid the crash by clearing cookies, so we seem logged out. + urlSession?.configuration.httpCookieStorage?.removeCookies(since: .distantPast) + + if let error = error as? DecodingError, + case .dataCorrupted = error + { + // Response data was not JSON. Maybe it was a server error delivered as HTML? + _ = try parseHTML(data: data, response: response) + } + + // We couldn't figure out a more helpful error, so throw the decoding error. + throw error + } let backgroundUser = try await backgroundContext.perform { let managed = try result.upsert(into: backgroundContext) try backgroundContext.save() @@ -1255,10 +1271,7 @@ extension URLSessionTask: Cancellable {} private typealias ParsedDocument = (document: HTMLDocument, url: URL?) private func parseHTML(data: Data, response: URLResponse) throws -> ParsedDocument { - let contentType: String? = { - guard let response = response as? HTTPURLResponse else { return nil } - return response.allHeaderFields["Content-Type"] as? String - }() + let contentType = (response as? HTTPURLResponse)?.allHeaderFields["Content-Type"] as? String let document = HTMLDocument(data: data, contentTypeHeader: contentType) try checkServerErrors(document) return (document: document, url: response.url) @@ -1282,15 +1295,27 @@ private func workAroundAnnoyingImageBBcodeTagNotMatching(in postbody: HTMLElemen } } -enum ServerError: LocalizedError { +public enum ServerError: LocalizedError { + case banned(reason: URL?, help: URL?) case databaseUnavailable(title: String, message: String) case standard(title: String, message: String) - var errorDescription: String? { + public var errorDescription: String? { switch self { + case .banned: + String(localized: "You've Been Banned", bundle: .module) case .databaseUnavailable(title: _, message: let message), .standard(title: _, message: let message): - return message + message + } + } + + public var failureReason: String? { + switch self { + case .banned: + String(localized: "Congratulations! Please visit the Something Awful Forums website to learn why you were banned, to contact a mod or admin, to read the rules, or to reactivate your account.") + case .databaseUnavailable, .standard: + nil } } } @@ -1298,9 +1323,10 @@ enum ServerError: LocalizedError { private func checkServerErrors(_ document: HTMLDocument) throws { if let result = try? DatabaseUnavailableScrapeResult(document, url: nil) { throw ServerError.databaseUnavailable(title: result.title, message: result.message) - } - else if let result = try? StandardErrorScrapeResult(document, url: nil) { + } else if let result = try? StandardErrorScrapeResult(document, url: nil) { throw ServerError.standard(title: result.title, message: result.message) + } else if let result = try? BannedScrapeResult(document, url: nil) { + throw ServerError.banned(reason: result.reason, help: result.help) } } diff --git a/AwfulCore/Sources/AwfulCore/Scraping/BannedScrapeResult.swift b/AwfulCore/Sources/AwfulCore/Scraping/BannedScrapeResult.swift new file mode 100644 index 000000000..025e077bd --- /dev/null +++ b/AwfulCore/Sources/AwfulCore/Scraping/BannedScrapeResult.swift @@ -0,0 +1,25 @@ +// BannedScrapeResult.swift +// +// Copyright 2024 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import HTMLReader + +public struct BannedScrapeResult: ScrapeResult { + public let help: URL? + public let reason: URL? + + public init(_ html: HTMLNode, url: URL?) throws { + guard let body = html.firstNode(matchingSelector: "body.banned") else { + throw ScrapingError.missingExpectedElement("body.banned") + } + + help = body + .firstNode(matchingSelector: "a[href*='showthread.php']") + .flatMap { $0["href"] } + .flatMap { URL(string: $0) } + reason = body + .firstNode(matchingSelector: "a[href*='banlist.php']") + .flatMap { $0["href"] } + .flatMap { URL(string: $0) } + } +} diff --git a/AwfulCore/Tests/AwfulCoreTests/Fixtures/banned.html b/AwfulCore/Tests/AwfulCoreTests/Fixtures/banned.html new file mode 100644 index 000000000..5e6901d44 --- /dev/null +++ b/AwfulCore/Tests/AwfulCoreTests/Fixtures/banned.html @@ -0,0 +1,24 @@ + + + + +You've Been Banned! + + + +

CONGRATULATIONS pokeyman!!!

+Banned!

+

To check why you were banned, click here.

+

To contact a mod or admin, click here.

+

To read the god damn rules, click here.

+

To reactivate your Something Awful Forums account, please click here.

+To logout of your worthless banned account, click here. + + diff --git a/AwfulCore/Tests/AwfulCoreTests/Scraping/BannedScrapingTests.swift b/AwfulCore/Tests/AwfulCoreTests/Scraping/BannedScrapingTests.swift new file mode 100644 index 000000000..859f119c3 --- /dev/null +++ b/AwfulCore/Tests/AwfulCoreTests/Scraping/BannedScrapingTests.swift @@ -0,0 +1,27 @@ +// BannedScrapingTests.swift +// +// Copyright 2024 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +@testable import AwfulCore +import XCTest + +final class BannedScrapingTests: XCTestCase { + override class func setUp() { + super.setUp() + testInit() + } + + func testBanned() throws { + let scraped = try scrapeHTMLFixture(BannedScrapeResult.self, named: "banned") + + let help = try XCTUnwrap(scraped.help) + XCTAssertTrue(help.path.hasPrefix("/showthread.php")) + + let reason = try XCTUnwrap(scraped.reason) + XCTAssertTrue(reason.path.hasPrefix("/banlist.php")) + } + + func testNotBanned() throws { + XCTAssertThrowsError(try scrapeHTMLFixture(BannedScrapeResult.self, named: "forumdisplay")) + } +} diff --git a/AwfulExtensions/Sources/SwiftUI/Backports.swift b/AwfulExtensions/Sources/SwiftUI/Backports.swift index 682ee6f24..369b793f0 100644 --- a/AwfulExtensions/Sources/SwiftUI/Backports.swift +++ b/AwfulExtensions/Sources/SwiftUI/Backports.swift @@ -5,6 +5,15 @@ import SwiftUI public extension Backport where Content: View { + + @ViewBuilder func fontDesign(_ design: Font.Design?) -> some View { + if #available(iOS 16.1, *) { + content.fontDesign(design) + } else { + content + } + } + /// Sets the font weight of the text in this view. @ViewBuilder func fontWeight(_ weight: Font.Weight?) -> some View { if #available(iOS 16, *) { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index ce51f95c4..bc75c6ef3 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -286,6 +286,7 @@ public struct SettingsView: View { } .section() } + .backport.fontDesign(theme.roundedFonts ? .rounded : nil) .foregroundStyle(theme[color: "listText"]!) .tint(theme[color: "tint"]!) .backport.scrollContentBackground(.hidden)