krz/hutch

an ios client for sourcehut

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

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