Skip to content

Commit

Permalink
Merge pull request #137 from gertrude-app/sync-public-keychains
Browse files Browse the repository at this point in the history
api: dump/sync pub keychains and app data to staging
  • Loading branch information
jaredh159 authored Jan 8, 2025
2 parents 7f1a530 + 93aeabd commit 59d988e
Show file tree
Hide file tree
Showing 11 changed files with 244 additions and 67 deletions.
3 changes: 3 additions & 0 deletions api/Sources/Api/Configure/jobs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@ public extension Configure {
app.queues.schedule(DiskSpaceJob()).hourly().at(0)

try app.queues.startScheduledJobs()

app.asyncCommands.use(ResetCommand(), as: "reset")
app.asyncCommands.use(SyncStagingDataCommand(), as: "sync-staging-data")
}
}
7 changes: 7 additions & 0 deletions api/Sources/Api/Configure/router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public extension Configure {
}
#endif

if app.env.mode != .staging {
app.get(
"dump-staging-data", ":token",
use: DumpStagingDataRoute.handler(_:)
)
}

app.get(
"releases",
use: ReleasesRoute.handler(_:)
Expand Down
4 changes: 4 additions & 0 deletions api/Sources/Api/Environment/EnvVars.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,8 @@ extension Env: TestDependencyKey {
public static var testValue: Env {
.fromProcess(mode: .testing)
}

func getUUID(_ key: String) -> UUID? {
self.get(key).flatMap(UUID.init(uuidString:))
}
}
6 changes: 6 additions & 0 deletions api/Sources/Api/Extend/Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ extension Request {
}
}

extension Parameters {
func getUUID(_ key: String) -> UUID? {
self.get("token").flatMap(UUID.init(uuidString:))
}
}

// helpers

private extension UUID {
Expand Down
15 changes: 13 additions & 2 deletions api/Sources/Api/Extend/XSlack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ extension XSlack.Slack.Client {

func sysLog(to channel: String = "info", _ message: String) async {
@Dependency(\.env) var env
@Dependency(\.logger) var logger

guard let token = env.get("SLACK_API_TOKEN"),
env.mode != .staging else {
return
Expand All @@ -31,8 +33,17 @@ extension XSlack.Slack.Client {
)

if let error = await send(slack, token) {
with(dependency: \.logger)
.error("Error sending slack: \(error)")
logger.error("Error sending slack: \(error)")
}

if channel == "errors" {
logger.error("Slack sysLog to #errors: \(message)")
} else {
logger.info("Slack sysLog to #info: \(message)")
}
}

func sysLogErr(_ message: String) async {
await self.sysLog(to: "errors", message)
}
}
9 changes: 7 additions & 2 deletions api/Sources/Api/Routes/Reset/AdminBetsy.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Dependencies
import DuetSQL
import Gertie
import Vapor
import XCore
Expand Down Expand Up @@ -58,12 +59,16 @@ enum AdminBetsy {
let (jimmy, sally, _) = try await createUsers(betsy)
let (musicTheory, misc) = try await createKeychains(betsy)

let firstPublicKeychain = try await Keychain.query()
.where(.isPublic == true)
.first(in: db)

try await db.create([
UserKeychain(userId: jimmy.id, keychainId: musicTheory.id),
UserKeychain(userId: jimmy.id, keychainId: misc.id),
UserKeychain(userId: jimmy.id, keychainId: Reset.Ids.htcKeychain),
UserKeychain(userId: jimmy.id, keychainId: firstPublicKeychain.id),
UserKeychain(userId: sally.id, keychainId: misc.id),
UserKeychain(userId: sally.id, keychainId: Reset.Ids.htcKeychain),
UserKeychain(userId: sally.id, keychainId: firstPublicKeychain.id),
])

try await self.createUserActivity()
Expand Down
49 changes: 12 additions & 37 deletions api/Sources/Api/Routes/Reset/Reset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,22 @@ import XCore
enum Reset {
static func run() async throws {
@Dependency(\.db) var db
try await db.delete(all: Admin.self)
try await self.createHtcPublicKeychain()
try await Admin.query()
.where(.id != Admin.Id.stagingPublicKeychainOwner)
.delete(in: db)
try await AdminBetsy.create()
}

static func createHtcPublicKeychain() async throws {
static func ensurePublicKeychainOwner() async throws {
@Dependency(\.db) var db
let jared = try await db.create(Admin(
email: "jared-htc-author" |> self.testEmail,
password: try Bcrypt.hash("jared123")
))
try await self.createKeychain(
id: Ids.htcKeychain,
adminId: jared.id,
name: "HTC",
isPublic: true,
description: "Keys for student's in Jared's How to Computer (HTC) class.",
keys: [
.anySubdomain(domain: .init("howtocomputer.link")!, scope: .unrestricted),
.anySubdomain(domain: .init("tailwind.css")!, scope: .webBrowsers),
.anySubdomain(domain: .init("vsassets.io")!, scope: .single(.identifiedAppSlug("vscode"))),
.anySubdomain(
domain: .init("executeprogram.com")!,
scope: .single(.bundleId("com.apple.Safari"))
),
.domain(domain: .init("friendslibrary.com")!, scope: .unrestricted),
.domain(domain: .init("developer.mozilla.org")!, scope: .webBrowsers),
.domain(domain: .init("nextjs.org")!, scope: .webBrowsers),
.domain(domain: .init("www.snowpack.dev")!, scope: .webBrowsers),
.domain(domain: .init("regexr.com")!, scope: .webBrowsers),
.domain(domain: .init("api.netlify.com")!, scope: .unrestricted),
.domain(domain: .init("registry.npmjs.org")!, scope: .single(.bundleId(".node"))),
.skeleton(scope: .identifiedAppSlug("slack")),
.skeleton(scope: .bundleId("Y48LQG59RS.com.sequelpro.SequelPro")),
.ipAddress(ipAddress: .init("76.88.114.31")!, scope: .unrestricted),
]
)
let existing = try? await db.find(Admin.Id.stagingPublicKeychainOwner)
if existing == nil {
try await db.create(Admin(
id: .stagingPublicKeychainOwner,
email: "public-keychain-owner" |> self.testEmail,
password: try Bcrypt.hash("\(UUID())")
))
}
}

@discardableResult
Expand Down Expand Up @@ -158,10 +137,6 @@ enum Reset {
))
}
}

enum Ids {
static let htcKeychain = Keychain.Id.from("AAA00000-0000-0000-0000-000000000000")
}
}

enum TimestampAdjustment {
Expand Down
21 changes: 20 additions & 1 deletion api/Sources/Api/Routes/Reset/ResetRoute.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
import Dependencies
import Vapor

struct ResetCommand: AsyncCommand {
struct Signature: CommandSignature {}
var help: String { "Reset staging data" }

func run(using context: CommandContext, signature: Signature) async throws {
guard get(dependency: \.env).mode != .prod else {
fatalError("`reset` is only allowed in non-prod environments")
}
try await Reset.run()
try await SyncStagingDataCommand().run(using: context, signature: .init())
}
}

enum ResetRoute {
@Sendable static func handler(_ request: Request) async throws -> Response {
guard request.env.mode != .prod else {
throw Abort(.notFound)
}

try await Reset.run()
try await SyncStagingDataCommand()
.exec(client: request.application.http.client.shared)
.mapError { Abort(.internalServerError, reason: $0.message) }
.get()

let betsy = try await request.context.db.find(AdminBetsy.Ids.betsy)

return .init(
status: .ok,
headers: ["Content-Type": "text/html"],
Expand All @@ -24,7 +43,7 @@ enum ResetRoute {
in <code>./dashboard/.env</code>
</p>
<pre style="background: #eaeaea; padding: 1em 1em 0 1em;">
SNOWPACK_PUBLIC_TEST_ADMIN_CREDS=\(betsy.id.lowercased):\(betsy.id.lowercased)
VITE_ADMIN_CREDS=\(betsy.id.lowercased):\(betsy.id.lowercased)
</pre>
<p>
...or use her email address:
Expand Down
121 changes: 121 additions & 0 deletions api/Sources/Api/Services/SyncStagingData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import AsyncHTTPClient
import Dependencies
import DuetSQL
import Foundation
import NIOFoundationCompat
import Vapor
import XCore

enum DumpStagingDataRoute {
@Sendable static func handler(_ request: Request) async throws -> some AsyncResponseEncodable {
@Dependency(\.db) var db
@Dependency(\.env) var env

guard let reqToken = request.parameters.getUUID("token"),
let envToken = env.getUUID("SYNC_STAGING_TOKEN"),
reqToken == envToken,
env.mode != .staging else {
throw Abort(.notFound)
}

var keychains = try await Keychain.query()
.where(.isPublic == true)
.all(in: db)
for i in 0 ..< keychains.count {
keychains[i].authorId = .stagingPublicKeychainOwner
}

return SyncStagingData(
keychains: keychains,
keys: try await Key.query()
.where(.keychainId |=| keychains.map(\.id))
.all(in: db),
appBundleIds: try await AppBundleId.query().all(in: db),
appCategories: try await AppCategory.query().all(in: db),
identifiedApps: try await IdentifiedApp.query().all(in: db)
)
}
}

struct SyncStagingDataCommand: AsyncCommand {
struct Signature: CommandSignature {}
var help: String { "Sync staging data" }

@Dependency(\.db) var db
@Dependency(\.env) var env
@Dependency(\.logger) var logger

func run(using context: CommandContext, signature: Signature) async throws {
switch await self.exec(client: context.application.http.client.shared) {
case .success:
self.logger.info("Synced staging data")
case .failure(let error):
self.logger.error("Error syncing staging data: \(error)")
}
}

func exec(client: HTTPClient) async -> Result<Void, StringError> {
guard self.env.mode != .prod, let token = env.getUUID("SYNC_STAGING_TOKEN") else {
return .failure("No SYNC_STAGING_TOKEN")
}

let dataResult = await fetchData(client, token)
guard case .success(let data) = dataResult else {
return dataResult.map { _ in () }
}

do {
try await self.db.delete(all: AppBundleId.self)
try await self.db.delete(all: IdentifiedApp.self)
try await self.db.delete(all: AppCategory.self)
try await self.db.create(data.appCategories)
try await self.db.create(data.identifiedApps)
try await self.db.create(data.appBundleIds)

try await Reset.ensurePublicKeychainOwner()
try await Keychain.query()
.where(.id |=| data.keychains.map(\.id))
.delete(in: self.db)
try await self.db.create(data.keychains)
try await self.db.create(data.keys)
return .success(())
} catch {
return .failure("Error saving staging data: \(error)")
}
}

func fetchData(
_ client: HTTPClient,
_ token: UUID
) async -> Result<SyncStagingData, StringError> {
do {
let request =
HTTPClientRequest(url: "https://api.gertrude.app/dump-staging-data/\(token.lowercased)")
let response = try await client.execute(request, timeout: .seconds(5))
guard response.status == .ok else {
return .failure("Unexpected status syncing staging data: \(response.status)")
}
let buf = try await response.body.collect(upTo: 1024 * 1024) // 1 MB
guard let json = buf.getString(at: 0, length: buf.readableBytes, encoding: .utf8) else {
return .failure("Error converting buffer to string")
}
let data = try JSON.decode(json, as: SyncStagingData.self, [.isoDates])
return .success(data)
} catch {
return .failure("Error syncing staging data: \(error)")
}
}
}

struct SyncStagingData: Content {
var keychains: [Keychain]
var keys: [Key]
var appBundleIds: [AppBundleId]
var appCategories: [AppCategory]
var identifiedApps: [IdentifiedApp]
}

extension Admin.Id {
static let stagingPublicKeychainOwner =
Self(UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")!)
}
Loading

0 comments on commit 59d988e

Please sign in to comment.