Skip to content

Commit

Permalink
"You're banned" error scraping and thematic rounded SwiftUI font (#1187)
Browse files Browse the repository at this point in the history
* Detect and report "you're banned" error page

* Cleanup

* Round fonts in (SwiftUI) Settings screen when requested by theme
  • Loading branch information
nolanw authored Jun 19, 2024
1 parent 26226c9 commit 2c1a8bb
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 21 deletions.
26 changes: 19 additions & 7 deletions App/View Controllers/LoginViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -137,7 +149,7 @@ class LoginViewController: ViewController {
completionBlock?(self)
} catch {
Log.e("Could not log in: \(error)")
state = .failedLogin
state = .failedLogin(error)
}
}
}
Expand Down
54 changes: 40 additions & 14 deletions AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -1282,25 +1295,38 @@ 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
}
}
}

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)
}
}

Expand Down
25 changes: 25 additions & 0 deletions AwfulCore/Sources/AwfulCore/Scraping/BannedScrapeResult.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
24 changes: 24 additions & 0 deletions AwfulCore/Tests/AwfulCoreTests/Fixtures/banned.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "//www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="//www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
<title>You've Been Banned!</title>
<style type="text/css">
body { background-color: #000000; text-align:center; font-family:arial, sans-serif; }
h1 { font-weight: bold; font-size: xx-large; color: #00FF00; }
.style2 { color: #FF0000; }
a:link { color: #FF9900; }
a:visited { color: #FF3300; }
a:active { color: #FFFF00; }
</style>
</head>
<body id="something_awful" class="banned">
<h1>CONGRATULATIONS pokeyman!!!</h1>
<img src="//i.somethingawful.com/images/banned/banned2.jpg" alt="Banned!" width="800" height="800" border="1"></p>
<p class="style2"><a href="/banlist.php?userid=xxxxxx&amp;actfilt=-1">To check why you were banned, click here.</a></p>
<p class="style2"><a href="//forums.somethingawful.com/showthread.php?threadid=3809308">To contact a mod or admin, click here.</a></p>
<p class="style2"><a href="//www.somethingawful.com/forum-rules/forum-rules/">To read the god damn rules, click here.</a></p>
<p class="style2"><a href="https://store.somethingawful.com/products/unban.php">To reactivate your Something Awful Forums account, please click here.</a></p>
<a href="/account.php?action=logout&amp;ma=xxxxxxxx">To logout of your worthless banned account, click here.</a>
</body>
</html>
27 changes: 27 additions & 0 deletions AwfulCore/Tests/AwfulCoreTests/Scraping/BannedScrapingTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
9 changes: 9 additions & 0 deletions AwfulExtensions/Sources/SwiftUI/Backports.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, *) {
Expand Down
1 change: 1 addition & 0 deletions AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 2c1a8bb

Please sign in to comment.