diff --git a/Package.swift b/Package.swift index 0e89f6856..683f65681 100644 --- a/Package.swift +++ b/Package.swift @@ -223,6 +223,7 @@ let package:Package = .init( .target(name: "HTTP"), .target(name: "IP"), .target(name: "UA"), + .target(name: "URI"), .product(name: "HTML", package: "swift-dom"), .product(name: "Atomics", package: "swift-atomics"), @@ -531,6 +532,7 @@ let package:Package = .init( .target(name: "MarkdownRendering"), .target(name: "Media"), .target(name: "UA"), + .target(name: "URI"), ]), .target(name: "UnidocQueries", diff --git a/Sources/HTTPServer/HTTP.ServerIntegralRequest.swift b/Sources/HTTPServer/HTTP.ServerIntegralRequest.swift index d5d474b96..ac0d714ed 100644 --- a/Sources/HTTPServer/HTTP.ServerIntegralRequest.swift +++ b/Sources/HTTPServer/HTTP.ServerIntegralRequest.swift @@ -3,29 +3,18 @@ import IP import NIOCore import NIOHPACK import NIOHTTP1 +import URI extension HTTP { public protocol ServerIntegralRequest:Sendable { - init?(get path:String, - headers:borrowing HPACKHeaders, - origin:IP.Origin) + init?(get uri:URI, headers:HPACKHeaders, origin:IP.Origin) + init?(get uri:URI, headers:HTTPHeaders, origin:IP.Origin) - init?(get path:String, - headers:borrowing HTTPHeaders, - origin:IP.Origin) - - init?(post path:String, - headers:borrowing HPACKHeaders, - origin:IP.Origin, - body:borrowing [UInt8]) - - init?(post path:String, - headers:borrowing HTTPHeaders, - origin:IP.Origin, - body:consuming [UInt8]) + init?(post path:URI, headers:HPACKHeaders, origin:IP.Origin, body:borrowing [UInt8]) + init?(post path:URI, headers:HTTPHeaders, origin:IP.Origin, body:borrowing [UInt8]) } } extension HTTP.ServerIntegralRequest @@ -36,12 +25,10 @@ extension HTTP.ServerIntegralRequest /// Servers that expect to handle a lot of HTTP/1.1 GET requests should override this with /// a more efficient implementation. @inlinable public - init?(get path:String, - headers:borrowing HTTPHeaders, - origin:IP.Origin) + init?(get uri:URI, headers:HTTPHeaders, origin:IP.Origin) { - self.init(get: path, - headers: .init(httpHeaders: copy headers), + self.init(get: uri, + headers: .init(httpHeaders: headers), origin: origin) } @@ -51,13 +38,10 @@ extension HTTP.ServerIntegralRequest /// Servers that expect to handle a lot of HTTP/1.1 POST requests should override this with /// a more efficient implementation. @inlinable public - init?(post path:String, - headers:borrowing HTTPHeaders, - origin:IP.Origin, - body:consuming [UInt8]) + init?(post uri:URI, headers:HTTPHeaders, origin:IP.Origin, body:borrowing [UInt8]) { - self.init(post: path, - headers: .init(httpHeaders: copy headers), + self.init(post: uri, + headers: .init(httpHeaders: headers), origin: origin, body: body) } diff --git a/Sources/HTTPServer/HTTP.ServerLoop.swift b/Sources/HTTPServer/HTTP.ServerLoop.swift index 6097804d5..072bae8a2 100644 --- a/Sources/HTTPServer/HTTP.ServerLoop.swift +++ b/Sources/HTTPServer/HTTP.ServerLoop.swift @@ -8,6 +8,7 @@ import NIOHTTP1 import NIOHTTP2 import NIOPosix import NIOSSL +import URI extension NIOHTTP2Handler.AsyncStreamMultiplexer:@unchecked Sendable { @@ -323,13 +324,19 @@ extension HTTP.ServerLoop as _:Authority.Type) async throws -> HTTP.ServerResponse where Authority:HTTP.ServerAuthority { + guard let path:URI = .init(h1.uri) + else + { + return .resource("Malformed URI", status: 400) + } + switch h1.method { case .HEAD: fallthrough case .GET: - if let request:IntegralRequest = .init(get: h1.uri, + if let request:IntegralRequest = .init(get: path, headers: h1.headers, origin: origin) { @@ -377,7 +384,7 @@ extension HTTP.ServerLoop } } - if let request:IntegralRequest = .init(post: h1.uri, + if let request:IntegralRequest = .init(post: path, headers: h1.headers, origin: origin, body: /* consume */ body) // https://github.com/apple/swift/issues/71605 @@ -578,6 +585,13 @@ extension HTTP.ServerLoop return .resource("Missing headers", status: 400) } + guard + let path:URI = .init(path) + else + { + return .resource("Malformed URI", status: 400) + } + switch method { case "HEAD": diff --git a/Sources/HTTPServer/HTTP.ServerStreamedRequest.swift b/Sources/HTTPServer/HTTP.ServerStreamedRequest.swift index 0cb2e1d14..624eefd83 100644 --- a/Sources/HTTPServer/HTTP.ServerStreamedRequest.swift +++ b/Sources/HTTPServer/HTTP.ServerStreamedRequest.swift @@ -3,21 +3,22 @@ import IP import NIOCore import NIOHPACK import NIOHTTP1 +import URI extension HTTP { public protocol ServerStreamedRequest:Sendable { - init?(put path:String, headers:borrowing HPACKHeaders) + init?(put path:URI, headers:borrowing HPACKHeaders) - init?(put path:String, headers:borrowing HTTPHeaders) + init?(put path:URI, headers:borrowing HTTPHeaders) } } extension HTTP.ServerStreamedRequest { @inlinable public - init?(put path:String, headers:borrowing HTTPHeaders) + init?(put path:URI, headers:borrowing HTTPHeaders) { self.init(put: path, headers: .init(httpHeaders: copy headers)) } diff --git a/Sources/UnidocProfiling/HTTP.ProfileHeaders.swift b/Sources/UnidocProfiling/HTTP.ProfileHeaders.swift deleted file mode 100644 index 03af97ea6..000000000 --- a/Sources/UnidocProfiling/HTTP.ProfileHeaders.swift +++ /dev/null @@ -1,26 +0,0 @@ -import HTTP - -extension HTTP -{ - @frozen public - struct ProfileHeaders:Equatable, Sendable - { - public - var acceptLanguage:String? - public - var userAgent:String? - public - var referer:String? - - @inlinable public - init( - acceptLanguage:String? = nil, - userAgent:String? = nil, - referer:String? = nil) - { - self.acceptLanguage = acceptLanguage - self.userAgent = userAgent - self.referer = referer - } - } -} diff --git a/Sources/UnidocProfiling/ServerTour.Request.swift b/Sources/UnidocProfiling/ServerTour.Request.swift deleted file mode 100644 index 949b22b5d..000000000 --- a/Sources/UnidocProfiling/ServerTour.Request.swift +++ /dev/null @@ -1,31 +0,0 @@ -import HTTPServer -import IP - -extension ServerTour -{ - @frozen public - struct Request:Equatable, Sendable - { - public - var version:HTTP - public - var headers:HTTP.ProfileHeaders - public - var origin:IP.Origin - public - var path:String - - @inlinable public - init( - version:HTTP, - headers:HTTP.ProfileHeaders, - origin:IP.Origin, - path:String) - { - self.version = version - self.headers = headers - self.origin = origin - self.path = path - } - } -} diff --git a/Sources/UnidocRecords/Building/Unidoc.BuildReport.swift b/Sources/UnidocRecords/Building/Unidoc.BuildReport.swift index caadd0ab0..71a7aceea 100644 --- a/Sources/UnidocRecords/Building/Unidoc.BuildReport.swift +++ b/Sources/UnidocRecords/Building/Unidoc.BuildReport.swift @@ -1,4 +1,5 @@ import BSON +import UnidocAPI extension Unidoc { diff --git a/Sources/UnidocRender/Unidoc.ServerRoot.swift b/Sources/UnidocRender/Unidoc.ServerRoot.swift index 0d9697d5a..e35eacb0a 100644 --- a/Sources/UnidocRender/Unidoc.ServerRoot.swift +++ b/Sources/UnidocRender/Unidoc.ServerRoot.swift @@ -3,14 +3,14 @@ import URI extension Unidoc { @frozen public - enum ServerRoot + enum ServerRoot:String, Sendable { case account case admin case api case asset case auth - case blog + case blog = "articles" case docs case docc case help @@ -23,11 +23,20 @@ extension Unidoc case pdct case realm case really + case robots_txt = "robots.txt" + case sitemap_xml = "sitemap.xml" case ssgc case stats case tags case telescope case user + + /// Deprecated. + case guides + /// Deprecated. + case reference + /// Deprecated. + case sitemaps } } extension Unidoc.ServerRoot @@ -45,43 +54,10 @@ extension Unidoc.ServerRoot extension Unidoc.ServerRoot:CustomStringConvertible { @inlinable public - var description:String { "/\(self.id)" } -} -extension Unidoc.ServerRoot:Identifiable -{ - @inlinable public - var id:String - { - switch self - { - case .account: "account" - case .admin: "admin" - case .api: "api" - case .asset: "asset" - case .auth: "auth" - case .blog: "articles" - case .docs: "docs" - case .docc: "docc" - case .help: "help" - case .hist: "hist" - case .hook: "hook" - case .login: "login" - case .lunr: "lunr" - case .plugin: "plugin" - case .ptcl: "ptcl" - case .pdct: "pdct" - case .realm: "realm" - case .really: "really" - case .ssgc: "ssgc" - case .stats: "stats" - case .tags: "tags" - case .telescope: "telescope" - case .user: "user" - } - } + var description:String { "/\(self.rawValue)" } } extension Unidoc.ServerRoot { @inlinable public - var uri:URI { [.push(self.id)] } + var uri:URI { [.push(self.rawValue)] } } diff --git a/Sources/UnidocServer/Administration/HTTP.Headers.swift b/Sources/UnidocServer/Administration/HTTP.Headers.swift new file mode 100644 index 000000000..dbe58c268 --- /dev/null +++ b/Sources/UnidocServer/Administration/HTTP.Headers.swift @@ -0,0 +1,13 @@ +import HTTP +import NIOHTTP1 +import NIOHPACK + +extension HTTP +{ + @frozen @usableFromInline + enum Headers:Equatable, Sendable + { + case http1_1(HTTPHeaders) + case http2(HPACKHeaders) + } +} diff --git a/Sources/UnidocServer/Administration/ServerTour.Request.swift b/Sources/UnidocServer/Administration/ServerTour.Request.swift new file mode 100644 index 000000000..034c80ccd --- /dev/null +++ b/Sources/UnidocServer/Administration/ServerTour.Request.swift @@ -0,0 +1,66 @@ +import HTML +import HTTP +import HTTPServer +import IP +import NIOHPACK +import URI + +extension ServerTour +{ + @frozen @usableFromInline + struct Request:Equatable, Sendable + { + var version:HTTP + var headers:HTTP.Headers + var origin:IP.Origin + var uri:URI + + init( + version:HTTP, + headers:HTTP.Headers, + origin:IP.Origin, + uri:URI) + { + self.version = version + self.headers = headers + self.origin = origin + self.uri = uri + } + } +} +extension ServerTour.Request:HTML.OutputStreamable +{ + @usableFromInline static + func += (dl:inout HTML.ContentEncoder, self:Self) + { + let uri:String = "\(self.uri)" + + dl[.dt] = "Path" + dl[.dd] { $0[.a] { $0.href = "\(uri)" } = "\(uri)" } + + dl[.dt] = "IP address" + dl[.dd] = "\(self.origin.address)" + + switch self.headers + { + case .http1_1(let headers): + for (name, value):(String, String) in headers + { + dl[.dt] = name + dl[.dd] = value + } + + case .http2(let headers): + for (name, value, _):(String, String, HPACKIndexing) in headers + { + if case ":"? = name.first + { + continue + } + + dl[.dt] = name + dl[.dd] = value + } + } + } +} diff --git a/Sources/UnidocProfiling/ServerTour.SlowestQuery.swift b/Sources/UnidocServer/Administration/ServerTour.SlowestQuery.swift similarity index 69% rename from Sources/UnidocProfiling/ServerTour.SlowestQuery.swift rename to Sources/UnidocServer/Administration/ServerTour.SlowestQuery.swift index f458e6b5b..e360f43d4 100644 --- a/Sources/UnidocProfiling/ServerTour.SlowestQuery.swift +++ b/Sources/UnidocServer/Administration/ServerTour.SlowestQuery.swift @@ -1,3 +1,5 @@ +import URI + extension ServerTour { @frozen public @@ -6,13 +8,13 @@ extension ServerTour public let time:Duration public - let path:String + let uri:URI @inlinable public - init(time:Duration, path:String) + init(time:Duration, uri:URI) { self.time = time - self.path = path + self.uri = uri } } } diff --git a/Sources/UnidocProfiling/ServerTour.swift b/Sources/UnidocServer/Administration/ServerTour.swift similarity index 82% rename from Sources/UnidocProfiling/ServerTour.swift rename to Sources/UnidocServer/Administration/ServerTour.swift index d999bcdbf..8a03ee7ee 100644 --- a/Sources/UnidocProfiling/ServerTour.swift +++ b/Sources/UnidocServer/Administration/ServerTour.swift @@ -1,26 +1,19 @@ import HTTP +import UnidocProfiling -@frozen public +@frozen @usableFromInline struct ServerTour { - public let started:ContinuousClock.Instant - public var profile:ServerProfile - public var errors:Int - public var lastImpression:Request? - public var lastSearchbot:Request? - public var lastRequest:Request? - public var slowestQuery:SlowestQuery? - @inlinable public init(started:ContinuousClock.Instant = .now) { self.started = started diff --git a/Sources/UnidocServer/Administration/Unidoc.AdminPage.Action.Complete.swift b/Sources/UnidocServer/Administration/Unidoc.AdminPage.Action.Complete.swift deleted file mode 100644 index d2b62a2c1..000000000 --- a/Sources/UnidocServer/Administration/Unidoc.AdminPage.Action.Complete.swift +++ /dev/null @@ -1,30 +0,0 @@ -import HTML -import UnidocRender -import URI - -extension Unidoc.AdminPage.Action -{ - struct Complete - { - var action:Unidoc.AdminPage.Action - var text:String - - init(action:Unidoc.AdminPage.Action, text:String) - { - self.action = action - self.text = text - } - } -} -extension Unidoc.AdminPage.Action.Complete:Unidoc.StaticPage -{ - var location:URI { Unidoc.AdminPage[self.action] } - var title:String { "Action complete" } -} -extension Unidoc.AdminPage.Action.Complete:Unidoc.AdministrativePage -{ - func main(_ main:inout HTML.ContentEncoder, format:Unidoc.RenderFormat) - { - main[.p] = self.text - } -} diff --git a/Sources/UnidocServer/Administration/Unidoc.AdminPage.Action.swift b/Sources/UnidocServer/Administration/Unidoc.AdminPage.Action.swift deleted file mode 100644 index ee9800fd4..000000000 --- a/Sources/UnidocServer/Administration/Unidoc.AdminPage.Action.swift +++ /dev/null @@ -1,76 +0,0 @@ -import HTML -import UnidocRender -import URI - -extension Unidoc.AdminPage -{ - enum Action:String, Equatable, Hashable, Sendable - { - case dropUnidocDB = "drop-unidoc-db" - - case restart = "restart" - - case upload = "upload" - } -} -extension Unidoc.AdminPage.Action -{ - var label:String - { - switch self - { - case .dropUnidocDB: "Drop Unidoc Database" - case .restart: "Restart Server" - case .upload: "Upload Snapshots" - } - } -} -extension Unidoc.AdminPage.Action -{ - var prompt:String - { - switch self - { - case .dropUnidocDB: - """ - This will drop (and reinitialize) the entire database. Are you sure? - """ - - case .restart: - """ - This will restart the server. Are you sure? - """ - - case .upload: - "" - } - } -} -extension Unidoc.AdminPage.Action:Unidoc.RenderablePage -{ - var title:String { "\(self.label)?" } -} -extension Unidoc.AdminPage.Action:Unidoc.StaticPage -{ - var location:URI { Unidoc.AdminPage[self] } -} -extension Unidoc.AdminPage.Action:Unidoc.AdministrativePage -{ - func main(_ main:inout HTML.ContentEncoder, format:Unidoc.RenderFormat) - { - main[.form] - { - $0.enctype = "multipart/form-data" - $0.action = "\(self.location)" - $0.method = "post" - } - content: - { - $0[.p] = self.prompt - $0[.p] - { - $0[.button] { $0.type = "submit" } = self.label - } - } - } -} diff --git a/Sources/UnidocServer/Administration/Unidoc.AdminPage.swift b/Sources/UnidocServer/Administration/Unidoc.AdminPage.swift index b55951e02..b21d5a329 100644 --- a/Sources/UnidocServer/Administration/Unidoc.AdminPage.swift +++ b/Sources/UnidocServer/Administration/Unidoc.AdminPage.swift @@ -28,14 +28,6 @@ extension Unidoc } } } -extension Unidoc.AdminPage -{ - static - subscript(action:Action) -> URI - { - Unidoc.ServerRoot.admin / action.rawValue - } -} extension Unidoc.AdminPage:Unidoc.StaticPage { var location:URI { Unidoc.ServerRoot.admin.uri } @@ -77,31 +69,6 @@ extension Unidoc.AdminPage:Unidoc.AdministrativePage } } - main[.hr] - - main[.form] - { - $0.enctype = "\(MultipartType.form_data)" - $0.action = "\(Self[.upload])" - $0.method = "post" - } - content: - { - $0[.p] - { - $0[.input] - { - $0.multiple = true - $0.name = "documentation-binary" - $0.type = "file" - } - } - $0[.p] - { - $0[.button] { $0.type = "submit" } = Action.upload.label - } - } - // Non-destructive actions. main[.hr] @@ -143,30 +110,6 @@ extension Unidoc.AdminPage:Unidoc.AdministrativePage } } - // Destructive actions. - for action:Action in - [ - .dropUnidocDB, - .restart, - ] - { - main[.hr] - - main[.form] - { - $0.enctype = "\(MediaType.application(.x_www_form_urlencoded))" - $0.action = "\(Self[action])" - $0.method = "get" - } - content: - { - $0[.p] - { - $0[.button] { $0.type = "submit" } = action.label - } - } - } - main[.hr] main[.h2] = "Database servers" @@ -202,68 +145,36 @@ extension Unidoc.AdminPage:Unidoc.AdministrativePage $0[.dd] = "\(self.tour.errors)" } - main[.h2] = "Performance" + main[.h2] = "Headers" - main[.dl] + if let last:ServerTour.Request = self.tour.lastImpression { - if let last:ServerTour.Request = self.tour.lastImpression - { - $0[.h3] = "Last Impression" - - $0[.dt] = "Path" - $0[.dd] { $0[.a] { $0.href = last.path } = last.path } - - $0[.dt] = "User Agent" - $0[.dd] = last.headers.userAgent ?? "none" - - $0[.dt] = "IP address" - $0[.dd] = "\(last.origin.address)" - - $0[.dt] = "Accept Language" - $0[.dd] = last.headers.acceptLanguage ?? "none" - - $0[.dt] = "Referrer" - $0[.dd] = last.headers.referer ?? "none" - } - if let last:ServerTour.Request = self.tour.lastSearchbot - { - $0[.h3] = "Last Searchbot" - - $0[.dt] = "Path" - $0[.dd] { $0[.a] { $0.href = last.path } = last.path } - - $0[.dt] = "User Agent" - $0[.dd] = last.headers.userAgent ?? "none" - - $0[.dt] = "IP address" - $0[.dd] = "\(last.origin.address)" - - $0[.dt] = "Accept Language" - $0[.dd] = last.headers.acceptLanguage ?? "none" - } - if let last:ServerTour.Request = self.tour.lastRequest - { - $0[.h3] = "Last Request" - - $0[.dt] = "Path" - $0[.dd] { $0[.a] { $0.href = last.path } = last.path } - - $0[.dt] = "User Agent" - $0[.dd] = last.headers.userAgent ?? "none" - - $0[.dt] = "IP address" - $0[.dd] = "\(last.origin.address)" + main[.h3] = "Last Impression" + main[.dl] = last + } + if let last:ServerTour.Request = self.tour.lastSearchbot + { + main[.h3] = "Last Searchbot" + main[.dl] = last + } + if let last:ServerTour.Request = self.tour.lastRequest + { + main[.h3] = "Last Request" + main[.dl] = last + } - $0[.dt] = "Accept Language" - $0[.dd] = last.headers.acceptLanguage ?? "none" - } + main[.h2] = "Performance" + main[.dl] + { if let query:ServerTour.SlowestQuery = self.tour.slowestQuery { + let uri:String = "\(query.uri)" + $0[.dt] = "slowest query" $0[.dd] { - $0[.a] { $0.href = "\(query.path)" } = query.path + $0[.a] { $0.href = "\(uri)" } = "\(uri)" $0 += " (\(query.time))" } } diff --git a/Sources/UnidocServer/Operations/Interactions/Unidoc.LoginOperation.swift b/Sources/UnidocServer/Operations/Interactions/Unidoc.LoginOperation.swift index 10bdf8290..f13d4a1ec 100644 --- a/Sources/UnidocServer/Operations/Interactions/Unidoc.LoginOperation.swift +++ b/Sources/UnidocServer/Operations/Interactions/Unidoc.LoginOperation.swift @@ -2,15 +2,16 @@ import GitHubAPI import GitHubClient import HTTP import UnidocUI +import URI extension Unidoc { struct LoginOperation:Sendable { let flow:LoginFlow - let path:String + let path:URI - init(flow:LoginFlow, from path:String = "\(ServerRoot.account)") + init(flow:LoginFlow, from path:URI = ServerRoot.account.uri) { self.flow = flow self.path = path diff --git a/Sources/UnidocServer/Operations/Interactions/Unidoc.PackageWebhookError.swift b/Sources/UnidocServer/Operations/Interactions/Unidoc.PackageWebhookError.swift new file mode 100644 index 000000000..102d4250c --- /dev/null +++ b/Sources/UnidocServer/Operations/Interactions/Unidoc.PackageWebhookError.swift @@ -0,0 +1,19 @@ +extension Unidoc +{ + enum PackageWebhookError:Error + { + case missingEventType + case unverifiedOrigin + } +} +extension Unidoc.PackageWebhookError:CustomStringConvertible +{ + var description:String + { + switch self + { + case .missingEventType: return "Missing event type" + case .unverifiedOrigin: return "Unverified IP address" + } + } +} diff --git a/Sources/UnidocServer/Operations/Interactions/Unidoc.PackageWebhookOperation.swift b/Sources/UnidocServer/Operations/Interactions/Unidoc.PackageWebhookOperation.swift index 2459f45d8..a71cb57dd 100644 --- a/Sources/UnidocServer/Operations/Interactions/Unidoc.PackageWebhookOperation.swift +++ b/Sources/UnidocServer/Operations/Interactions/Unidoc.PackageWebhookOperation.swift @@ -1,5 +1,6 @@ import GitHubAPI import HTTP +import IP import JSON import MongoDB import SemanticVersions @@ -7,24 +8,47 @@ import UnidocRender extension Unidoc { - struct PackageWebhookOperation:Sendable + enum PackageWebhookOperation:Sendable { - private - let event:GitHub.WebhookCreate - - private - init(event:GitHub.WebhookCreate) - { - self.event = event - } + case create(GitHub.WebhookCreate) + case ignore(String) } } extension Unidoc.PackageWebhookOperation { - init(parsing body:[UInt8]) throws + init(json:JSON, from origin:IP.Origin, with headers:__shared HTTP.Headers) throws { - let json:JSON = .init(utf8: body[...]) - self.init(event: try json.decode()) + // Did this request actually come from GitHub? (Anyone can POST over HTTP/2.) + // + // FIXME: there is a security hole during the (hopefully brief) interval between + // when the server restarts and the whitelists are initialized. + switch origin.owner + { + case .github: break + case .unknown: break + default: throw Unidoc.PackageWebhookError.unverifiedOrigin + } + + let type:String? + + switch headers + { + case .http1_1(let headers): type = headers["X-GitHub-Event"].first + case .http2(let headers): type = headers["X-GitHub-Event"].first + } + + switch type + { + case "create"?: + self = .create(try json.decode()) + + case let type?: + self = .ignore(type) + + case nil: + throw Unidoc.PackageWebhookError.missingEventType + } + } } extension Unidoc.PackageWebhookOperation:Unidoc.PublicOperation @@ -33,12 +57,18 @@ extension Unidoc.PackageWebhookOperation:Unidoc.PublicOperation func load(from server:borrowing Unidoc.Server, as format:Unidoc.RenderFormat) async throws -> HTTP.ServerResponse? { + var event:GitHub.WebhookCreate + switch self + { + case .create(let value): event = value + case .ignore(let type): return .ok("Ignored event type '\(type)'\n") + } + let session:Mongo.Session = try await .init(from: server.db.sessions) - var event:GitHub.WebhookCreate = self.event guard let package:Unidoc.PackageMetadata = try await server.db.packages.findGitHub( - repo: self.event.repo.id, + repo: event.repo.id, with: session) else { diff --git a/Sources/UnidocServer/Operations/Interactions/Unidoc.SiteConfigOperation.swift b/Sources/UnidocServer/Operations/Interactions/Unidoc.SiteConfigOperation.swift index e4262fe53..ff66d880c 100644 --- a/Sources/UnidocServer/Operations/Interactions/Unidoc.SiteConfigOperation.swift +++ b/Sources/UnidocServer/Operations/Interactions/Unidoc.SiteConfigOperation.swift @@ -11,7 +11,6 @@ extension Unidoc { enum SiteConfigOperation { - case perform(Unidoc.AdminPage.Action, MultipartForm?) case recode(Unidoc.AdminPage.Recode.Target) case telescope(days:Int) } @@ -21,8 +20,6 @@ extension Unidoc.SiteConfigOperation:Unidoc.AdministrativeOperation func load(from server:borrowing Unidoc.Server, with session:Mongo.Session) async throws -> HTTP.ServerResponse? { - let page:Unidoc.AdminPage.Action.Complete - switch self { case .recode(let target): @@ -44,25 +41,11 @@ extension Unidoc.SiteConfigOperation:Unidoc.AdministrativeOperation return .ok(complete.resource(format: server.format)) - case .perform(.dropUnidocDB, _): - try await server.db.unidoc.drop(with: session) - - page = .init(action: .dropUnidocDB, text: "Reinitialized Unidoc database!") - - case .perform(.restart, _): - fatalError("Restarting server...") - - case .perform(.upload, _): - // No longer supported. - return nil - case .telescope(days: let days): let updates:Mongo.Updates = try await server.db.crawlingWindows.create(days: days, with: session) return .ok("Updated \(updates.modified) of \(updates.selected) crawling windows.") } - - return .ok(page.resource(format: server.format)) } } diff --git a/Sources/UnidocServer/Operations/Interactions/Unidoc.UserRenderOperation.swift b/Sources/UnidocServer/Operations/Interactions/Unidoc.UserRenderOperation.swift index 5413469bc..644182ba0 100644 --- a/Sources/UnidocServer/Operations/Interactions/Unidoc.UserRenderOperation.swift +++ b/Sources/UnidocServer/Operations/Interactions/Unidoc.UserRenderOperation.swift @@ -28,18 +28,11 @@ extension Unidoc.UserRenderOperation { init(volume:Unidoc.VolumeSelector, shoot:Unidoc.Shoot, - query parameters:__shared [(key:String, value:String)]?) + query:__shared URI.Query) { self.init(request: .init(volume: volume, vertex: shoot)) - guard - let parameters:[(key:String, value:String)] - else - { - return - } - - for (key, value):(String, String) in parameters + for (key, value):(String, String) in query.parameters { switch key { diff --git a/Sources/UnidocServer/Operations/Unidoc.Credentials.swift b/Sources/UnidocServer/Operations/Unidoc.Credentials.swift index 8893b220f..8abe7dfbf 100644 --- a/Sources/UnidocServer/Operations/Unidoc.Credentials.swift +++ b/Sources/UnidocServer/Operations/Unidoc.Credentials.swift @@ -1,3 +1,5 @@ +import URI + extension Unidoc { @frozen public @@ -6,10 +8,10 @@ extension Unidoc public let cookies:Cookies public - let request:String + let request:URI @inlinable public - init(cookies:Cookies, request:String) + init(cookies:Cookies, request:URI) { self.cookies = cookies self.request = request diff --git a/Sources/UnidocServer/Operations/Unidoc.LoginPage.swift b/Sources/UnidocServer/Operations/Unidoc.LoginPage.swift index 6a768a727..dc12d0f98 100644 --- a/Sources/UnidocServer/Operations/Unidoc.LoginPage.swift +++ b/Sources/UnidocServer/Operations/Unidoc.LoginPage.swift @@ -10,11 +10,11 @@ extension Unidoc struct LoginPage { let client:String - let from:String + let from:URI let flow:LoginFlow - init(client:String, flow:LoginFlow, from:String) + init(client:String, flow:LoginFlow, from:URI) { self.client = client self.flow = flow diff --git a/Sources/UnidocServer/Requests/MD5 (ext).swift b/Sources/UnidocServer/Requests/MD5 (ext).swift new file mode 100644 index 000000000..2a4ed2a0f --- /dev/null +++ b/Sources/UnidocServer/Requests/MD5 (ext).swift @@ -0,0 +1,32 @@ +import MD5 + +extension MD5 +{ + init?(header lines:[Line]) where Line:StringProtocol, Line.SubSequence == Substring + { + // We aren’t parsing this correctly, because an `if-none-match` field can contain + // multiples entity tags. We cannot perform a naïve split on commas, because entity + // tags themselves can contain commas. This implementation also won’t parse tags with + // the weak comparison prefix (`W/`). + for line:Line in lines + { + guard + let last:String.Index = line.indices.last, + last != line.startIndex, + line[line.startIndex] == "\"", + line[last] == "\"" + else + { + continue + } + + if let tag:MD5 = .init(line[line.index(after: line.startIndex) ..< last]) + { + self = tag + return + } + } + + return nil + } +} diff --git a/Sources/UnidocServer/Requests/Unidoc.ClientAnnotation.swift b/Sources/UnidocServer/Requests/Unidoc.ClientAnnotation.swift index ba802701c..7e2d8591f 100644 --- a/Sources/UnidocServer/Requests/Unidoc.ClientAnnotation.swift +++ b/Sources/UnidocServer/Requests/Unidoc.ClientAnnotation.swift @@ -56,7 +56,7 @@ extension Unidoc.ClientAnnotation extension Unidoc.ClientAnnotation { static - func guess(headers:HTTP.ProfileHeaders, owner:IP.Owner) -> Self + func guess(headers:HTTP.Headers, owner:IP.Owner) -> Self { switch owner { @@ -65,21 +65,38 @@ extension Unidoc.ClientAnnotation case _: break } + let acceptLanguage:String? + let userAgent:String? + let referer:String? + + switch headers + { + case .http1_1(let headers): + acceptLanguage = headers["accept-language"].last + userAgent = headers["user-agent"].last + referer = headers["referer"].last + + case .http2(let headers): + acceptLanguage = headers["accept-language"].last + userAgent = headers["user-agent"].last + referer = headers["referer"].last + } + guard - let agent:String = headers.userAgent + let userAgent:String else { return .robot(.tool) } - if agent.starts(with: "Discourse Forum Onebox") + if userAgent.starts(with: "Discourse Forum Onebox") { // This is *probably* the Swift Forums bot. return .robot(.discoursebot) } guard - let agent:UA = .init(agent) + let userAgent:UA = .init(userAgent) else { return .robot(.tool) @@ -88,7 +105,7 @@ extension Unidoc.ClientAnnotation /// Base suspicion level. var suspicion:Int = 100 - for component:UA.Component in agent.components + for component:UA.Component in userAgent.components { switch component { @@ -148,9 +165,9 @@ extension Unidoc.ClientAnnotation } guard - let locale:String = headers.acceptLanguage, - let locale:HTTP.AcceptLanguage = .init(locale), - let locale:HTTP.Locale = locale.dominant + let acceptLanguage:String, + let acceptLanguage:HTTP.AcceptLanguage = .init(acceptLanguage), + let locale:HTTP.Locale = acceptLanguage.dominant else { // Didn’t send a locale: definitely a bot. @@ -158,12 +175,12 @@ extension Unidoc.ClientAnnotation } // Sent a referrer: might be a Barbie. - if case _? = headers.referer + if case _? = referer { suspicion -= 1 } - for component:UA.Component in agent.components + for component:UA.Component in userAgent.components { switch component { diff --git a/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Metadata.swift b/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Metadata.swift index b3b8d0b4f..59726432c 100644 --- a/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Metadata.swift +++ b/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Metadata.swift @@ -6,6 +6,7 @@ import NIOHTTP1 import UA import UnidocProfiling import UnidocRecords +import URI extension Unidoc.IntegralRequest { @@ -14,69 +15,71 @@ extension Unidoc.IntegralRequest { public let annotation:Unidoc.ClientAnnotation + + let headers:HTTP.Headers let cookies:Unidoc.Cookies - public - let version:HTTP - public - let headers:HTTP.ProfileHeaders public let origin:IP.Origin public let host:String? public - let path:String + let uri:URI private init( - annotation:Unidoc.ClientAnnotation, + headers:HTTP.Headers, cookies:Unidoc.Cookies, - version:HTTP, - headers:HTTP.ProfileHeaders, origin:IP.Origin, host:String?, - path:String) + uri:URI) { - self.annotation = annotation - self.cookies = cookies - - self.version = version + self.annotation = .guess(headers: headers, owner: origin.owner) self.headers = headers + self.cookies = cookies self.origin = origin self.host = host - self.path = path + self.uri = uri } } } extension Unidoc.IntegralRequest.Metadata { - var hostSupportsPublicAPI:Bool + /// Computes and returns the case-folded, normalized path from the ``uri``. + var path:ArraySlice + { + self.uri.path.normalized(lowercase: true)[...] + } +} +extension Unidoc.IntegralRequest.Metadata +{ + var version:HTTP { - switch self.host + switch self.headers { - case "api.swiftinit.org"?: true - case "localhost"?: true - default: false + case .http1_1: .http1_1 + case .http2: .http2 } } + var logged:ServerTour.Request { .init( version: self.version, headers: self.headers, origin: self.origin, - path: self.path) + uri: self.uri) } var credentials:Unidoc.Credentials { - .init(cookies: self.cookies, request: self.path) + .init(cookies: self.cookies, request: self.uri) } } extension Unidoc.IntegralRequest.Metadata { public - init(headers:borrowing HPACKHeaders, origin:IP.Origin, path:String) + init(headers:HPACKHeaders, origin:IP.Origin, uri:URI) { let cookies:[String] = headers["cookie"] let host:String? = headers[":authority"].last.map @@ -91,48 +94,24 @@ extension Unidoc.IntegralRequest.Metadata } } - let headers:HTTP.ProfileHeaders = .init( - acceptLanguage: headers["accept-language"].last, - userAgent: headers["user-agent"].last, - referer: headers["referer"].last) - - self.init( - annotation: .guess(headers: headers, owner: origin.owner), + self.init(headers: .http2(headers), cookies: .init(header: cookies), - version: .http2, - headers: headers, origin: origin, host: host, - path: path) + uri: uri) } public - init(headers:borrowing HTTPHeaders, origin:IP.Origin, path:String) + init(headers:HTTPHeaders, origin:IP.Origin, uri:URI) { let host:String? = headers["host"].last - let headers:HTTP.ProfileHeaders = .init( - acceptLanguage: headers["accept-language"].last, - userAgent: headers["user-agent"].last, - referer: headers["referer"].last) // None of our authenticated endpoints support HTTP/1.1, so there is no // need to load cookies. - self.init( - annotation: .guess(headers: headers, owner: origin.owner), + self.init(headers: .http1_1(headers), cookies: .init(), - version: .http1_1, - headers: headers, origin: origin, host: host, - path: path) - - if case .robot(.discoursebot) = self.annotation - { - Log[.debug] = """ - Approved possible Swift Forums robot - User-Agent: '\(headers.userAgent ?? "")' - IP Address: \(origin.address) - """ - } + uri: uri) } } diff --git a/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Ordering.swift b/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Ordering.swift index 28015695c..c045fde2e 100644 --- a/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Ordering.swift +++ b/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.Ordering.swift @@ -1,21 +1,8 @@ -import BSON -import FNV1 import HTTP -import IP import MD5 -import Media import MongoDB -import Multiparts -import SemanticVersions -import UnidocUI -import Symbols import UnidocAssets -import UnidocDB -import UnidocQueries -import UnidocRecords import UnidocRender -import UnixTime -import URI extension Unidoc.IntegralRequest { @@ -39,644 +26,13 @@ extension Unidoc.IntegralRequest.Ordering static func explainable(_ endpoint:Base, parameters:Unidoc.PipelineParameters, - accept:HTTP.AcceptType? = nil) -> Self + etag:MD5?) -> Self where Base:HTTP.ServerEndpoint, Base:Mongo.PipelineEndpoint, Base:Sendable { parameters.explain ? .actor(Unidoc.LoadExplainedOperation.init(query: endpoint.query)) - : .actor(Unidoc.LoadOptimizedOperation.init(base: endpoint, - etag: parameters.tag)) - } -} -// GET endpoints -extension Unidoc.IntegralRequest.Ordering -{ - static - func get(admin trunk:String, _ stem:ArraySlice, tag:MD5?) -> Self? - { - if let action:Unidoc.AdminPage.Action = .init(rawValue: trunk) - { - return .syncResource(action) - } - - switch trunk - { - case Unidoc.AdminPage.Recode.name: - guard - let target:String = stem.first - else - { - return .syncResource(Unidoc.AdminPage.Recode.init()) - } - - if let target:Unidoc.AdminPage.Recode.Target = .init(rawValue: target) - { - return .syncResource(target) - } - else - { - return nil - } - - case Unidoc.ReplicaSetPage.name: - return .actor(Unidoc.LoadDashboardOperation.replicaSet) - - case Unidoc.CookiePage.name: - return .actor(Unidoc.LoadDashboardOperation.cookie(scramble: false)) - - case "robots": - return .actor(Unidoc.TextEditorOperation.init(id: .robots_txt)) - - case _: - return nil - } - } - - static - func get(asset trunk:String, tag:MD5?) -> Self? - { - let asset:Unidoc.Asset? = .init(trunk) - return asset.map { .syncLoad(.init($0, tag: tag)) } - } - - static - func get(auth trunk:String, with parameters:Unidoc.AuthParameters) -> Self? - { - switch trunk - { - case "github": - if let state:String = parameters.state, - let code:String = parameters.code, - let from:String = parameters.from, - let flow:Unidoc.LoginFlow = parameters.flow - { - return .actor(Unidoc.AuthOperation.init(state: state, - code: code, - flow: flow, - from: from)) - } - - case "register": - if let token:String = parameters.token - { - return .actor(Unidoc.UserIndexOperation.init(token: token, flow: .sso)) - } - - case _: - break - } - - return nil - } - - static - func get(blog:String, - _ trunk:String, - with parameters:Unidoc.PipelineParameters) -> Self - { - .explainable(Unidoc.BlogEndpoint.init(query: .init( - volume: .init(package: "__swiftinit", version: "__max"), - vertex: .init(path: [blog, trunk], hash: nil))), - parameters: parameters) - } - - static - func get(docs trunk:String, - _ stem:ArraySlice, - with parameters:Unidoc.PipelineParameters) -> Self - { - let volume:Unidoc.VolumeSelector = .init(trunk) - - // Special sitemap route. - // The '-' in the name means it will never collide with a decl. - if case nil = volume.version, - case "all-symbols"? = stem.first, - case stem.endIndex = stem.index(after: stem.startIndex) - { - return .explainable(Unidoc.SitemapEndpoint.init(query: .init( - package: volume.package)), - parameters: parameters) - } - else - { - let shoot:Unidoc.Shoot = .init(path: stem, hash: parameters.hash) - return .explainable(Unidoc.DocsEndpoint.init(query: .init( - volume: volume, - vertex: shoot)), - parameters: parameters) - } - } - - static - func get(lunr trunk:String, - with parameters:Unidoc.PipelineParameters) -> Self? - { - if let id:Symbol.Edition = .init(trunk) - { - .explainable(Unidoc.LunrEndpoint.init(query: .init( - tag: parameters.tag, - id: id)), - parameters: parameters, - accept: .application(.json)) - } - else if trunk == "packages.json" - { - .explainable(Unidoc.TextEndpoint.init(query: .init( - tag: parameters.tag, - id: .packages_json)), - parameters: parameters, - accept: .application(.json)) - } - else - { - nil - } - } - - static - func get(ptcl trunk:String, - _ stem:ArraySlice, - with parameters:Unidoc.PipelineParameters) -> Self - { - .explainable(Unidoc.PtclEndpoint.init(query: .init( - volume: .init(trunk), - vertex: .init(path: stem, hash: parameters.hash), - layer: .protocols)), - parameters: parameters) - } - - static - func get(realm trunk:String, - with parameters:Unidoc.PipelineParameters) -> Self - { - .explainable(Unidoc.RealmEndpoint.init(query: .init( - realm: trunk, - user: parameters.user)), - parameters: parameters) - } - - static - func get(stats trunk:String, - _ stem:ArraySlice, - with parameters:Unidoc.PipelineParameters) -> Self - { - .explainable(Unidoc.StatsEndpoint.init(query: .init( - volume: .init(trunk), - vertex: .init(path: stem, hash: parameters.hash))), - parameters: parameters) - } - - static - func get(tags trunk:String, with parameters:Unidoc.PipelineParameters) -> Self - { - let filter:Unidoc.VersionsQuery.Predicate - - if let page:Int = parameters.page - { - filter = .tags(limit: 20, - page: page, - series: parameters.beta ? .prerelease : .release) - } - else - { - filter = .none(limit: 12) - } - - return .explainable(Unidoc.TagsEndpoint.init(query: .init( - symbol: .init(trunk), - filter: filter, - as: parameters.user)), - parameters: parameters) - } - - static - func get(telescope trunk:String, with parameters:Unidoc.PipelineParameters) -> Self? - { - if let year:Timestamp.Year = .init(trunk), - let endpoint:Unidoc.PackagesCrawledEndpoint = .init(year: year) - { - .explainable(endpoint, parameters: parameters) - } - else if - let date:Timestamp.Date = .init(trunk), - let endpoint:Unidoc.PackagesCreatedEndpoint = .init(date: date) - { - .explainable(endpoint, parameters: parameters) - } - else - { - nil - } - } - - static - func get(user trunk:String, with parameters:Unidoc.PipelineParameters) -> Self? - { - guard - let account:Unidoc.Account = .init(trunk) - else - { - return nil - } - - return .explainable(Unidoc.UserPropertyEndpoint.init(query: .init( - account: account)), - parameters: parameters) - } - - static - func get( - legacy trunk:String, - _ stem:ArraySlice, - with parameters:Unidoc.LegacyParameters) -> Self - { - let query:Unidoc.RedirectQuery = .legacy(head: trunk, - rest: stem, - from: parameters.from) - - // Always pass empty parameters, as this endpoint always returns a redirect! - if let overload:Symbol.Decl = parameters.overload - { - return .explainable(Unidoc.RedirectEndpoint.init( - query: .init(volume: query.volume, lookup: overload)), - parameters: .none) - } - else - { - return .explainable(Unidoc.RedirectEndpoint.init( - query: query), - parameters: .none) - } - } -} - -// POST endpoints -extension Unidoc.IntegralRequest.Ordering -{ - static - func post(admin action:String, _ rest:ArraySlice, - body:consuming [UInt8], - type:ContentType) throws -> Self? - { - if let action:Unidoc.AdminPage.Action = .init(rawValue: action), - case .multipart(.form_data(boundary: let boundary?)) = type - { - let form:MultipartForm = try .init(splitting: body, on: boundary) - return .actor(Unidoc.SiteConfigOperation.perform(action, form)) - } - - switch action - { - case Unidoc.AdminPage.Recode.name: - if let target:String = rest.first, - let target:Unidoc.AdminPage.Recode.Target = .init(rawValue: target) - { - return .actor(Unidoc.SiteConfigOperation.recode(target)) - } - - case Unidoc.CookiePage.name: - return .actor(Unidoc.LoadDashboardOperation.cookie(scramble: true)) - - case _: - break - } - - return nil - } - - static - func post(api trunk:String, - body:consuming [UInt8], - type:ContentType, - user account:Unidoc.Account?) throws -> Self? - { - guard - let trunk:Unidoc.PostAction = .init(trunk) - else - { - return nil - } - - switch type - { - case .media(.application(.x_www_form_urlencoded, charset: _)): - let query:URI.Query = try .parse(parameters: body) - let form:[String: String] = query.parameters.reduce(into: [:]) - { - $0[$1.key] = $1.value - } - - switch trunk - { - case .build: - if let account:Unidoc.Account, - let build:Unidoc.PackageBuildOperation.Parameters = .init(from: form) - { - return .actor(Unidoc.PackageBuildOperation.init( - account: account, - build: build)) - } - - case .packageAlias: - if let package:String = form["package"], - let package:Unidoc.Package = .init(package), - let alias:String = form["alias"] - { - return .actor(Unidoc.PackageAliasOperation.init( - package: package, - alias: .init(alias))) - } - - case .packageAlign: - if let package:String = form["package"], - let package:Unidoc.Package = .init(package) - { - return .update(Unidoc.PackageAlignOperation.init( - package: package, - realm: form["realm"], - force: form["force"] == "true")) - } - - case .packageConfig: - if let package:String = form["package"], - let package:Unidoc.Package = .init(package), - let update:Unidoc.PackageConfigOperation.Update = .init(from: form) - { - let endpoint:Unidoc.PackageConfigOperation = .init( - account: account, - package: package, - update: update, - from: form["from"]) - - return .actor(endpoint) - } - - case .packageIndex: - if let account:Unidoc.Account, - let subject:Unidoc.PackageIndexOperation.Subject = .init(from: form) - { - return .actor(Unidoc.PackageIndexOperation.init( - account: account, - subject: subject)) - } - - case .telescope: - if let days:String = form["days"], - let days:Int = .init(days) - { - return .actor(Unidoc.SiteConfigOperation.telescope(days: days)) - } - - case .uplinkAll: - return .actor(Unidoc.LinkerOperation.init(queue: .all)) - - case .uplink: - if let package:String = form["package"], - let package:Unidoc.Package = .init(package), - let version:String = form["version"], - let version:Unidoc.Version = .init(version) - { - return .actor(Unidoc.LinkerOperation.init( - queue: .one(.init(package: package, version: version), - action: .uplinkRefresh), - from: form["from"])) - } - - case .unlink: - if let package:String = form["package"], - let package:Unidoc.Package = .init(package), - let version:String = form["version"], - let version:Unidoc.Version = .init(version) - { - return .actor(Unidoc.LinkerOperation.init( - queue: .one(.init(package: package, version: version), - action: .unlink), - from: form["from"])) - } - - case .delete: - if let package:String = form["package"], - let package:Unidoc.Package = .init(package), - let version:String = form["version"], - let version:Unidoc.Version = .init(version) - { - return .actor(Unidoc.LinkerOperation.init( - queue: .one(.init(package: package, version: version), - action: .delete), - from: form["from"])) - } - - case .userConfig: - if let account:Unidoc.Account, - let update:Unidoc.UserConfigOperation.Update = .init(from: form) - { - return .actor(Unidoc.UserConfigOperation.init( - account: account, - update: update)) - } - - case .userSyncPermissions: - return .actor(Unidoc.LoginOperation.init(flow: .sync)) - - default: - break - } - - return nil - - case .multipart(.form_data(boundary: let boundary?)): - let form:MultipartForm = try .init(splitting: body, on: boundary) - - switch trunk - { - case .robots_txt: - if let item:MultipartForm.Item = form.first( - where: { $0.header.name == "text" }) - { - return .actor(Unidoc.TextUpdateOperation.init(text: .init(id: .robots_txt, - text: .utf8(item.value)))) - } - - default: - break - } - - return nil - - default: - return nil - } - } - - static - func post(hook:String, - body:consuming [UInt8], - type:ContentType, - from origin:IP.Origin) -> Self? - { - switch hook - { - case "github": - // Did this request actually come from GitHub? (Anyone can POST over HTTP/2.) - // - // FIXME: there is a security hole during the (hopefully brief) interval between - // when the server restarts and the whitelists are initialized. - switch origin.owner - { - case .github: break - case .unknown: break - default: return nil - } - - do - { - return .actor(try Unidoc.PackageWebhookOperation.init(parsing: body)) - } - catch let error - { - return .syncError("Failed to parse webhook event: \(error)") - } - - default: - return nil - } - } - - static - func post(really trunk:String, - body:consuming [UInt8], - type:ContentType) throws -> Self? - { - guard - let trunk:Unidoc.PostAction = .init(trunk) - else - { - return nil - } - - guard - case .media(.application(.x_www_form_urlencoded, charset: _)) = type - else - { - return nil - } - - let action:URI = .init(path: Unidoc.Post[trunk].path, - query: try .parse(parameters: body)) - - let form:[String: String] = action.query?.parameters.reduce(into: [:]) - { - $0[$1.key] = $1.value - } ?? [:] - - let heading:String - let prompt:String - let button:String - - switch trunk - { - case .build: - guard - let build:Unidoc.PackageBuildOperation.Parameters = .init(from: form) - else - { - return nil - } - - return .syncResource(Unidoc.BuildRequestPage.init(selector: build.selector, - cancel: build.request == nil, - action: action)) - - case .unlink: - heading = "Unlink symbol graph?" - prompt = """ - Nobody will be able to read the documentation for this version of the package. \ - You can reverse this action by uplinking the symbol graph again. - """ - button = "Remove documentation" - - case .delete: - heading = "Delete symbol graph?" - prompt = """ - Nobody will be able to read the documentation for this version of the package. \ - This action is irreversible! - """ - button = "It is so ordered" - - case .packageConfig: - guard - let update:Unidoc.PackageConfigOperation.Update = .init(from: form) - else - { - return nil - } - - switch update - { - case .expires: - heading = "Refresh package tags?" - prompt = """ - This package will be added to a priority crawl queue. \ - Submitting this form multiple times will not improve its queue position. - """ - button = "Refresh tags" - - case .hidden(true): - heading = "Hide package?" - prompt = """ - The package will no longer appear in search, or in the activity feed. \ - This will not affect the package’s documentation. - """ - button = "Hide package" - - case .hidden(false): - heading = "Unhide package?" - prompt = """ - The package will appear in search, and in the activity feed. - """ - button = "Unhide package" - - case .symbol: - heading = "Rename package?" - prompt = """ - This will not affect documentation that has already been generated. - """ - button = "Rename package" - - case .reset: - // These don’t need a confirmation page. - return nil - } - - case .userConfig: - guard - let update:Unidoc.UserConfigOperation.Update = .init(from: form) - else - { - return nil - } - switch update - { - case .generateKey: - heading = "Generate API key?" - prompt = """ - This will invalidate any previously-generated API keys. \ - You cannot undo this action! - """ - button = "Generate key" - } - - default: - return nil - } - - let really:Unidoc.ReallyPage = .init(title: heading, - prompt: prompt, - button: button, - action: action) - - return .syncResource(really) + : .actor(Unidoc.LoadOptimizedOperation.init(base: endpoint, etag: etag)) } } diff --git a/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.swift b/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.swift index 475ae5dda..38223b96a 100644 --- a/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.swift +++ b/Sources/UnidocServer/Requests/Unidoc.IntegralRequest.swift @@ -23,192 +23,11 @@ extension Unidoc extension Unidoc.IntegralRequest { public - init?(get metadata:Metadata, tag:MD5?) + init?(get metadata:Metadata) { - guard - let uri:URI = .init(metadata.path) - else - { - return nil - } - - var path:ArraySlice = uri.path.normalized(lowercase: true)[...] - - guard - let root:String = path.popFirst() - else - { - let parameters:Unidoc.PipelineParameters = .init(uri.query?.parameters) - - self.init( - metadata: metadata, - ordering: .explainable(Unidoc.HomeEndpoint.init(query: .init(limit: 16)), - parameters: parameters)) - - return - } + var router:Unidoc.Router = .init(metadata) - guard - let trunk:String = path.popFirst() - else - { - let ordering:Ordering - - switch root - { - case Unidoc.ServerRoot.account.id: - guard - let user:Unidoc.UserSession = metadata.cookies.session - else - { - ordering = .syncRedirect(.temporary("\(Unidoc.ServerRoot.login)")) - break - } - - ordering = .explainable(Unidoc.UserSettingsEndpoint.init( - query: .init(session: user)), - parameters: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.admin.id: - ordering = .actor(Unidoc.LoadDashboardOperation.master) - - case Unidoc.ServerRoot.login.id: - ordering = .actor(Unidoc.LoginOperation.init(flow: .sso)) - - case "robots.txt": - let parameters:Unidoc.PipelineParameters = .init(uri.query?.parameters, - tag: tag) - - ordering = .explainable(Unidoc.TextEndpoint.init(query: .init( - tag: parameters.tag, - id: .robots_txt)), - parameters: parameters) - - case "sitemap.xml": - ordering = .actor(Unidoc.LoadSitemapIndexOperation.init(tag: tag)) - - case Unidoc.ServerRoot.ssgc.id: - guard - let query:URI.Query = uri.query, - let build:Unidoc.BuildLabelsPrompt = .init(query: query) - else - { - return nil - } - - ordering = .actor(Unidoc.BuilderLabelOperation.init(prompt: build)) - - case _: - return nil - } - - self.init(metadata: metadata, ordering: ordering) - return - } - - let ordering:Ordering? - - switch root - { - case Unidoc.ServerRoot.admin.id: - ordering = .get(admin: trunk, path, tag: tag) - - case Unidoc.ServerRoot.asset.id: - ordering = .get(asset: trunk, tag: tag) - - case Unidoc.ServerRoot.auth.id: - ordering = .get(auth: trunk, - with: .init(uri.query?.parameters)) - - case Unidoc.ServerRoot.blog.id: - ordering = .get(blog: "Articles", trunk, - with: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.docs.id, Unidoc.ServerRoot.docc.id, Unidoc.ServerRoot.hist.id: - ordering = .get(docs: trunk, path, - with: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.help.id: - ordering = .get(blog: "Help", trunk, - with: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.lunr.id: - ordering = .get(lunr: trunk, - with: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.plugin.id: - ordering = .actor(Unidoc.LoadDashboardOperation.plugin(trunk)) - - case Unidoc.ServerRoot.ptcl.id: - ordering = .get(ptcl: trunk, path, - with: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.realm.id: - ordering = .get(realm: trunk, - with: .init(uri.query?.parameters, tag: tag)) - - case "render": - guard metadata.hostSupportsPublicAPI - else - { - ordering = .syncRedirect(.permanent( - external: "https://api.swiftinit.org/render")) - break - } - - ordering = .actor(Unidoc.UserRenderOperation.init(volume: .init(trunk), - shoot: .init(path: path), - query: uri.query?.parameters)) - - // Deprecated route. - case "sitemaps": - ordering = .syncRedirect(.permanent(""" - \(Unidoc.ServerRoot.docs)/\(trunk.prefix { $0 != "." })/all-symbols - """)) - - case Unidoc.ServerRoot.ssgc.id: - guard trunk == "poll", - let user:Unidoc.UserSession = metadata.cookies.session - else - { - return nil - } - - ordering = .actor(Unidoc.BuilderPollOperation.init(id: user.account)) - - case Unidoc.ServerRoot.stats.id: - ordering = .get(stats: trunk, path, - with: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.tags.id: - ordering = .get(tags: trunk, - with: .init(uri.query?.parameters, - // OK to do this, if someone forges a cookie, they can see the admin - // controls, but they can't do anything with them. - user: metadata.cookies.session?.account, - tag: tag)) - - case Unidoc.ServerRoot.telescope.id: - ordering = .get(telescope: trunk, - with: .init(uri.query?.parameters, tag: tag)) - - case Unidoc.ServerRoot.user.id: - ordering = .get(user: trunk, - with: .init(uri.query?.parameters, tag: tag)) - - case "reference": - ordering = .get(legacy: trunk, path, - with: .init(uri.query?.parameters)) - - case "learn": - ordering = .get(legacy: trunk, path, - with: .init(uri.query?.parameters)) - - case _: - return nil - } - - if let ordering:Ordering + if let ordering:Unidoc.IntegralRequest.Ordering = router.get() { self.init(metadata: metadata, ordering: ordering) } @@ -219,61 +38,11 @@ extension Unidoc.IntegralRequest } public - init?(post metadata:Metadata, body:consuming [UInt8], type:ContentType) + init?(post metadata:Metadata, body:borrowing [UInt8]) { - guard - let uri:URI = .init(metadata.path) - else - { - return nil - } - - var path:ArraySlice = uri.path.normalized(lowercase: true)[...] - - guard - let root:String = path.popFirst() - else - { - return nil - } - - let ordering:Unidoc.IntegralRequest.Ordering? - - if let trunk:String = path.popFirst() - { - switch root - { - case Unidoc.ServerRoot.admin.id: - ordering = try? .post(admin: trunk, path, body: body, type: type) - - case Unidoc.ServerRoot.api.id: - ordering = try? .post(api: trunk, - body: body, - type: type, - user: metadata.cookies.session?.account) - - case Unidoc.ServerRoot.hook.id: - ordering = .post(hook: trunk, body: body, type: type, from: metadata.origin) - - case Unidoc.ServerRoot.really.id: - ordering = try? .post(really: trunk, body: body, type: type) - - case _: - return nil - } - } - else if Unidoc.ServerRoot.login.id == root, - let query:URI.Query = try? .parse(parameters: body), - let path:String = query.parameters.first?.value - { - ordering = .actor(Unidoc.LoginOperation.init(flow: .sso, from: path)) - } - else - { - return nil - } + var router:Unidoc.Router = .init(metadata) - if let ordering:Ordering + if let ordering:Unidoc.IntegralRequest.Ordering = router.post(body: body) { self.init(metadata: metadata, ordering: ordering) } diff --git a/Sources/UnidocServer/Requests/Unidoc.PipelineParameters.swift b/Sources/UnidocServer/Requests/Unidoc.PipelineParameters.swift index d5fc3229d..ac48fca8f 100644 --- a/Sources/UnidocServer/Requests/Unidoc.PipelineParameters.swift +++ b/Sources/UnidocServer/Requests/Unidoc.PipelineParameters.swift @@ -1,6 +1,5 @@ import FNV1 -import MD5 -import UnidocRecords +import URI extension Unidoc { @@ -11,38 +10,23 @@ extension Unidoc var hash:FNV24? var page:Int? - let user:Unidoc.Account? - let tag:MD5? - private - init(user:Unidoc.Account?, tag:MD5?) + init() { self.explain = false self.beta = false self.hash = nil self.page = nil - - self.user = user - self.tag = tag } } } extension Unidoc.PipelineParameters { - init(_ parameters:[(key:String, value:String)]?, - user:Unidoc.Account? = nil, - tag:MD5? = nil) + init(_ query:URI.Query) { - self.init(user: user, tag: tag) - - guard - let parameters:[(key:String, value:String)] - else - { - return - } + self.init() - for (key, value):(String, String) in parameters + for (key, value):(String, String) in query.parameters { switch key { @@ -63,5 +47,5 @@ extension Unidoc.PipelineParameters } static - var none:Self { .init(user: nil, tag: nil) } + var none:Self { .init() } } diff --git a/Sources/UnidocServer/Requests/Unidoc.ReallyPage.swift b/Sources/UnidocServer/Requests/Unidoc.ReallyPage.swift index 1dae0b462..8a7a31d63 100644 --- a/Sources/UnidocServer/Requests/Unidoc.ReallyPage.swift +++ b/Sources/UnidocServer/Requests/Unidoc.ReallyPage.swift @@ -25,6 +25,87 @@ extension Unidoc } } } +extension Unidoc.ReallyPage +{ + static + func unlink(_ action:URI) -> Self + { + .init(title: "Unlink symbol graph?", + prompt: """ + Nobody will be able to read the documentation for this version of the package. \ + You can reverse this action by uplinking the symbol graph again. + """, + button: "Remove documentation", + action: action) + } + + static + func delete(_ action:URI) -> Self + { + .init(title: "Delete symbol graph?", + prompt: """ + Nobody will be able to read the documentation for this version of the package. \ + This action is irreversible! + """, + button: "It is so ordered", + action: action) + } + + static + func packageConfig(_ action:URI, update:Unidoc.PackageConfigOperation.Update) -> Self? + { + switch update + { + case .expires: + return .init(title: "Refresh package tags?", + prompt: """ + This package will be added to a priority crawl queue. \ + Submitting this form multiple times will not improve its queue position. + """, + button: "Refresh tags", + action: action) + + case .hidden(true): + return .init(title: "Hide package?", + prompt: """ + The package will no longer appear in search, or in the activity feed. \ + This will not affect the package’s documentation. + """, + button: "Hide package", + action: action) + + case .hidden(false): + return nil + + case .symbol: + return .init(title: "Rename package?", + prompt: """ + This will not affect documentation that has already been generated. + """, + button: "Rename package", + action: action) + + case .reset: + return nil + } + } + + static + func userConfig(_ action:URI, update:Unidoc.UserConfigOperation.Update) -> Self + { + switch update + { + case .generateKey: + return .init(title: "Generate API key?", + prompt: """ + This will invalidate any previously-generated API keys. \ + You cannot undo this action! + """, + button: "Generate key", + action: action) + } + } +} extension Unidoc.ReallyPage:Unidoc.ConfirmationPage { func form(_ form:inout HTML.ContentEncoder, format:Unidoc.RenderFormat) diff --git a/Sources/UnidocServer/Requests/Unidoc.AuthParameters.swift b/Sources/UnidocServer/Requests/Unidoc.Router.AuthParameters.swift similarity index 75% rename from Sources/UnidocServer/Requests/Unidoc.AuthParameters.swift rename to Sources/UnidocServer/Requests/Unidoc.Router.AuthParameters.swift index 183aae12a..e24053dfc 100644 --- a/Sources/UnidocServer/Requests/Unidoc.AuthParameters.swift +++ b/Sources/UnidocServer/Requests/Unidoc.Router.AuthParameters.swift @@ -1,4 +1,6 @@ -extension Unidoc +import URI + +extension Unidoc.Router { struct AuthParameters { @@ -11,7 +13,7 @@ extension Unidoc /// Defined by us and parroted back by GitHub. var from:String? - var flow:LoginFlow? + var flow:Unidoc.LoginFlow? private init() @@ -24,20 +26,13 @@ extension Unidoc } } } -extension Unidoc.AuthParameters +extension Unidoc.Router.AuthParameters { - init(_ parameters:[(key:String, value:String)]?) + init(_ query:__shared URI.Query) { self.init() - guard - let parameters:[(key:String, value:String)] - else - { - return - } - - for (key, value):(String, String) in parameters + for (key, value):(String, String) in query.parameters { switch key { diff --git a/Sources/UnidocServer/Requests/Unidoc.LegacyParameters.swift b/Sources/UnidocServer/Requests/Unidoc.Router.LegacyParameters.swift similarity index 64% rename from Sources/UnidocServer/Requests/Unidoc.LegacyParameters.swift rename to Sources/UnidocServer/Requests/Unidoc.Router.LegacyParameters.swift index 751a00dda..e9f5601bd 100644 --- a/Sources/UnidocServer/Requests/Unidoc.LegacyParameters.swift +++ b/Sources/UnidocServer/Requests/Unidoc.Router.LegacyParameters.swift @@ -1,6 +1,7 @@ import Symbols +import URI -extension Unidoc +extension Unidoc.Router { struct LegacyParameters { @@ -15,20 +16,13 @@ extension Unidoc } } } -extension Unidoc.LegacyParameters +extension Unidoc.Router.LegacyParameters { - init(_ parameters:[(key:String, value:String)]?) + init(_ query:__shared URI.Query) { self.init() - guard - let parameters:[(key:String, value:String)] - else - { - return - } - - for (key, value):(String, String) in parameters + for (key, value):(String, String) in query.parameters { switch key { diff --git a/Sources/UnidocServer/Requests/Unidoc.Router.swift b/Sources/UnidocServer/Requests/Unidoc.Router.swift new file mode 100644 index 000000000..63c0e2b17 --- /dev/null +++ b/Sources/UnidocServer/Requests/Unidoc.Router.swift @@ -0,0 +1,1024 @@ +import GitHubAPI +import HTTP +import IP +import JSON +import MD5 +import Media +import Multiparts +import Symbols +import URI +import UnixTime + +extension Unidoc +{ + struct Router + { + let headers:HTTP.Headers + let session:Unidoc.UserSession? + let origin:IP.Origin + let host:String? + + private + var stem:ArraySlice + private + let query:URI.Query + + private + init( + headers:HTTP.Headers, + session:Unidoc.UserSession?, + origin:IP.Origin, + host:String?, + stem:ArraySlice, + query:URI.Query) + { + self.headers = headers + self.session = session + self.origin = origin + self.host = host + self.stem = stem + self.query = query + } + } +} +extension Unidoc.Router +{ + init(_ metadata:Unidoc.IntegralRequest.Metadata) + { + self.init( + headers: metadata.headers, + session: metadata.cookies.session, + origin: metadata.origin, + host: metadata.host, + stem: metadata.path, + query: metadata.uri.query ?? [:]) + } +} +extension Unidoc.Router +{ + private + var contentType:ContentType? + { + let contentType:String? + + switch self.headers + { + case .http1_1(let headers): contentType = headers["content-type"].first + case .http2(let headers): contentType = headers["content-type"].first + } + + guard + let contentType:String, + let contentType:ContentType = .init(contentType) + else + { + return nil + } + + return contentType + } + + private + var etag:MD5? + { + switch self.headers + { + case .http1_1(let headers): .init(header: headers["if-none-match"]) + case .http2(let headers): .init(header: headers["if-none-match"]) + } + } + + private + var hostSupportsPublicAPI:Bool + { + switch self.host + { + case "api.swiftinit.org"?: true + case "localhost"?: true + default: false + } + } +} +extension Unidoc.Router +{ + mutating + func descend() -> String? + { + self.stem.popFirst() + } + + mutating + func descend(into:Component.Type = Component.self) -> Component? + where Component:RawRepresentable + { + guard + let next:String = self.descend() + else + { + return nil + } + + return .init(rawValue: next) + } + + mutating + func descend( + into:Unidoc.VolumeSelector.Type = Unidoc.VolumeSelector.self) -> Unidoc.VolumeSelector? + { + guard + let next:String = self.descend() + else + { + return nil + } + + return .init(next) + } + + mutating + func descend( + into:Symbol.Package.Type = Symbol.Package.self) -> Symbol.Package? + { + guard + let next:String = self.descend() + else + { + return nil + } + + return .init(next) + } +} +extension Unidoc.Router +{ + mutating + func get() -> Unidoc.IntegralRequest.Ordering? + { + guard let root:Unidoc.ServerRoot = self.descend() + else + { + return .explainable(Unidoc.HomeEndpoint.init(query: .init(limit: 16)), + parameters: .init(self.query), + etag: self.etag) + } + + switch root + { + case .account: return self.account() + case .admin: return self.admin() + case .api: return nil // POST only + case .asset: return self.asset() + case .auth: return self.auth() + case .blog: return self.blog(module: "Articles") + case .docc: return self.docs() + case .docs: return self.docs() + case .guides: return self.docsLegacy() + case .help: return self.blog(module: "Help") + case .hist: return self.docs() + case .hook: return nil // POST only + case .login: return self.login() + case .lunr: return self.lunr() + case .plugin: return self.plugin() + case .pdct: return nil // Unimplemented. + case .ptcl: return self.ptcl() + case .really: return nil // POST only + case .realm: return self.realm() + case .reference: return self.docsLegacy() + case .robots_txt: return self.robots() + case .sitemap_xml: return self.sitemap() + case .sitemaps: return self.sitemaps() + case .ssgc: return self.ssgc() + case .stats: return self.stats() + case .tags: return self.tags() + case .telescope: return self.telescope() + case .user: return self.user() + } + } + + mutating + func post(body:[UInt8]) -> Unidoc.IntegralRequest.Ordering? + { + switch self.contentType + { + case .media(.application(.json, charset: _))?: + return self.post(json: .init(utf8: body[...])) + + case .media(.application(.x_www_form_urlencoded, charset: _))?: + guard + let form:URI.Query = try? .parse(parameters: body) + else + { + return .syncError("Cannot parse URL-encoded form data\n") + } + + return self.post(form: form) + + case .multipart(.form_data(boundary: let boundary?))?: + guard + let form:MultipartForm = try? .init(splitting: body, on: boundary) + else + { + return .syncError("Cannot parse multipart form data\n") + } + + return self.post(form: form) + + case let other?: + return .syncError("Cannot POST content type '\(other)'\n") + + default: + return .syncError("Cannot POST without a content type\n") + } + } + + private mutating + func post(json:JSON) -> Unidoc.IntegralRequest.Ordering? + { + switch self.descend(into: Unidoc.ServerRoot.self) + { + case .hook?: return self.hook(json: json) + default: return nil + } + } + + private mutating + func post(form:URI.Query) -> Unidoc.IntegralRequest.Ordering? + { + switch self.descend(into: Unidoc.ServerRoot.self) + { + case .admin?: return self.admin(form: form) + case .api?: return self.api(form: form) + case .login?: return self.login(form: form) + case .really?: return self.really(form: form) + default: return nil + } + } + private mutating + func post(form:MultipartForm) -> Unidoc.IntegralRequest.Ordering? + { + switch self.descend(into: Unidoc.ServerRoot.self) + { + case .admin?: return self.admin(form: form) + case .api?: return self.api(form: form) + default: return nil + } + } +} +extension Unidoc.Router +{ + private + func account() -> Unidoc.IntegralRequest.Ordering + { + guard + let user:Unidoc.UserSession = self.session + else + { + return .syncRedirect(.temporary("\(Unidoc.ServerRoot.login)")) + } + + return .explainable(Unidoc.UserSettingsEndpoint.init( + query: .init(session: user)), + parameters: .init(self.query), + etag: self.etag) + } +} +extension Unidoc.Router +{ + private mutating + func admin() -> Unidoc.IntegralRequest.Ordering? + { + guard let next:String = self.descend() + else + { + return .actor(Unidoc.LoadDashboardOperation.master) + } + + switch next + { + case Unidoc.AdminPage.Recode.name: + if let target:Unidoc.AdminPage.Recode.Target = self.descend() + { + return .syncResource(target) + } + else + { + return .syncResource(Unidoc.AdminPage.Recode.init()) + } + + case Unidoc.ReplicaSetPage.name: + return .actor(Unidoc.LoadDashboardOperation.replicaSet) + + case Unidoc.CookiePage.name: + return .actor(Unidoc.LoadDashboardOperation.cookie(scramble: false)) + + case "robots": + return .actor(Unidoc.TextEditorOperation.init(id: .robots_txt)) + + default: + return nil + } + } + // These are kind of a mess right now. + private mutating + func admin(form:URI.Query) -> Unidoc.IntegralRequest.Ordering? + { + guard case Unidoc.CookiePage.name? = self.descend() as String? + else + { + return nil + } + + return .actor(Unidoc.LoadDashboardOperation.cookie(scramble: true)) + } + private mutating + func admin(form:MultipartForm) -> Unidoc.IntegralRequest.Ordering? + { + guard let action:String = self.descend() + else + { + return nil + } + + if action == Unidoc.AdminPage.Recode.name, + let target:Unidoc.AdminPage.Recode.Target = self.descend() + { + return .actor(Unidoc.SiteConfigOperation.recode(target)) + } + else + { + return nil + } + } +} +extension Unidoc.Router +{ + private mutating + func api(form:URI.Query) -> Unidoc.IntegralRequest.Ordering? + { + guard + let action:Unidoc.PostAction = self.descend() + else + { + return nil + } + + let form:[String: String] = form.parameters.reduce(into: [:]) + { + $0[$1.key] = $1.value + } + + switch action + { + case .build: + if let account:Unidoc.Account = self.session?.account, + let build:Unidoc.PackageBuildOperation.Parameters = .init(from: form) + { + return .actor(Unidoc.PackageBuildOperation.init( + account: account, + build: build)) + } + + case .packageAlias: + if let package:String = form["package"], + let package:Unidoc.Package = .init(package), + let alias:String = form["alias"] + { + return .actor(Unidoc.PackageAliasOperation.init( + package: package, + alias: .init(alias))) + } + + case .packageAlign: + if let package:String = form["package"], + let package:Unidoc.Package = .init(package) + { + return .update(Unidoc.PackageAlignOperation.init( + package: package, + realm: form["realm"], + force: form["force"] == "true")) + } + + case .packageConfig: + if let package:String = form["package"], + let package:Unidoc.Package = .init(package), + let update:Unidoc.PackageConfigOperation.Update = .init(from: form) + { + let endpoint:Unidoc.PackageConfigOperation = .init( + account: self.session?.account, + package: package, + update: update, + from: form["from"]) + + return .actor(endpoint) + } + + case .packageIndex: + if let account:Unidoc.Account = self.session?.account, + let subject:Unidoc.PackageIndexOperation.Subject = .init(from: form) + { + return .actor(Unidoc.PackageIndexOperation.init( + account: account, + subject: subject)) + } + + case .telescope: + if let days:String = form["days"], + let days:Int = .init(days) + { + return .actor(Unidoc.SiteConfigOperation.telescope(days: days)) + } + + case .uplinkAll: + return .actor(Unidoc.LinkerOperation.init(queue: .all)) + + case .uplink: + if let package:String = form["package"], + let package:Unidoc.Package = .init(package), + let version:String = form["version"], + let version:Unidoc.Version = .init(version) + { + return .actor(Unidoc.LinkerOperation.init( + queue: .one(.init(package: package, version: version), + action: .uplinkRefresh), + from: form["from"])) + } + + case .unlink: + if let package:String = form["package"], + let package:Unidoc.Package = .init(package), + let version:String = form["version"], + let version:Unidoc.Version = .init(version) + { + return .actor(Unidoc.LinkerOperation.init( + queue: .one(.init(package: package, version: version), + action: .unlink), + from: form["from"])) + } + + case .delete: + if let package:String = form["package"], + let package:Unidoc.Package = .init(package), + let version:String = form["version"], + let version:Unidoc.Version = .init(version) + { + return .actor(Unidoc.LinkerOperation.init( + queue: .one(.init(package: package, version: version), + action: .delete), + from: form["from"])) + } + + case .userConfig: + if let account:Unidoc.Account = self.session?.account, + let update:Unidoc.UserConfigOperation.Update = .init(from: form) + { + return .actor(Unidoc.UserConfigOperation.init( + account: account, + update: update)) + } + + case .userSyncPermissions: + return .actor(Unidoc.LoginOperation.init(flow: .sync)) + + default: + break + } + + return nil + } + private mutating + func api(form:MultipartForm) -> Unidoc.IntegralRequest.Ordering? + { + guard + let action:Unidoc.PostAction = self.descend() + else + { + return nil + } + + switch action + { + case .robots_txt: + guard + let item:MultipartForm.Item = form.first(where: { $0.header.name == "text" }) + else + { + return .syncError("Cannot parse form data: missing field 'text'\n") + } + + return .actor(Unidoc.TextUpdateOperation.init(text: .init(id: .robots_txt, + text: .utf8(item.value)))) + + default: + return nil + } + } +} +extension Unidoc.Router +{ + private mutating + func asset() -> Unidoc.IntegralRequest.Ordering? + { + guard + let asset:Unidoc.Asset = self.descend() + else + { + return nil + } + + return .syncLoad(.init(asset, tag: self.etag)) + } +} +extension Unidoc.Router +{ + private mutating + func auth() -> Unidoc.IntegralRequest.Ordering? + { + let parameters:AuthParameters = .init(self.query) + + switch self.descend() + { + case "github"?: + if let state:String = parameters.state, + let code:String = parameters.code, + let from:String = parameters.from, + let flow:Unidoc.LoginFlow = parameters.flow + { + return .actor(Unidoc.AuthOperation.init(state: state, + code: code, + flow: flow, + from: from)) + } + + case "register"?: + if let token:String = parameters.token + { + return .actor(Unidoc.UserIndexOperation.init(token: token, flow: .sso)) + } + + case _: + break + } + + return nil + } +} +extension Unidoc.Router +{ + private mutating + func blog(module:String) -> Unidoc.IntegralRequest.Ordering? + { + guard let article:String = self.descend() + else + { + return nil + } + + return .explainable(Unidoc.BlogEndpoint.init(query: .init( + volume: .init(package: "__swiftinit", version: "__max"), + vertex: .init(path: [module, article], hash: nil))), + parameters: .init(self.query), + etag: self.etag) + } + + private mutating + func docs() -> Unidoc.IntegralRequest.Ordering? + { + guard + let volume:Unidoc.VolumeSelector = self.descend().map(Unidoc.VolumeSelector.init) + else + { + return nil + } + + let parameters:Unidoc.PipelineParameters = .init(self.query) + + // Special sitemap route. + // The '-' in the name means it will never collide with a decl. + if case nil = volume.version, + case ["all-symbols"] = self.stem + { + return .explainable(Unidoc.SitemapEndpoint.init(query: .init( + package: volume.package)), + parameters: parameters, + etag: self.etag) + } + else + { + let shoot:Unidoc.Shoot = .init(path: self.stem, hash: parameters.hash) + return .explainable(Unidoc.DocsEndpoint.init(query: .init( + volume: volume, + vertex: shoot)), + parameters: parameters, + etag: self.etag) + } + } +} +extension Unidoc.Router +{ + private mutating + func hook(json:JSON) -> Unidoc.IntegralRequest.Ordering? + { + switch self.descend() + { + case "github"?: + do + { + return .actor(try Unidoc.PackageWebhookOperation.init(json: json, + from: self.origin, + with: self.headers)) + } + catch let error + { + return .syncError("Rejected webhook event: \(error)") + } + + default: + return nil + } + } +} +extension Unidoc.Router +{ + private + func login() -> Unidoc.IntegralRequest.Ordering + { + .actor(Unidoc.LoginOperation.init(flow: .sso)) + } + private + func login(form:URI.Query) -> Unidoc.IntegralRequest.Ordering + { + if let path:String = form.parameters.first?.value, + let path:URI = .init(path) + { + return .actor(Unidoc.LoginOperation.init(flow: .sso, from: path)) + } + else + { + return .syncError("Cannot parse login form data: missing return path\n") + } + } +} +extension Unidoc.Router +{ + private mutating + func lunr() -> Unidoc.IntegralRequest.Ordering? + { + guard let next:String = self.descend() + else + { + return nil + } + + let etag:MD5? = self.etag + + if let id:Symbol.Edition = .init(next) + { + return .explainable(Unidoc.LunrEndpoint.init(query: .init(tag: etag, id: id)), + parameters: .init(self.query), + etag: etag) + } + else if next == "packages.json" + { + return .explainable(Unidoc.TextEndpoint.init(query: .init(tag: etag, + id: .packages_json)), + parameters: .init(self.query), + etag: etag) + } + else + { + return nil + } + } +} +extension Unidoc.Router +{ + private mutating + func plugin() -> Unidoc.IntegralRequest.Ordering? + { + guard let next:String = self.descend() + else + { + return nil + } + + return .actor(Unidoc.LoadDashboardOperation.plugin(next)) + } + + private mutating + func ptcl() -> Unidoc.IntegralRequest.Ordering? + { + guard + let volume:Unidoc.VolumeSelector = self.descend() + else + { + return nil + } + + let parameters:Unidoc.PipelineParameters = .init(self.query) + + return .explainable(Unidoc.PtclEndpoint.init(query: .init( + volume: volume, + vertex: .init(path: self.stem, hash: parameters.hash), + layer: .protocols)), + parameters: parameters, + etag: self.etag) + } + + private mutating + func realm() -> Unidoc.IntegralRequest.Ordering? + { + guard + let realm:String = self.descend() + else + { + return nil + } + + return .explainable(Unidoc.RealmEndpoint.init(query: .init(realm: realm, + user: self.session?.account)), + parameters: .init(self.query), + etag: self.etag) + } + + private mutating + func render() -> Unidoc.IntegralRequest.Ordering? + { + guard self.hostSupportsPublicAPI + else + { + return .syncRedirect(.permanent(external: "https://api.swiftinit.org/render")) + } + + guard + let volume:Unidoc.VolumeSelector = self.descend() + else + { + return nil + } + + return .actor(Unidoc.UserRenderOperation.init(volume: volume, + shoot: .init(path: self.stem), + query: self.query)) + } +} +extension Unidoc.Router +{ + private mutating + func really(form:URI.Query) -> Unidoc.IntegralRequest.Ordering? + { + guard + let confirm:Unidoc.PostAction = self.descend() + else + { + return nil + } + + let action:URI = .init(path: Unidoc.Post[confirm].path, query: form) + var table:[String: String] + { + form.parameters.reduce(into: [:]) { $0[$1.key] = $1.value } + } + + let really:Unidoc.ReallyPage? + + switch confirm + { + case .build: + guard + let build:Unidoc.PackageBuildOperation.Parameters = .init(from: table) + else + { + return nil + } + + return .syncResource(Unidoc.BuildRequestPage.init(selector: build.selector, + cancel: build.request == nil, + action: action)) + + case .unlink: + really = .unlink(action) + + case .delete: + really = .delete(action) + + case .packageConfig: + guard + let update:Unidoc.PackageConfigOperation.Update = .init(from: table) + else + { + return nil + } + + really = .packageConfig(action, update: update) + + case .userConfig: + guard + let update:Unidoc.UserConfigOperation.Update = .init(from: table) + else + { + return nil + } + + really = .userConfig(action, update: update) + + default: + return nil + } + + guard + let really:Unidoc.ReallyPage = really + else + { + return nil + } + + return .syncResource(really) + } +} +extension Unidoc.Router +{ + private + func robots() -> Unidoc.IntegralRequest.Ordering + { + let etag:MD5? = self.etag + return .explainable(Unidoc.TextEndpoint.init(query: .init( + tag: etag, + id: .robots_txt)), + parameters: .init(self.query), + etag: etag) + } + + private + func sitemap() -> Unidoc.IntegralRequest.Ordering + { + .actor(Unidoc.LoadSitemapIndexOperation.init(tag: self.etag)) + } + + /// Deprecated route. + private mutating + func sitemaps() -> Unidoc.IntegralRequest.Ordering? + { + guard let next:String = self.descend() + else + { + return nil + } + + return .syncRedirect(.permanent(""" + \(Unidoc.ServerRoot.docs)/\(next.prefix { $0 != "." })/all-symbols + """)) + } + + private mutating + func ssgc() -> Unidoc.IntegralRequest.Ordering? + { + switch self.descend() + { + case nil: + guard let build:Unidoc.BuildLabelsPrompt = .init(query: self.query) + else + { + return nil + } + + return .actor(Unidoc.BuilderLabelOperation.init(prompt: build)) + + case "poll"?: + guard let user:Unidoc.UserSession = self.session + else + { + return nil + } + + return .actor(Unidoc.BuilderPollOperation.init(id: user.account)) + + default: + return nil + } + } + + private mutating + func stats() -> Unidoc.IntegralRequest.Ordering? + { + guard let volume:Unidoc.VolumeSelector = self.descend() + else + { + return nil + } + + let parameters:Unidoc.PipelineParameters = .init(self.query) + + return .explainable(Unidoc.StatsEndpoint.init(query: .init( + volume: volume, + vertex: .init(path: self.stem, hash: parameters.hash))), + parameters: parameters, + etag: self.etag) + } + + private mutating + func tags() -> Unidoc.IntegralRequest.Ordering? + { + guard let symbol:Symbol.Package = self.descend() + else + { + return nil + } + + let parameters:Unidoc.PipelineParameters = .init(self.query) + + let filter:Unidoc.VersionsQuery.Predicate + + if let page:Int = parameters.page + { + filter = .tags(limit: 20, + page: page, + series: parameters.beta ? .prerelease : .release) + } + else + { + filter = .none(limit: 12) + } + + return .explainable(Unidoc.TagsEndpoint.init(query: .init( + symbol: symbol, + filter: filter, + as: self.session?.account)), + parameters: parameters, + etag: self.etag) + } + + private mutating + func telescope() -> Unidoc.IntegralRequest.Ordering? + { + guard let next:String = self.descend() + else + { + return nil + } + + if let year:Timestamp.Year = .init(next), + let endpoint:Unidoc.PackagesCrawledEndpoint = .init(year: year) + { + return .explainable(endpoint, parameters: .init(self.query), etag: self.etag) + } + else if + let date:Timestamp.Date = .init(next), + let endpoint:Unidoc.PackagesCreatedEndpoint = .init(date: date) + { + return .explainable(endpoint, parameters: .init(self.query), etag: self.etag) + } + else + { + return nil + } + } + + private mutating + func user() -> Unidoc.IntegralRequest.Ordering? + { + guard + let account:String = self.descend(), + let account:Unidoc.Account = .init(account) + else + { + return nil + } + + return .explainable(Unidoc.UserPropertyEndpoint.init(query: .init( + account: account)), + parameters: .init(self.query), + etag: self.etag) + } + + private mutating + func docsLegacy() -> Unidoc.IntegralRequest.Ordering? + { + guard let next:String = self.descend() + else + { + return nil + } + + let parameters:LegacyParameters = .init(self.query) + + let query:Unidoc.RedirectQuery = .legacy(head: next, + rest: self.stem, + from: parameters.from) + + // Always pass empty parameters, as this endpoint always returns a redirect! + if let overload:Symbol.Decl = parameters.overload + { + return .explainable(Unidoc.RedirectEndpoint.init( + query: .init(volume: query.volume, lookup: overload)), + parameters: .none, + etag: self.etag) + } + else + { + return .explainable(Unidoc.RedirectEndpoint.init( + query: query), + parameters: .none, + etag: self.etag) + } + } +} diff --git a/Sources/UnidocServer/Requests/Unidoc.StreamedRequest.swift b/Sources/UnidocServer/Requests/Unidoc.StreamedRequest.swift index a5500ec95..877fa4b52 100644 --- a/Sources/UnidocServer/Requests/Unidoc.StreamedRequest.swift +++ b/Sources/UnidocServer/Requests/Unidoc.StreamedRequest.swift @@ -23,18 +23,19 @@ extension Unidoc extension Unidoc.StreamedRequest { public - init?(put path:String, headers:HPACKHeaders) + init?(put uri:URI, headers:HPACKHeaders) { - guard let uri:URI = .init(path) + var path:ArraySlice = uri.path.normalized(lowercase: true)[...] + + guard + let root:String = path.popFirst(), + let root:Unidoc.ServerRoot = .init(rawValue: root) else { return nil } - var path:ArraySlice = uri.path.normalized(lowercase: true)[...] - - guard - case Unidoc.ServerRoot.ssgc.id? = path.popFirst(), + guard case .ssgc = root, let route:String = path.popFirst(), let route:Unidoc.BuildRoute = .init(route) else diff --git a/Sources/UnidocServer/Server/Unidoc.ServerLoop.swift b/Sources/UnidocServer/Server/Unidoc.ServerLoop.swift index b41be9ab3..9a3ecda2b 100644 --- a/Sources/UnidocServer/Server/Unidoc.ServerLoop.swift +++ b/Sources/UnidocServer/Server/Unidoc.ServerLoop.swift @@ -23,7 +23,6 @@ extension Unidoc let updateQueue:AsyncStream.Continuation, updates:AsyncStream - public var tour:ServerTour public @@ -246,7 +245,7 @@ extension Unidoc.ServerLoop self.tour.errors += 1 Log[.error] = "\(error)" - Log[.error] = "request = \(metadata.path)" + Log[.error] = "request = \(metadata.uri)" let page:Unidoc.ServerErrorPage = .init(error: error) return .error(page.resource(format: self.format)) @@ -269,12 +268,12 @@ extension Unidoc.ServerLoop if self.tour.slowestQuery?.time ?? .zero < duration { - self.tour.slowestQuery = .init(time: duration, path: metadata.path) + self.tour.slowestQuery = .init(time: duration, uri: metadata.uri) } if duration > .seconds(1) { Log[.warning] = """ - query '\(metadata.path)' took \(duration) to complete! + query '\(metadata.uri)' took \(duration) to complete! """ } diff --git a/Sources/UnidocUI/Endpoints/Tags/Unidoc.VersionsPage.swift b/Sources/UnidocUI/Endpoints/Tags/Unidoc.VersionsPage.swift index 1d068c240..a699a6e9e 100644 --- a/Sources/UnidocUI/Endpoints/Tags/Unidoc.VersionsPage.swift +++ b/Sources/UnidocUI/Endpoints/Tags/Unidoc.VersionsPage.swift @@ -249,10 +249,12 @@ extension Unidoc.VersionsPage if case .administratrix? = self.view.global { + // If package is hidden, we can unhide it without confirmation. + let really:Bool = self.package.hidden $0[.form] { $0.enctype = "\(MediaType.application(.x_www_form_urlencoded))" - $0.action = "\(Unidoc.Post[.packageConfig, really: false])" + $0.action = "\(Unidoc.Post[.packageConfig, really: really])" $0.method = "post" } = ConfigButton.init(package: self.package.id, update: "hidden", diff --git a/Sources/unidoc-preview/Unidoc.IntegralRequest (ext).swift b/Sources/unidoc-preview/Unidoc.IntegralRequest (ext).swift index 78d9fd14c..810303417 100644 --- a/Sources/unidoc-preview/Unidoc.IntegralRequest (ext).swift +++ b/Sources/unidoc-preview/Unidoc.IntegralRequest (ext).swift @@ -4,41 +4,28 @@ import Multiparts import NIOHPACK import NIOHTTP1 import UnidocServer +import URI extension Unidoc.IntegralRequest:HTTP.ServerIntegralRequest { public - init?(get path:String, - headers:borrowing HTTPHeaders, - origin:IP.Origin) + init?(get uri:URI, headers:HTTPHeaders, origin:IP.Origin) { - self.init(get: .init(headers: headers, origin: origin, path: path), tag: nil) + let metadata:Metadata = .init(headers: headers, origin: origin, uri: uri) + self.init(get: metadata) } public - init?(get path:String, - headers:borrowing HPACKHeaders, - origin:IP.Origin) + init?(get uri:URI, headers:HPACKHeaders, origin:IP.Origin) { - self.init(get: .init(headers: headers, origin: origin, path: path), tag: nil) + let metadata:Metadata = .init(headers: headers, origin: origin, uri: uri) + self.init(get: metadata) } public - init?(post path:String, - headers:borrowing HPACKHeaders, - origin:IP.Origin, - body:consuming [UInt8]) + init?(post uri:URI, headers:HPACKHeaders, origin:IP.Origin, body:borrowing [UInt8]) { - let metadata:Metadata = .init(headers: headers, origin: origin, path: path) - - guard - let type:String = headers["content-type"].first, - let type:ContentType = .init(type) - else - { - return nil - } - - self.init(post: metadata, body: body, type: type) + let metadata:Metadata = .init(headers: headers, origin: origin, uri: uri) + self.init(post: metadata, body: body) } }