diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd7570a..8f516a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -133,6 +133,7 @@ jobs: FLICKR_SECRET=${{ secrets.FLICKR_SECRET }} REFUND_PERIOD=${{ secrets.REFUND_PERIOD }} CONFERENCE=${{ matrix.conference }} + JWT_SECRET=${{ secrets.JWT_SECRET }} - name: Update GitHub Deployment uses: bobheadxi/deployments@v1 diff --git a/Package.resolved b/Package.resolved index 54d16a6..e44dcbf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "25e8201281b55a93e0dc2eb271b2b327d568944d28063efba00877c780e6fa71", + "originHash" : "c813346744436891189c6b2ddcdecc17f8a5e93d38b5bb2d0a39c9c3dbc3dd6a", "pins" : [ { "identity" : "apns", @@ -91,6 +91,24 @@ "version" : "1.1.1" } }, + { + "identity" : "jwt", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt.git", + "state" : { + "revision" : "af1c59762d70d1065ddbc0d7902ea9b3dacd1a26", + "version" : "5.1.2" + } + }, + { + "identity" : "jwt-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt-kit.git", + "state" : { + "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", + "version" : "5.2.0" + } + }, { "identity" : "leaf", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ee5a8b1..75601dc 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( .package(url: "https://github.com/vapor/apns.git", from: "5.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), .package(url: "https://github.com/handya/markdown.git", branch: "fix/xcode-16"), + .package(url: "https://github.com/vapor/jwt.git", from: "5.0.0"), // This package is used by AWSSDKSwiftCore on Linux only. We add it here (but don't utilise it) in order to // add it to the Package.resolved file. This ensures that when Docker resolves this project, it will not ignore @@ -35,6 +36,7 @@ let package = Package( .product(name: "VaporAPNS", package: "apns"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "JWT", package: "jwt"), ], swiftSettings: swiftSettings ), diff --git a/Sources/App/Features/Activities/Controllers/ActivityRouteController.swift b/Sources/App/Features/Activities/Controllers/ActivityRouteController.swift index 195ca3d..2ad8fa0 100644 --- a/Sources/App/Features/Activities/Controllers/ActivityRouteController.swift +++ b/Sources/App/Features/Activities/Controllers/ActivityRouteController.swift @@ -17,12 +17,15 @@ struct ActivityRouteController: RouteCollection { let activity = try await request.parameters.get("id").map { Activity.find($0, on: request.db) }?.get() try await activity?.$event.load(on: request.db) - let context = try await buildContext(from: request.db, activity: activity) + let context = try await buildContext(req: request, activity: activity) return try await request.view.render("Admin/Form/activity_form", context) } - private func buildContext(from db: any Database, activity: Activity?) async throws -> ActivityContext { - let events = try await Event.query(on: db).sort(\.$date).all() + private func buildContext(req: Request, activity: Activity?) async throws -> ActivityContext { + let events = try await Event.query(on: req.db) + .filter(\.$conference == req.application.conference.rawValue) + .sort(\.$date) + .all() return ActivityContext(activity: activity, events: events) } diff --git a/Sources/App/Features/App Login/Controllers/AppLoginRouteController.swift b/Sources/App/Features/App Login/Controllers/AppLoginRouteController.swift new file mode 100644 index 0000000..d425c27 --- /dev/null +++ b/Sources/App/Features/App Login/Controllers/AppLoginRouteController.swift @@ -0,0 +1,67 @@ +import Fluent +import Vapor + +struct AppLoginRouteController: RouteCollection { + func boot(routes: any RoutesBuilder) throws { + routes.post("ticket", use: login) + + routes.group(AppBearerMiddleware()) { builder in + builder.get("ticket", use: ticket) + } + } + + @Sendable private func login(request: Request) async throws -> String { + let payload = try request.content.decode(AppLoginRequest.self, as: .json) + + var eventQuery = Event.query(on: request.db) + .filter(\.$conference == request.application.conference.rawValue) + + if let eventId = payload.event { + eventQuery = eventQuery.filter(\.$id == eventId) + } else { + eventQuery = eventQuery.filter(\.$isCurrent == true) + } + + guard let event = try await eventQuery.first() else { + throw Abort(.notFound, reason: "failed to identify event") + } + + guard let titoEvent = event.titoEvent else { + throw Abort(.internalServerError, reason: "login has not been setup for this event") + } + + let lookup = TicketLoginPayload(email: payload.email, ticket: payload.ticket) + guard let ticket = try await TitoService(event: titoEvent).ticket(payload: lookup, req: request) else { + throw Abort(.forbidden) + } + + guard let email = ticket.email else { + // An email is `nil` when the ticket has not been assigned + // This an impossible flow though as to get past the ticket lookup you must have matched the input email + // therefore ticket.email will always equal payload.email (± formatting) + throw Abort(.internalServerError, reason: "requested unassigned ticket") + } + + return try await request.jwt.sign(AppTicketJWTPayload( + sub: .init(value: email), + iat: .init(value: .now), + slug: ticket.slug, + reference: ticket.reference, + event: event.requireID(), + ticketType: ticket.release.title + )) + } + + @Sendable private func ticket(request: Request) async throws -> TitoTicketResponse { + guard let ticket = request.storage.get(TicketStorage.self) else { + throw Abort(.unauthorized, reason: "Ticket not present in session storage") + } + + return TitoTicketResponse(ticket: ticket) + } +} + +struct TitoTicketResponse: Content { + static let defaultContentType: HTTPMediaType = .json + let ticket: TitoTicket +} diff --git a/Sources/App/Features/App Login/Middleware/AppBearerMiddleware.swift b/Sources/App/Features/App Login/Middleware/AppBearerMiddleware.swift new file mode 100644 index 0000000..af519c0 --- /dev/null +++ b/Sources/App/Features/App Login/Middleware/AppBearerMiddleware.swift @@ -0,0 +1,31 @@ +import Fluent +import Vapor + +struct AppBearerMiddleware: AsyncMiddleware { + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + guard let token = request.headers.bearerAuthorization?.token else { + throw Abort(.unauthorized) + } + + let payload = try await request.jwt.verify(token, as: AppTicketJWTPayload.self) + + guard let currentEvent = try await Event.query(on: request.db).filter(\.$id == payload.event).first() else { + throw Abort(.internalServerError) + } + + try await currentEvent.$days.load(on: request.db) + + guard let titoEvent = currentEvent.titoEvent else { + throw Abort(.badRequest, reason: "unable to identify tito project") + } + + await request.storage.setWithAsyncShutdown(CurrentEventKey.self, to: currentEvent) + + guard let ticket = try await TitoService(event: titoEvent).ticket(stub: payload.slug, req: request) else { + throw Abort(.unauthorized) + } + + await request.storage.setWithAsyncShutdown(TicketStorage.self, to: ticket) + return try await next.respond(to: request) + } +} diff --git a/Sources/App/Features/App Login/Models/AppLoginRequest.swift b/Sources/App/Features/App Login/Models/AppLoginRequest.swift new file mode 100644 index 0000000..2f89189 --- /dev/null +++ b/Sources/App/Features/App Login/Models/AppLoginRequest.swift @@ -0,0 +1,7 @@ +import Vapor + +struct AppLoginRequest: Content { + let event: UUID? + let email: String + let ticket: String +} diff --git a/Sources/App/Features/App Login/Models/AppTicketResponse.swift b/Sources/App/Features/App Login/Models/AppTicketResponse.swift new file mode 100644 index 0000000..b4fede8 --- /dev/null +++ b/Sources/App/Features/App Login/Models/AppTicketResponse.swift @@ -0,0 +1,13 @@ +import Foundation +import JWT + +struct AppTicketJWTPayload: JWTPayload { + let sub: SubjectClaim + let iat: IssuedAtClaim + let slug: String + let reference: String + let event: UUID + let ticketType: String + + func verify(using _: some JWTKit.JWTAlgorithm) async throws {} +} diff --git a/Sources/App/Features/CheckIn/Controllers/CheckInAPIController.swift b/Sources/App/Features/CheckIn/Controllers/CheckInAPIController.swift index 8f1d7d8..b64c5f5 100644 --- a/Sources/App/Features/CheckIn/Controllers/CheckInAPIController.swift +++ b/Sources/App/Features/CheckIn/Controllers/CheckInAPIController.swift @@ -16,7 +16,7 @@ struct CheckInAPIController: RouteCollection { } // If it is, then return Event.checkinKey or `CHECKIN_TAG` - let event = try await Event.getCurrent(on: request.db) + let event = try await Event.getCurrent(req: request) return CheckIn(tag: event.checkinKey ?? Environment.get("CHECKIN_TAG") ?? "") } diff --git a/Sources/App/Features/DropIns/Controllers/DropInRouteController.swift b/Sources/App/Features/DropIns/Controllers/DropInRouteController.swift index 20c6eb6..e48545c 100644 --- a/Sources/App/Features/DropIns/Controllers/DropInRouteController.swift +++ b/Sources/App/Features/DropIns/Controllers/DropInRouteController.swift @@ -33,7 +33,10 @@ struct DropInRouteController: RouteCollection { .first() }?.get() - let events = try await Event.query(on: request.db).sort(\.$date).all() + let events = try await Event.query(on: request.db) + .filter(\.$conference == request.application.conference.rawValue) + .sort(\.$date) + .all() let context = SessionContext( session: session, events: events, diff --git a/Sources/App/Features/Event Day/Controllers/EventDayRouteController.swift b/Sources/App/Features/Event Day/Controllers/EventDayRouteController.swift index e7c4bce..8852948 100644 --- a/Sources/App/Features/Event Day/Controllers/EventDayRouteController.swift +++ b/Sources/App/Features/Event Day/Controllers/EventDayRouteController.swift @@ -20,7 +20,10 @@ struct EventDayRouteController: RouteCollection { let day = try await request.parameters.get("id").map { EventDay.find($0, on: request.db) }?.get() try await day?.$event.load(on: request.db) - let events = try await Event.query(on: request.db).all() + let events = try await Event + .query(on: request.db) + .filter(\.$conference == request.application.conference.rawValue) + .all() let context = EventDayContext(day: day, events: events) return try await request.view.render("Admin/Form/event_day_form", context) diff --git a/Sources/App/Features/Events/Models/Event.swift b/Sources/App/Features/Events/Models/Event.swift index 8d2c5e0..4c6a65d 100644 --- a/Sources/App/Features/Events/Models/Event.swift +++ b/Sources/App/Features/Events/Models/Event.swift @@ -52,8 +52,12 @@ final class Event: Model, Content, @unchecked Sendable { } extension Event { - static func getCurrent(on db: any Database) async throws -> Event { - guard let event = try await Event.query(on: db).filter(\.$isCurrent == true).first() else { + static func getCurrent(req: Request) async throws -> Event { + guard let event = try await Event.query(on: req.db) + .filter(\.$isCurrent == true) + .filter(\.$conference == req.application.conference.rawValue) + .first() + else { throw Abort(.notFound, reason: "could not locate current event") } @@ -61,6 +65,11 @@ extension Event { } func shouldBeReturned(by request: Request) -> Bool { + // prevent being able to specify an ID of a conference on a different deployment + guard request.application.conference.rawValue == conference else { + return false + } + // if the request has a query parameter of 'event' (the event ID) // then only return 'true' if the ID provided matches this event if let targetEvent: String = try? request.query.get(String.self, at: "event") { diff --git a/Sources/App/Features/Home/HomeRouteController.swift b/Sources/App/Features/Home/HomeRouteController.swift index c51c711..68ce99c 100644 --- a/Sources/App/Features/Home/HomeRouteController.swift +++ b/Sources/App/Features/Home/HomeRouteController.swift @@ -200,11 +200,11 @@ struct HomeRouteController: RouteCollection { private func getEvent(for req: Request) async throws -> Event? { if let yearParameterItem = req.parameters.get("year") { return try await Event.query(on: req.db) - .filter("name", .equal, "SwiftLeeds \(yearParameterItem)") + .filter("name", .custom("ilike"), "\(req.application.conference.rawValue) \(yearParameterItem)") .first() } - return try await Event.getCurrent(on: req.db) + return try await Event.getCurrent(req: req) } } diff --git a/Sources/App/Features/Presentations/Controllers/PresentationRouteController.swift b/Sources/App/Features/Presentations/Controllers/PresentationRouteController.swift index bc21647..fb66dc4 100644 --- a/Sources/App/Features/Presentations/Controllers/PresentationRouteController.swift +++ b/Sources/App/Features/Presentations/Controllers/PresentationRouteController.swift @@ -16,7 +16,11 @@ struct PresentationRouteController: RouteCollection { @Sendable private func onRead(request: Request) async throws -> View { let presentation = try await request.parameters.get("id").map { Presentation.find($0, on: request.db) }?.get() let speakers = try await Speaker.query(on: request.db).sort(\.$name).all() - let events = try await Event.query(on: request.db).sort(\.$date).all() + let events = try await Event + .query(on: request.db) + .filter(\.$conference == request.application.conference.rawValue) + .sort(\.$date) + .all() let context = PresentationContext(presentation: presentation, speakers: speakers, events: events, hasSecondSpeaker: presentation?.$secondSpeaker.id != nil) return try await request.view.render("Admin/Form/presentation_form", context) diff --git a/Sources/App/Features/Purchase/PurchaseRouteController.swift b/Sources/App/Features/Purchase/PurchaseRouteController.swift index 9875a27..fbb51fb 100644 --- a/Sources/App/Features/Purchase/PurchaseRouteController.swift +++ b/Sources/App/Features/Purchase/PurchaseRouteController.swift @@ -7,7 +7,7 @@ struct PurchaseRouteController: RouteCollection { } @Sendable func get(req: Request) async throws -> View { - let event = try await Event.getCurrent(on: req.db) + let event = try await Event.getCurrent(req: req) return try await req.view.render("Purchase/index", HomeContext(event: EventContext(event: event))) } } diff --git a/Sources/App/Features/Sponsors/Controllers/SponsorRouteController.swift b/Sources/App/Features/Sponsors/Controllers/SponsorRouteController.swift index 4b8d3d4..1571034 100644 --- a/Sources/App/Features/Sponsors/Controllers/SponsorRouteController.swift +++ b/Sources/App/Features/Sponsors/Controllers/SponsorRouteController.swift @@ -15,7 +15,10 @@ struct SponsorRouteController: RouteCollection { @Sendable private func onRead(request: Request) async throws -> View { let sponsor = try await request.parameters.get("id").map { Sponsor.find($0, on: request.db) }?.get() - let events = try await Event.query(on: request.db).sort(\.$date).all() + let events = try await Event.query(on: request.db) + .filter(\.$conference == request.application.conference.rawValue) + .sort(\.$date) + .all() let context = SponsorContext(sponsor: sponsor, sponsorLevels: sponsorLevels, events: events) return try await request.view.render("Admin/Form/sponsor_form", context) diff --git a/Sources/App/Features/Talks/TalkRouteController.swift b/Sources/App/Features/Talks/TalkRouteController.swift index 13b31e0..4a70c20 100644 --- a/Sources/App/Features/Talks/TalkRouteController.swift +++ b/Sources/App/Features/Talks/TalkRouteController.swift @@ -11,18 +11,18 @@ struct TalkRouteController: RouteCollection { title: "ICYMI: Enums Are...", description: """ 2 facts about me: I am obsessed with enums, and I am a professional drummer and percussionist 🎵 - + For this talk, I'd like to marry these great loves by - + 1. sharing my curiosity about enums with the SwiftLeeds community 2. leveraging my musical skills as the backdrop for a sample app called PocketPerc: Beat Generator - + In the app, a user would be able to toggle on any number of instrument samples to build a unique groove all their own, with samples largely recorded by me 🥁 - + To build up to the beat part (obviously the Grand Finale), I’d like to showcase enums in all their glory, aka possibly/hopefully using them way too much and inspiring some wild ideas about what they can do 🤗 The static data we would need is a perfect opportunity to eclipse structs and classes as the go-to data types, and instead rely heavily on enums to build views and stay categorized! To make it extra fun, I plan to limit myself as much as possible to only that data type, see what boundaries we bump up against, and discuss where and why we might want to pivot to another structure for production. - + Here’s a non-exhaustive list of the power moves I’ve been exploring: - + Protocol conformance CaseIterable, Equatable and Comparable conformances Associated values (with and without default values) Pattern matching Screen management Static properties Computed properties Interaction with ObservableObjects Custom raw types """.replacingOccurrences(of: "\n", with: "
"), author: "Jessie Linden", diff --git a/Sources/App/Features/Ticket Hub/Controllers/TicketHubRouteController.swift b/Sources/App/Features/Ticket Hub/Controllers/TicketHubRouteController.swift index 2237f69..4febfb0 100644 --- a/Sources/App/Features/Ticket Hub/Controllers/TicketHubRouteController.swift +++ b/Sources/App/Features/Ticket Hub/Controllers/TicketHubRouteController.swift @@ -5,7 +5,6 @@ struct TicketHubRouteController: RouteCollection { func boot(routes: any RoutesBuilder) throws { routes.grouped(ValidTicketMiddleware()).group("ticket") { builder in builder.get { req -> View in - // guard let currentEvent = try await Event.query(on: req.db).filter(\.$name == "SwiftLeeds 2023").first() else { guard let currentEvent = req.storage.get(CurrentEventKey.self) else { throw Abort(.badRequest, reason: "unable to identify current event") } diff --git a/Sources/App/Features/Tickets/Controllers/TicketLoginController.swift b/Sources/App/Features/Tickets/Controllers/TicketLoginController.swift index 45d7aad..5240224 100644 --- a/Sources/App/Features/Tickets/Controllers/TicketLoginController.swift +++ b/Sources/App/Features/Tickets/Controllers/TicketLoginController.swift @@ -15,7 +15,7 @@ struct TicketLoginController: RouteCollection { } routes.post("ticketLogin", "validate") { req async throws -> Response in - guard let currentTitoEvent = try await Event.query(on: req.db).filter(\.$isCurrent == true).first()?.titoEvent else { + guard let currentTitoEvent = try await Event.getCurrent(req: req).titoEvent else { throw Abort(.badRequest, reason: "unable to identify current event") } diff --git a/Sources/App/Features/Tickets/Middleware/ValidTicketMiddleware.swift b/Sources/App/Features/Tickets/Middleware/ValidTicketMiddleware.swift index b8444b3..6c862ac 100644 --- a/Sources/App/Features/Tickets/Middleware/ValidTicketMiddleware.swift +++ b/Sources/App/Features/Tickets/Middleware/ValidTicketMiddleware.swift @@ -3,10 +3,7 @@ import Vapor struct ValidTicketMiddleware: AsyncMiddleware { func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { - guard let currentEvent = try await Event.query(on: request.db).filter(\.$isCurrent == true).first() else { - throw Abort(.badRequest, reason: "unable to identify current event") - } - + let currentEvent = try await Event.getCurrent(req: request) try await currentEvent.$days.load(on: request.db) guard let titoEvent = currentEvent.titoEvent else { diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 8397fe8..b93e238 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,3 +1,4 @@ +import JWT import Leaf import Vapor import VaporAPNS @@ -51,15 +52,12 @@ public func configure(_ app: Application) async throws { // Routes app.routes.defaultMaxBodySize = "10mb" + try routes(app) - switch app.conference { - case .kotlinleeds: - app.get { req in - req.view.render("Kotlin/home") - } - - case .swiftleeds: - try routes(app) + // JWT + if let secret = Environment.get("JWT_SECRET") { + await app.jwt.keys.add(hmac: HMACKey(from: secret), digestAlgorithm: .sha256) + app.logger.info("Setup JWT successfully") } // APNS diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 495149b..9bd8cbb 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -1,29 +1,36 @@ +import Fluent import Vapor func routes(_ app: Application) throws { // MARK: - Web Routes - try app.routes.register(collection: HomeRouteController()) - - #if DEBUG - try app.routes.register(collection: TalkRouteController()) - #endif - - app.routes.get("login") { request in - request.view.render("Authentication/login") - } - - app.routes.get("register") { request -> View in - if let message = request.query[String.self, at: "message"] { - return try await request.view.render("Authentication/register", RegisterContext(message: message)) - } else { - return try await request.view.render("Authentication/register") + switch app.conference { + case .swiftleeds: + try app.routes.register(collection: HomeRouteController()) + + #if DEBUG + try app.routes.register(collection: TalkRouteController()) + #endif + + case .kotlinleeds: + app.get { req in + req.view.render("Kotlin/home") } } - app.get("conduct") { req -> View in - return try await req.view.render("Secondary/conduct", HomeContext()) - } + // MARK: - API Routes + + let apiRoutes = app.grouped("api", "v1") + try apiRoutes.grouped("sponsors").register(collection: SponsorAPIController()) + try apiRoutes.grouped("schedule").register(collection: ScheduleAPIController()) + try apiRoutes.grouped("local").register(collection: LocalAPIController()) + try apiRoutes.grouped("tickets").register(collection: TicketsAPIController()) + try apiRoutes.grouped("checkin").register(collection: CheckInAPIController()) + try apiRoutes.grouped("login").register(collection: AppLoginRouteController()) + + let apiV2Routes = app.grouped("api", "v2") + try apiV2Routes.grouped("schedule").register(collection: ScheduleAPIControllerV2()) + try apiV2Routes.grouped("team").register(collection: TeamAPIController()) app.get("robots.txt") { _ -> String in let disallowedPaths = [ @@ -40,6 +47,26 @@ func routes(_ app: Application) throws { \(disallowedPaths) """ } + + if app.conference == .kotlinleeds { + return // limit routes on KotlinLeeds domain for now + } + + app.routes.get("login") { request in + request.view.render("Authentication/login") + } + + app.routes.get("register") { request -> View in + if let message = request.query[String.self, at: "message"] { + return try await request.view.render("Authentication/register", RegisterContext(message: message)) + } else { + return try await request.view.render("Authentication/register") + } + } + + app.get("conduct") { req -> View in + return try await req.view.render("Secondary/conduct", HomeContext()) + } try app.routes.register(collection: AuthController()) // TODO: Split this out into web/api/admin try app.routes.register(collection: PushController()) @@ -47,19 +74,6 @@ func routes(_ app: Application) throws { try app.routes.register(collection: TicketHubRouteController()) try app.routes.register(collection: PurchaseRouteController()) - // MARK: - API Routes - - let apiRoutes = app.grouped("api", "v1") - try apiRoutes.grouped("sponsors").register(collection: SponsorAPIController()) - try apiRoutes.grouped("schedule").register(collection: ScheduleAPIController()) - try apiRoutes.grouped("local").register(collection: LocalAPIController()) - try apiRoutes.grouped("tickets").register(collection: TicketsAPIController()) - try apiRoutes.grouped("checkin").register(collection: CheckInAPIController()) - - let apiV2Routes = app.grouped("api", "v2") - try apiV2Routes.grouped("schedule").register(collection: ScheduleAPIControllerV2()) - try apiV2Routes.grouped("team").register(collection: TeamAPIController()) - // MARK: - Admin Routes let adminRoutes = app.grouped("admin").grouped(AdminMiddleware()) @@ -83,7 +97,10 @@ func routes(_ app: Application) throws { .with(\.$speaker) .with(\.$secondSpeaker) .all() - let events = try await Event.query(on: request.db).sort(\.$date).all() + let events = try await Event.query(on: request.db) + .filter(\.$conference == request.application.conference.rawValue) + .sort(\.$date) + .all() let jobs = try await Job.query(on: request.db).sort(\.$title) .with(\.$sponsor) .all()