Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions packages/dashboard/app/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createPortal } from 'react-dom'
import { ThemeToggle } from '~/components/ui/theme-toggle'
import { ColorThemePicker } from '~/components/ui/color-theme-picker'
import { cn } from '~/lib/utils'
import type { PublicDatabase } from '~/lib/types'
import {
Sidebar,
SidebarContent,
Expand All @@ -17,16 +18,9 @@ import {
useSidebar,
} from '~/components/ui/sidebar'

interface DatabaseConfig {
id: string
name: string
url: string
schema: string
}

interface RootLoaderData {
databases: DatabaseConfig[]
currentDb: DatabaseConfig
databases: PublicDatabase[]
currentDb: PublicDatabase
}

const navigation = [
Expand Down Expand Up @@ -107,9 +101,9 @@ function DatabaseSelector ({
currentDb,
onSelect,
}: {
databases: DatabaseConfig[]
currentDb: DatabaseConfig
onSelect: (db: DatabaseConfig) => void
databases: PublicDatabase[]
currentDb: PublicDatabase
onSelect: (db: PublicDatabase) => void
}) {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
Expand Down Expand Up @@ -218,7 +212,7 @@ export function AppSidebar () {
const currentDb = rootData?.currentDb
const dbParam = searchParams.get('db')

const handleDatabaseSelect = (db: DatabaseConfig) => {
const handleDatabaseSelect = (db: PublicDatabase) => {
const params = new URLSearchParams(searchParams)
if (db.id === databases[0]?.id) {
params.delete('db')
Expand Down
14 changes: 14 additions & 0 deletions packages/dashboard/app/lib/config.server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PublicDatabase } from './types'

// Multi-database configuration support
// Format: "name1=postgres://host1/db1|name2=postgres://host2/db2"
// Or simply: "postgres://host1/db1|postgres://host2/db2" (names derived from database)
Expand All @@ -9,6 +11,18 @@ export interface DatabaseConfig {
schema: string; // pg-boss schema
}

/**
* Strip a database configuration down to what the browser is allowed to see.
*
* `url` is a connection string with a password in it. Anything a loader
* returns is serialized into the SSR payload and readable in page source, so
* the projection has to happen before the value leaves the server, not in the
* component that renders it.
*/
export function toPublicDatabase ({ id, name, schema }: DatabaseConfig): PublicDatabase {
return { id, name, schema }
}

const SEPARATOR = '|'
const DEFAULT_SCHEMA = 'pgboss'

Expand Down
12 changes: 12 additions & 0 deletions packages/dashboard/app/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,15 @@ export interface ScheduleResult extends Schedule {
createdOn: Date;
updatedOn: Date;
}

// The subset of a database configuration that is safe to send to the browser.
// `DatabaseConfig` in config.server.ts also carries `url`, a connection string
// with a password in it, and that must never reach a loader payload — React
// Router serializes those into the HTML for hydration, where anyone who can
// load the page can read them. The schema name is not a credential and the
// database selector displays it, so it stays.
export interface PublicDatabase {
id: string;
name: string;
schema: string;
}
10 changes: 8 additions & 2 deletions packages/dashboard/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Breadcrumbs } from "~/components/breadcrumbs";
import { SidebarProvider, SidebarTrigger, useSidebar } from "~/components/ui/sidebar";
import { cn } from "~/lib/utils";
import { dbContext } from "~/lib/db-context";
import { toPublicDatabase } from "~/lib/config.server";

function MainContent ({ children }: { children: React.ReactNode }) {
const { open, isMobile, state } = useSidebar()
Expand Down Expand Up @@ -96,9 +97,14 @@ const themeScript = `

export async function loader({ context }: Route.LoaderArgs) {
const { databases, currentDb } = context.get(dbContext);

// Project before returning. The sidebar needs an id, a display name and the
// schema; it never needs the connection string. A loader's return value is
// serialized into the HTML for hydration, so returning the raw config puts
// every connection string, passwords included, in page source.
return {
databases,
currentDb,
databases: databases.map(toPublicDatabase),
currentDb: currentDb ? toPublicDatabase(currentDb) : currentDb,
};
}

Expand Down
95 changes: 95 additions & 0 deletions packages/dashboard/tests/server/root-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { RouterContextProvider } from 'react-router'
import { ctx } from './helpers'
import { dbContext } from '~/lib/db-context'
import { loader as rootLoader } from '~/root'

// A loader's return value is serialized into the HTML for hydration, so
// anything it returns is readable by anyone who can load the page. These
// connection strings carry passwords.
const PASSWORD = 'hunter2-should-never-reach-the-browser'

function contextWith (databases: Array<{ id: string, name: string, url: string, schema: string }>) {
const provider = new RouterContextProvider()

provider.set(dbContext, {
databases,
currentDb: databases[0],
DB_URL: databases[0].url,
SCHEMA: databases[0].schema,
})

return provider
}

const primary = {
id: 'primary',
name: 'Production',
url: `postgres://admin:${PASSWORD}@db.internal:5432/app`,
schema: 'pgboss',
}

const secondary = {
id: 'secondary',
name: 'Staging',
url: `postgres://admin:${PASSWORD}@stage.internal:5432/app`,
schema: 'jobs',
}

async function load (databases = [primary, secondary]) {
return rootLoader({
request: new Request('http://localhost/'),
context: contextWith(databases),
params: {},
} as Parameters<typeof rootLoader>[0])
}

describe('root loader', () => {
it('never sends a connection string to the browser', async () => {
const data = await load()

expect(JSON.stringify(data)).not.toContain(PASSWORD)
expect(JSON.stringify(data)).not.toContain('postgres://')
})

it('sends only the fields the sidebar renders', async () => {
const { databases, currentDb } = await load()

for (const db of [...databases, currentDb]) {
expect(Object.keys(db).sort()).toEqual(['id', 'name', 'schema'])
}
})

it('keeps the schema, which the database selector displays', async () => {
const { databases } = await load()

expect(databases.map((db) => db.schema)).toEqual(['pgboss', 'jobs'])
})

it('preserves identity and order so the selector still works', async () => {
const { databases, currentDb } = await load()

expect(databases.map((db) => db.id)).toEqual(['primary', 'secondary'])
expect(databases.map((db) => db.name)).toEqual(['Production', 'Staging'])
expect(currentDb).toEqual({ id: 'primary', name: 'Production', schema: 'pgboss' })
})

it('tolerates a missing current database rather than throwing', async () => {
const provider = new RouterContextProvider()

provider.set(dbContext, {
databases: [],
currentDb: undefined as never,
DB_URL: ctx.connectionString,
SCHEMA: ctx.schema,
})

const data = await rootLoader({
request: new Request('http://localhost/'),
context: provider,
params: {},
} as Parameters<typeof rootLoader>[0])

expect(data).toEqual({ databases: [], currentDb: undefined })
})
})