| 1 | import HTTPTypes |
| 2 | import Hummingbird |
| 3 | |
| 4 | /// Gives each visitor their own list through an anonymous cookie. |
| 5 | /// |
| 6 | /// The cookie holds 128 random bits as 32 hex digits. A value the app |
| 7 | /// didn't issue, well-formed or not, gets a fresh empty list and a new |
| 8 | /// cookie, never an error. |
| 9 | struct Visitors: Sendable { |
| 10 | static let cookieName = "todo_list" |
| 11 | |
| 12 | let store: Store |
| 13 | /// Adds `Secure` to the cookie. True when served over https. |
| 14 | let secure: Bool |
| 15 | |
| 16 | struct Visitor { |
| 17 | var list: Int |
| 18 | /// Set when this request minted the visitor a new list. |
| 19 | var newToken: String? |
| 20 | } |
| 21 | |
| 22 | func resolve(_ request: Request) async throws -> Visitor { |
| 23 | if let token = request.cookies[Self.cookieName]?.value, Self.isWellFormed(token), |
| 24 | let list = try await store.list(token: token) |
| 25 | { |
| 26 | return Visitor(list: list) |
| 27 | } |
| 28 | let token = Self.newToken() |
| 29 | return Visitor(list: try await store.createList(token: token), newToken: token) |
| 30 | } |
| 31 | |
| 32 | func attach(_ visitor: Visitor, to response: inout Response) { |
| 33 | guard let token = visitor.newToken else { return } |
| 34 | // About 400 days, the longest lifetime browsers honour. |
| 35 | var cookie = "\(Self.cookieName)=\(token); Path=/; Max-Age=34560000; HttpOnly; SameSite=Lax" |
| 36 | if secure { cookie += "; Secure" } |
| 37 | response.headers.append(HTTPField(name: .setCookie, value: cookie)) |
| 38 | } |
| 39 | |
| 40 | static func newToken() -> String { |
| 41 | var rng = SystemRandomNumberGenerator() |
| 42 | let bytes = (0..<16).map { _ in UInt8.random(in: .min ... .max, using: &rng) } |
| 43 | return bytes.map { byte in |
| 44 | let hex = String(byte, radix: 16) |
| 45 | return byte < 16 ? "0" + hex : hex |
| 46 | }.joined() |
| 47 | } |
| 48 | |
| 49 | static func isWellFormed(_ token: String) -> Bool { |
| 50 | token.utf8.count == 32 && token.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) } |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /// Refuses a POST whose `Origin` header names another site. A POST with no |
| 55 | /// `Origin` is allowed; there is no CSRF token. |
| 56 | struct OriginCheck<Context: RequestContext>: RouterMiddleware { |
| 57 | /// The app's own origin, or nil to accept the origin the `Host` header |
| 58 | /// implies (an instance started without `CARLOS_HOST`). |
| 59 | let origin: String? |
| 60 | |
| 61 | func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { |
| 62 | if request.method == .post, let sent = request.headers[.origin], !allows(sent, host: request.head.authority) { |
| 63 | return Response( |
| 64 | status: .forbidden, |
| 65 | headers: [.contentType: "text/plain; charset=utf-8"], |
| 66 | body: .init(byteBuffer: ByteBuffer(string: "Cross-site form posts are refused.\n")) |
| 67 | ) |
| 68 | } |
| 69 | return try await next(request, context) |
| 70 | } |
| 71 | |
| 72 | func allows(_ sent: String, host: String?) -> Bool { |
| 73 | if let origin { return sent == origin } |
| 74 | guard let host else { return false } |
| 75 | return sent == "https://\(host)" || sent == "http://\(host)" |
| 76 | } |
| 77 | } |
| 78 | |