You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
6.4 KiB
156 lines
6.4 KiB
import Foundation
|
|
import Network
|
|
import OSLog
|
|
|
|
struct HTTPRequest: Equatable {
|
|
var method: String
|
|
var path: String
|
|
var headers: [String: String]
|
|
var body: [UInt8]
|
|
}
|
|
|
|
struct HTTPResponse {
|
|
var status: Int
|
|
var contentType: String
|
|
var body: [UInt8]
|
|
|
|
static func text(_ status: Int, _ text: String) -> HTTPResponse {
|
|
HTTPResponse(status: status, contentType: "text/plain; charset=utf-8", body: Array(text.utf8))
|
|
}
|
|
|
|
static func json(_ value: some Encodable) -> HTTPResponse {
|
|
HTTPResponse(status: 200, contentType: "application/json", body: Array((try? JSONEncoder().encode(value)) ?? Data()))
|
|
}
|
|
|
|
var serialized: [UInt8] {
|
|
let reason = [200: "OK", 204: "No Content", 400: "Bad Request", 404: "Not Found", 405: "Method Not Allowed",
|
|
413: "Payload Too Large", 503: "Service Unavailable"][status] ?? "Error"
|
|
let head = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n"
|
|
+ "Cache-Control: no-store\r\nConnection: close\r\n\r\n"
|
|
return Array(head.utf8) + body
|
|
}
|
|
}
|
|
|
|
/// Incremental HTTP/1.1 request parser (no chunked bodies; the phone page only sends small JSON).
|
|
struct HTTPRequestParser {
|
|
enum ParseError: Error { case malformed, tooLarge }
|
|
|
|
static let maxHeader = 8 * 1024
|
|
static let maxBody = 64 * 1024
|
|
|
|
private var buffer: [UInt8] = []
|
|
|
|
/// Appends bytes; returns the request once it is complete.
|
|
mutating func append(_ bytes: [UInt8]) throws -> HTTPRequest? {
|
|
buffer += bytes
|
|
guard let headerEnd = Self.find([13, 10, 13, 10], in: buffer) else {
|
|
if buffer.count > Self.maxHeader { throw ParseError.tooLarge }
|
|
return nil
|
|
}
|
|
guard let head = String(bytes: buffer[0..<headerEnd], encoding: .utf8) else { throw ParseError.malformed }
|
|
let lines = head.components(separatedBy: "\r\n")
|
|
let requestLine = lines[0].split(separator: " ")
|
|
guard requestLine.count == 3, requestLine[2].hasPrefix("HTTP/1.") else { throw ParseError.malformed }
|
|
var headers: [String: String] = [:]
|
|
for line in lines.dropFirst() {
|
|
guard let colon = line.firstIndex(of: ":") else { continue }
|
|
headers[line[..<colon].lowercased()] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
|
}
|
|
let length = Int(headers["content-length"] ?? "0") ?? -1
|
|
guard length >= 0 else { throw ParseError.malformed }
|
|
guard length <= Self.maxBody else { throw ParseError.tooLarge }
|
|
let bodyStart = headerEnd + 4
|
|
guard buffer.count >= bodyStart + length else { return nil }
|
|
let path = String(requestLine[1].split(separator: "?", maxSplits: 1).first ?? "/")
|
|
return HTTPRequest(method: String(requestLine[0]), path: path, headers: headers,
|
|
body: Array(buffer[bodyStart..<bodyStart + length]))
|
|
}
|
|
|
|
private static func find(_ needle: [UInt8], in haystack: [UInt8]) -> Int? {
|
|
guard haystack.count >= needle.count else { return nil }
|
|
for i in 0...(haystack.count - needle.count) where haystack[i] == needle[0] {
|
|
if Array(haystack[i..<i + needle.count]) == needle { return i }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Minimal HTTP server bound to one interface. One request per connection.
|
|
final class HTTPServer: @unchecked Sendable {
|
|
typealias Handler = @Sendable (HTTPRequest) -> HTTPResponse
|
|
|
|
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.http")
|
|
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
|
|
private var listener: NWListener?
|
|
private let handler: Handler
|
|
|
|
init(handler: @escaping Handler) {
|
|
self.handler = handler
|
|
}
|
|
|
|
func start(interface: NWInterface, port: UInt16) async throws {
|
|
let parameters = NWParameters.tcp
|
|
parameters.requiredInterface = interface
|
|
parameters.allowLocalEndpointReuse = true
|
|
parameters.includePeerToPeer = false
|
|
let listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port) ?? .any)
|
|
let once = OnceFlag()
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
listener.stateUpdateHandler = { [log] state in
|
|
switch state {
|
|
case .ready:
|
|
if once.claim() { continuation.resume() }
|
|
case .failed(let error):
|
|
log.error("HTTP failed: \(error.localizedDescription, privacy: .public)")
|
|
if once.claim() { continuation.resume(throwing: error) }
|
|
default: break
|
|
}
|
|
}
|
|
listener.newConnectionHandler = { [weak self] connection in self?.serve(connection) }
|
|
listener.start(queue: queue)
|
|
}
|
|
self.listener = listener
|
|
}
|
|
|
|
func stop() {
|
|
listener?.cancel()
|
|
listener = nil
|
|
}
|
|
|
|
private func serve(_ connection: NWConnection) {
|
|
connection.start(queue: queue)
|
|
read(connection, parser: ParserBox())
|
|
}
|
|
|
|
private func read(_ connection: NWConnection, parser: ParserBox) {
|
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, isComplete, error in
|
|
guard let self else { return }
|
|
do {
|
|
if let data, let request = try parser.value.append(Array(data)) {
|
|
// Signalling may block briefly (session setup); keep the HTTP queue free.
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
self.respond(self.handler(request), on: connection)
|
|
}
|
|
return
|
|
}
|
|
} catch HTTPRequestParser.ParseError.tooLarge {
|
|
respond(.text(413, "Zu groß."), on: connection)
|
|
return
|
|
} catch {
|
|
respond(.text(400, "Ungültige Anfrage."), on: connection)
|
|
return
|
|
}
|
|
if isComplete || error != nil { connection.cancel() } else { read(connection, parser: parser) }
|
|
}
|
|
}
|
|
|
|
private func respond(_ response: HTTPResponse, on connection: NWConnection) {
|
|
connection.send(content: Data(response.serialized), completion: .contentProcessed { _ in connection.cancel() })
|
|
}
|
|
}
|
|
|
|
/// Per-connection parser state; only touched on the HTTP queue.
|
|
private final class ParserBox: @unchecked Sendable {
|
|
var value = HTTPRequestParser()
|
|
}
|