import Configuration import Hummingbird import HummingbirdFluent import Localization import Logging import Persistence import Infrastructure import WebsiteLibrary /// Builds the website application. /// /// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles /// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys. /// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts /// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated here, before the landing page reads its listings, while a /// PostgreSQL backend is migrated out of band (so a shared database is never migrated on boot). /// - Parameter reader: the configuration reader the values are read from. /// - Returns: the configured application, ready to run as a service. /// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built /// (e.g. its TLS context fails to build). func application( reader: ConfigReader ) async throws -> some ApplicationProtocol { let languages = LanguageList() let logger = logger( serverName: reader.serverName, logLevel: reader.logLevel ) // A broken catalog degrades to serving raw localization keys rather than failing, so it is only ever visible to // visitors — surface it here instead. if languages.catalogState != .loaded { let isCatalogMissing = languages.catalogState == .missing logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys") } let driver = try reader.driver let persistence = try Service( driver: driver, logger: logger ) let fluent = persistence() let fingerprintAssets = FingerprintAssets(logger: logger) let prepareDB = PrepareDB() await prepareDB(for: fluent) // The in-memory backend is recreated on every launch, so it is migrated — and seeded — before the landing page // reads its listings. The PostgreSQL backend is left untouched: a shared database is migrated out of band to avoid // multi-instance races. if case .inMemory = driver { try await fluent.migrate() } let repository = CatalogueRepository(fluent: fluent) var app = Application( router: router( staticFilesPath: reader.staticFilesPath, assetVersion: fingerprintAssets(reader.staticFilesPath), analytics: reader.analytics, cacheControl: reader.cacheControl, compressionMinResponseSize: reader.compressionMinResponseSize, httpsRedirect: reader.httpsRedirect, rateLimit: reader.rateLimit, securityHeaders: reader.securityHeaders, // An unset origin leaves the pages without canonical URLs and language alternates, rather than // building both against an empty host. siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin, logLevel: reader.logLevel, passkeysOrigin: reader.passkeysOrigin, probe: Probe(fluent: fluent), fluent: fluent, accounts: AccountRepository(fluent: fluent), trade: TradeRepository(fluent: fluent), repository: repository, listings: await homeListings( from: repository, logger: logger ) ), configuration: ApplicationConfiguration( reader: reader.scoped(to: "http") ), logger: logger ) app.addServices(fluent) return app } /// Runs every registered migration against the configured backend, then exits. /// /// This is the out-of-band migration path selected by the `database.migrate` flag: it builds the same driver the service would run against, applies the /// migrations, and shuts the database down — so a shared PostgreSQL database is migrated by a single deliberate invocation rather than by every /// booting instance. /// - Parameter reader: the configuration reader the values are read from. func migration( reader: ConfigReader ) async throws { let logger = logger( serverName: reader.serverName, logLevel: reader.logLevel ) let service = try Service( driver: try reader.driver, logger: logger ) let fluent = service() let prepareDB = PrepareDB() await prepareDB(for: fluent) do { try await fluent.migrate() } catch { try? await fluent.shutdown() throw error } try await fluent.shutdown() } // MARK: - Helpers /// The request context type the application serves its routes with. private typealias AppRequestContext = WebsiteRequestContext /// Reads the listings the landing page shows, once: the page is rendered at startup and cached. /// /// A catalogue that cannot be read — a PostgreSQL backend not migrated yet — leaves the page without its rows rather than failing the launch. /// - Parameters: /// - repository: the repository the listings are read through. /// - logger: the logger the failure is reported to. /// - Returns: the landing page's listings, or none when the catalogue cannot be read. private func homeListings( from repository: CatalogueRepository, logger: Logger ) async -> HomeListings { do { return .init(from: try await repository.listings()) } catch { logger.warning("Catalogue is unreadable; the landing page will show no listings: \(error)") return .init() } } /// Builds the application's logger. /// - Parameters: /// - serverName: the label applied to the logger. /// - logLevel: the minimum level the logger emits. /// - Returns: the configured logger. private func logger( serverName: String, logLevel: Logger.Level ) -> Logger { var logger = Logger(label: serverName) logger.logLevel = logLevel return logger } /// Builds the application's router. /// /// The chain below reads as its own list; the order is what does not. Security headers sit just inside request logging, so they reach every response a /// client sees — pages, compressed bodies, the not-found page, the served files. The HTTPS redirect sits directly beneath, keeping those headers while /// skipping the compression and file lookup it would otherwise pay for. The trailing-slash redirect follows, ahead of the routes and `FileMiddleware` /// that would otherwise answer both spellings of every path. The language is negotiated by the responders that read it — the landing route and the /// not-found middleware — rather than on every request past. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned. /// - analytics: the analytics tracker both pages embed, or `nil` to omit it. /// - cacheControl: the cache-control directives applied to the served static files. /// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied. /// - httpsRedirect: the origin plain-HTTP requests are redirected to, and whether the forwarded-protocol header is trusted. /// - rateLimit: the rate limit configuration, currently applied to no route. /// - securityHeaders: the security headers applied to every response. /// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them. /// - logLevel: the level the request-logging middleware logs at. /// - passkeysOrigin: the origin the `PasskeyController` binds passkeys to. /// - probe: the probe consulted by the `HealthController` readiness route. /// - fluent: the service the remaining repositories are built on. /// - accounts: the repository the `SessionController` finds and creates accounts through. /// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales through. /// - repository: the repository the `CatalogueController` reads the shops and listings through. /// - listings: the listings the landing page shows in its rows. /// - Returns: the configured router. private func router( staticFilesPath: String, assetVersion: String?, analytics: Analytics?, cacheControl: CacheControl, compressionMinResponseSize: Int, httpsRedirect: HTTPSRedirectMiddleware.Configuration, rateLimit: RateLimitMiddleware.Configuration, securityHeaders: SecurityHeadersMiddleware.Configuration, siteOrigin: String?, logLevel: Logger.Level, passkeysOrigin: String, probe: Probe, fluent: Fluent, accounts: AccountRepository, trade: TradeRepository, repository: CatalogueRepository, listings: HomeListings ) -> Router { // HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get // the page's status and headers instead of a 404. let router = Router( context: AppRequestContext.self, options: .autoGenerateHeadEndpoints ) let collecting = CollectingRepository(fluent: fluent) let sessions = SessionStore( trade: trade, collecting: collecting ) let root = RootController( assetVersion: assetVersion, siteOrigin: siteOrigin, analytics: analytics, listings: listings, sessions: sessions ) // A sale takes a copy off the landing page and a new listing puts one on; either renders it again. let refreshHome: @Sendable () async -> Void = { [home = root.home] in guard let listings = try? await repository.listings() else { return } await home.refresh(with: .init(from: listings)) } router.addMiddleware { LogRequestsMiddleware(logLevel) SecurityHeadersMiddleware( configuration: securityHeaders ) HTTPSRedirectMiddleware( configuration: httpsRedirect ) TrailingSlashRedirectMiddleware() VaryMiddleware() CompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) NotFoundMiddleware( assetVersion: assetVersion, analytics: analytics ) FileMiddleware( staticFilesPath, cacheControl: cacheControl ) } router.addController { root CatalogueController( repository: repository, sessions: sessions, assetVersion: assetVersion, analytics: analytics ) SessionController( sessions: sessions, accounts: accounts, catalogue: repository, // Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable. isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"), assetVersion: assetVersion, analytics: analytics ) AccountController( accounts: accounts, catalogue: repository, sessions: sessions, assetVersion: assetVersion, analytics: analytics ) CollectingController( collecting: collecting, catalogue: repository, sessions: sessions, assetVersion: assetVersion, analytics: analytics ) PasskeyController( origin: passkeysOrigin, accounts: accounts, sessions: sessions ) ListingFormController( catalogue: repository, trade: trade, sessions: sessions, photos: PhotoStore(staticFilesPath: staticFilesPath), assetVersion: assetVersion, analytics: analytics, onCatalogueChange: refreshHome ) TradeController( trade: trade, catalogue: repository, sessions: sessions, assetVersion: assetVersion, analytics: analytics, onCatalogueChange: refreshHome ) HealthController( probe: probe ) } return router }