krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.1.3: Hutch/Networking/SystemStatusService.swift · raw

  1import Foundation
  2
  3struct SystemStatusService: Sendable {
  4    nonisolated static let statusURL = SRHTWebURL.status
  5    nonisolated static let feedURL = SRHTWebURL.statusIncidentFeed
  6
  7    private let session: URLSession
  8    private let now: @Sendable () -> Date
  9
 10    nonisolated init(session: URLSession = .shared, now: @escaping @Sendable () -> Date = Date.init) {
 11        self.session = session
 12        self.now = now
 13    }
 14
 15    func fetchSnapshot() async throws -> SystemStatusSnapshot {
 16        let html = try await fetchSnapshotHTML()
 17        return try Self.parseSnapshotHTML(html, fetchedAt: now())
 18    }
 19
 20    func fetchIncidentFeed() async throws -> [StatusIncident] {
 21        let data = try await fetchIncidentFeedData()
 22        return try await Self.parseIncidentFeedXML(data)
 23    }
 24
 25    func fetchSnapshotHTML() async throws -> String {
 26        try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
 27    }
 28
 29    func fetchIncidentFeedData() async throws -> Data {
 30        try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
 31    }
 32
 33    private func fetchText(from url: URL, accept: String) async throws -> String {
 34        let data = try await fetchData(from: url, accept: accept)
 35        guard let text = String(data: data, encoding: .utf8) else {
 36            throw SRHTError.decodingError(
 37                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
 38            )
 39        }
 40        return text
 41    }
 42
 43    private func fetchData(from url: URL, accept: String) async throws -> Data {
 44        var request = URLRequest(url: url)
 45        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
 46        request.setValue(accept, forHTTPHeaderField: "Accept")
 47
 48        let (data, response): (Data, URLResponse)
 49        do {
 50            (data, response) = try await session.data(for: request)
 51        } catch {
 52            throw SRHTError.networkError(error)
 53        }
 54
 55        if let http = response as? HTTPURLResponse,
 56           !(200...299).contains(http.statusCode) {
 57            throw SRHTError.httpError(http.statusCode)
 58        }
 59
 60        return data
 61    }
 62
 63    private var userAgent: String {
 64        let bundle = Bundle.main
 65        let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
 66            ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
 67            ?? "Hutch"
 68        let version = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev"
 69        return "\(name)/\(version) (System Status)"
 70    }
 71}
 72
 73extension SystemStatusService: SystemStatusServing {}
 74
 75extension SystemStatusService {
 76    nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot {
 77        let services = parseServices(in: html)
 78        let incidents = parseHTMLIncidentCards(in: html)
 79        let summaries = parseActiveIncidentSummaries(in: html)
 80
 81        let activeIncidents = incidents
 82            .filter { $0.isActive == true }
 83            .map { incident in
 84                let summary = incident.url.flatMap { summaries[$0.absoluteString] } ?? incident.summary
 85                return StatusIncident(
 86                    id: incident.id,
 87                    title: incident.title,
 88                    summary: summary,
 89                    url: incident.url,
 90                    publishedAt: incident.publishedAt,
 91                    updatedAt: incident.updatedAt,
 92                    isActive: incident.isActive
 93                )
 94            }
 95
 96        return SystemStatusSnapshot(services: services, activeIncidents: activeIncidents, lastUpdated: fetchedAt)
 97    }
 98
 99    nonisolated static func parseIncidentFeedXML(_ data: Data) async throws -> [StatusIncident] {
100        try await MainActor.run {
101            let parser = SystemStatusFeedParser()
102            return try parser.parse(data: data)
103        }
104    }
105
106    nonisolated private static func parseServices(in html: String) -> [StatusServiceState] {
107        firstMatches(
108            in: html,
109            pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent\b[^"']*["'])(?=[^>]*\bdata-status\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</div>"#
110        ).compactMap { captures in
111            guard captures.count >= 2 else { return nil }
112
113            let rawStatus = captures[0]
114            let content = captures[1]
115            guard let linkCaptures = firstMatches(
116                in: content,
117                pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
118            ).first,
119                  linkCaptures.count >= 2 else {
120                return nil
121            }
122
123            let href = linkCaptures[0]
124            let cleanedName = cleanText(linkCaptures[1])
125            let readableStatus = firstMatch(
126                in: content,
127                pattern: #"<(?:span|small|div)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent-status\b[^"']*["'])[^>]*>([\s\S]*?)</(?:span|small|div)>"#
128            ).map(cleanText) ?? firstStatusLabel(in: content) ?? ""
129            let level = statusLevel(fromHTMLStatus: rawStatus)
130
131            return StatusServiceState(
132                id: normalizedSlug(from: href, fallback: cleanedName) ?? cleanedName,
133                name: cleanedName,
134                slug: normalizedSlug(from: href, fallback: cleanedName),
135                status: level == .unknown ? statusLevel(fromLabel: readableStatus) : level,
136                description: nil
137            )
138        }
139    }
140
141    nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] {
142        firstMatches(
143            in: html,
144            pattern: #"<a\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bissue\b[^"']*["'])(?=[^>]*\bhref\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</a>"#
145        ).compactMap { captures in
146            guard captures.count >= 2 else { return nil }
147            let href = captures[0]
148            let content = captures[1]
149            guard let titleHTML = firstMatch(in: content, pattern: #"<h[1-6][^>]*>\s*([\s\S]*?)\s*</h[1-6]>"#)
150                    ?? firstMatch(in: content, pattern: #"<strong[^>]*>\s*([\s\S]*?)\s*</strong>"#)
151                    ?? firstMatch(in: content, pattern: #"<span[^>]*>\s*([\s\S]*?)\s*</span>"#),
152                  let publishedAt = publishedIncidentDate(in: content) else {
153                return nil
154            }
155
156            let url = URL(string: href, relativeTo: statusURL)?.absoluteURL
157            return StatusIncident(
158                id: url?.absoluteString ?? cleanText(titleHTML),
159                title: cleanText(titleHTML),
160                summary: nil,
161                url: url,
162                publishedAt: publishedAt,
163                updatedAt: nil,
164                isActive: isActiveIncidentCard(content)
165            )
166        }
167    }
168
169    nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] {
170        firstMatches(
171            in: html,
172            pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bannouncement-box\b[^"']*["'])[^>]*>([\s\S]*?)</div>\s*(?:<hr\b[^>]*\bannouncement-box\b[^>]*>)?"#
173        ).reduce(into: [:]) { partialResult, captures in
174            guard let content = captures.first,
175                  let titleLinkCaptures = firstMatches(
176                    in: content,
177                    pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
178                  ).first,
179                  let href = titleLinkCaptures.first else {
180                return
181            }
182
183            let paragraphs = firstMatches(in: content, pattern: #"<p>([\s\S]*?)</p>"#)
184                .compactMap(\.first)
185                .map(cleanText)
186                .filter { !$0.isEmpty }
187
188            let summary = paragraphs.dropFirst(2).first ?? paragraphs.dropFirst().first
189            guard let summary, !summary.isEmpty else { return }
190            if let url = URL(string: href, relativeTo: statusURL)?.absoluteURL {
191                partialResult[url.absoluteString] = summary
192            }
193        }
194    }
195
196    nonisolated private static func statusLevel(fromHTMLStatus status: String) -> StatusLevel {
197        switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
198        case "ok":
199            .operational
200        case "disrupted":
201            .degraded
202        case "down":
203            .majorOutage
204        case "notice":
205            .maintenance
206        default:
207            .unknown
208        }
209    }
210
211    nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel {
212        let normalizedLabel = label
213            .trimmingCharacters(in: .whitespacesAndNewlines)
214            .lowercased()
215            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
216
217        return switch normalizedLabel {
218        case "operational", "all systems operational":
219            StatusLevel.operational
220        case "disrupted", "degraded", "partial outage":
221            StatusLevel.degraded
222        case "down", "major outage", "outage":
223            StatusLevel.majorOutage
224        case "maintenance", "scheduled maintenance", "under maintenance":
225            StatusLevel.maintenance
226        default:
227            StatusLevel.unknown
228        }
229    }
230
231    nonisolated private static func normalizedSlug(from href: String, fallback name: String) -> String? {
232        if href.contains("/affected/") {
233            let trimmed = href.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
234            if let slug = trimmed.split(separator: "/").last {
235                return String(slug)
236            }
237        }
238        return name.isEmpty ? nil : name
239    }
240
241    nonisolated private static func firstMatch(in text: String, pattern: String) -> String? {
242        firstMatches(in: text, pattern: pattern).first?.first
243    }
244
245    nonisolated private static func firstMatches(in text: String, pattern: String) -> [[String]] {
246        guard let regex = try? NSRegularExpression(
247            pattern: pattern,
248            options: [.caseInsensitive, .dotMatchesLineSeparators]
249        ) else {
250            return []
251        }
252
253        let range = NSRange(text.startIndex..., in: text)
254        return regex.matches(in: text, range: range).map { match in
255            (1..<match.numberOfRanges).compactMap { captureIndex in
256                guard let captureRange = Range(match.range(at: captureIndex), in: text) else { return nil }
257                return String(text[captureRange])
258            }
259        }
260    }
261
262    nonisolated private static func cleanText(_ text: String) -> String {
263        let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
264        let decoded = decodeHTMLEntities(stripped)
265        return decoded
266            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
267            .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression)
268            .replacingOccurrences(of: "", with: "")
269            .trimmingCharacters(in: .whitespacesAndNewlines)
270    }
271
272    nonisolated private static func firstStatusLabel(in text: String) -> String? {
273        cleanText(text)
274            .split(separator: "")
275            .map(String.init)
276            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
277            .first(where: { statusLevel(fromLabel: $0) != .unknown })
278    }
279
280    nonisolated private static func publishedIncidentDate(in content: String) -> Date? {
281        if let timeValue = firstMatch(in: content, pattern: #"<time\b[^>]*\bdatetime\s*=\s*["']([^"']+)["'][^>]*>"#),
282           let parsed = parseISO8601Date(cleanText(timeValue)) {
283            return parsed
284        }
285
286        if let titleAttribute = firstMatch(
287            in: content,
288            pattern: #"<(?:small|time)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bdate\b[^"']*["'])[^>]*\btitle\s*=\s*["']([^"']+)["'][^>]*>"#
289        ) ?? firstMatch(in: content, pattern: #"\btitle\s*=\s*["']([^"']+UTC)["']"#) {
290            return htmlIssueDateFormatter.date(from: cleanText(titleAttribute))
291        }
292
293        return nil
294    }
295
296    nonisolated private static func isActiveIncidentCard(_ content: String) -> Bool {
297        let normalizedContent = cleanText(content).lowercased()
298        return normalizedContent.contains("not resolved yet")
299            || normalizedContent.contains("ongoing")
300            || normalizedContent.contains("investigating")
301    }
302
303    nonisolated fileprivate static func parseISO8601Date(_ value: String) -> Date? {
304        let formatter = ISO8601DateFormatter()
305        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
306        return formatter.date(from: value) ?? {
307            let fallbackFormatter = ISO8601DateFormatter()
308            fallbackFormatter.formatOptions = [.withInternetDateTime]
309            return fallbackFormatter.date(from: value)
310        }()
311    }
312
313    nonisolated private static let htmlIssueDateFormatter: DateFormatter = {
314        let formatter = DateFormatter()
315        formatter.locale = Locale(identifier: "en_US_POSIX")
316        formatter.timeZone = TimeZone(identifier: "UTC")
317        formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz"
318        return formatter
319    }()
320
321}
322
323private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable {
324    private var incidents: [StatusIncident] = []
325    private var currentItem: FeedItem?
326    private var textBuffer = ""
327
328    func parse(data: Data) throws -> [StatusIncident] {
329        incidents = []
330        currentItem = nil
331        textBuffer = ""
332
333        let parser = XMLParser(data: data)
334        parser.delegate = self
335        guard parser.parse() else {
336            throw parser.parserError ?? SRHTError.decodingError(
337                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Failed to parse status feed"))
338            )
339        }
340        return incidents.sorted { $0.publishedAt > $1.publishedAt }
341    }
342
343    func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, qualifiedName _: String?, attributes _: [String: String] = [:]) {
344        textBuffer = ""
345        if elementName == "item" {
346            currentItem = FeedItem()
347        }
348    }
349
350    func parser(_: XMLParser, foundCharacters string: String) {
351        textBuffer += string
352    }
353
354    func parser(_: XMLParser, foundCDATA cdata: Data) {
355        if let string = String(data: cdata, encoding: .utf8) {
356            textBuffer += string
357        }
358    }
359
360    func parser(_: XMLParser, didEndElement elementName: String, namespaceURI _: String?, qualifiedName _: String?) {
361        guard var currentItem else {
362            textBuffer = ""
363            return
364        }
365
366        let value = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
367        switch elementName {
368        case "title":
369            currentItem.title = value
370        case "link":
371            currentItem.link = value
372        case "guid":
373            currentItem.guid = value
374        case "description":
375            currentItem.description = value
376        case "pubDate", "dc:date":
377            currentItem.pubDate = value
378        case "category":
379            currentItem.category = value
380        case "item":
381            if let incident = currentItem.makeIncident() {
382                incidents.append(incident)
383            }
384            self.currentItem = nil
385        default:
386            self.currentItem = currentItem
387        }
388
389        if elementName != "item" {
390            self.currentItem = currentItem
391        }
392        textBuffer = ""
393    }
394
395    private struct FeedItem {
396        var title = ""
397        var link = ""
398        var guid = ""
399        var description = ""
400        var pubDate = ""
401        var category = ""
402
403        func makeIncident() -> StatusIncident? {
404            let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "")
405            guard !cleanedTitle.isEmpty,
406                  let publishedAt = SystemStatusFeedParser.parsePubDate(pubDate) else {
407                return nil
408            }
409
410            let url = URL(string: link)
411            let updatedAt = category.isEmpty ? nil : SystemStatusFeedParser.updatedDateFormatter.date(from: category)
412
413            return StatusIncident(
414                id: guid.isEmpty ? (url?.absoluteString ?? cleanedTitle) : guid,
415                title: cleanedTitle,
416                summary: SystemStatusFeedParser.summary(from: description),
417                url: url,
418                publishedAt: publishedAt,
419                updatedAt: updatedAt,
420                isActive: category.isEmpty
421            )
422        }
423    }
424
425    nonisolated private static func summary(from html: String) -> String? {
426        html
427            .components(separatedBy: "</p>")
428            .map { $0.replacingOccurrences(of: "<p>", with: "") }
429            .map(stripHTML)
430            .map {
431                $0.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
432                    .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression)
433                    .trimmingCharacters(in: .whitespacesAndNewlines)
434            }
435            .first { !$0.isEmpty }
436    }
437
438    nonisolated private static func stripHTML(_ text: String) -> String {
439        let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
440        return decodeHTMLEntities(stripped)
441    }
442
443    nonisolated private static func parsePubDate(_ value: String) -> Date? {
444        pubDateFormatter.date(from: value) ?? SystemStatusService.parseISO8601Date(value)
445    }
446
447    nonisolated private static let pubDateFormatter: DateFormatter = {
448        let formatter = DateFormatter()
449        formatter.locale = Locale(identifier: "en_US_POSIX")
450        formatter.timeZone = TimeZone(identifier: "UTC")
451        formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
452        return formatter
453    }()
454
455    nonisolated private static let updatedDateFormatter: DateFormatter = {
456        let formatter = DateFormatter()
457        formatter.locale = Locale(identifier: "en_US_POSIX")
458        formatter.timeZone = TimeZone(identifier: "UTC")
459        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
460        return formatter
461    }()
462}