339 lines
13 KiB
Swift
339 lines
13 KiB
Swift
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,
|
|
isIndexable: reader.isIndexable,
|
|
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.
|
|
/// - isIndexable: whether search engines may index the site; while `false`, every response is marked `noindex`.
|
|
/// - 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<AppRequestContext>.Configuration,
|
|
isIndexable: Bool,
|
|
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
|
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
|
siteOrigin: String?,
|
|
logLevel: Logger.Level,
|
|
passkeysOrigin: String,
|
|
probe: Probe,
|
|
fluent: Fluent,
|
|
accounts: AccountRepository,
|
|
trade: TradeRepository,
|
|
repository: CatalogueRepository,
|
|
listings: HomeListings
|
|
) -> Router<AppRequestContext> {
|
|
// 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 recoveryLinks = RecoveryLinks()
|
|
let sessions = SessionStore(
|
|
trade: trade,
|
|
collecting: collecting
|
|
)
|
|
let root = RootController<AppRequestContext>(
|
|
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))
|
|
}
|
|
|
|
// Outermost, so the mark reaches redirects, errors, and served files alike.
|
|
if !isIndexable {
|
|
router.add(middleware: NoIndexMiddleware())
|
|
}
|
|
|
|
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<AppRequestContext>(
|
|
repository: repository,
|
|
sessions: sessions,
|
|
collecting: collecting,
|
|
assetVersion: assetVersion,
|
|
analytics: analytics
|
|
)
|
|
SessionController<AppRequestContext>(
|
|
sessions: sessions,
|
|
accounts: accounts,
|
|
catalogue: repository,
|
|
recoveryLinks: recoveryLinks,
|
|
// 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<AppRequestContext>(
|
|
accounts: accounts,
|
|
catalogue: repository,
|
|
sessions: sessions,
|
|
assetVersion: assetVersion,
|
|
analytics: analytics
|
|
)
|
|
CollectingController<AppRequestContext>(
|
|
collecting: collecting,
|
|
catalogue: repository,
|
|
sessions: sessions,
|
|
assetVersion: assetVersion,
|
|
analytics: analytics
|
|
)
|
|
PasskeyController<AppRequestContext>(
|
|
origin: passkeysOrigin,
|
|
accounts: accounts,
|
|
sessions: sessions,
|
|
recoveryLinks: recoveryLinks
|
|
)
|
|
ListingFormController<AppRequestContext>(
|
|
catalogue: repository,
|
|
trade: trade,
|
|
sessions: sessions,
|
|
photos: PhotoStore(staticFilesPath: staticFilesPath),
|
|
assetVersion: assetVersion,
|
|
analytics: analytics,
|
|
onCatalogueChange: refreshHome
|
|
)
|
|
TradeController<AppRequestContext>(
|
|
trade: trade,
|
|
catalogue: repository,
|
|
sessions: sessions,
|
|
collecting: collecting,
|
|
assetVersion: assetVersion,
|
|
analytics: analytics,
|
|
onCatalogueChange: refreshHome
|
|
)
|
|
HealthController<AppRequestContext>(
|
|
probe: probe
|
|
)
|
|
}
|
|
|
|
return router
|
|
}
|