Skip to content
Open
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
11 changes: 9 additions & 2 deletions packages/bridge/client/accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import { sessionToken } from 'cordis-webui-solidjs/session'
import {
copyText,
formatPhone,
formatServerConfig,
parseTelegramLoginUrl,
remainingSeconds,
safeImageURL,
sameOriginPath,
serializeServerConfig,
} from './bridge-model.js'
export default function AccountsPage(props: PageProps) {
const rpc = useRpc<PlatformAccountDashboardData>(props.entryId),
Expand All @@ -55,8 +57,13 @@ export default function AccountsPage(props: PageProps) {
),
)
const ids = createMemo(() => [...accounts().keys()])
// The page shows the readable document, but the clipboard has to stay on a single
// line: Android clients reject a pasted pretty-printed configuration as invalid JSON.
const configuration = createMemo(() =>
JSON.stringify(rpc.data.serverConfig, null, 2),
formatServerConfig(rpc.data.serverConfig),
)
const clipboardConfiguration = createMemo(() =>
serializeServerConfig(rpc.data.serverConfig),
)
const [search, setSearch] = createSignal(''),
[limit, setLimit] = createSignal(24)
Expand Down Expand Up @@ -107,7 +114,7 @@ export default function AccountsPage(props: PageProps) {
disabled={!rpc.ready || !rpc.data.serverConfig}
onClick={() =>
void action.run(async () => {
await copyText(configuration()!)
await copyText(clipboardConfiguration()!)
setCopied(true)
})
}
Expand Down
40 changes: 40 additions & 0 deletions packages/bridge/client/bridge-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CrossGramServerConfig } from '../src/dashboard-types.js'
import {
botLink,
copyText,
formatPhone,
formatServerConfig,
parseTelegramLoginUrl,
remainingSeconds,
safeImageURL,
sameOriginPath,
serializeServerConfig,
} from './bridge-model.js'
afterEach(() => vi.restoreAllMocks())
describe('bridge dashboard input boundaries', () => {
Expand Down Expand Up @@ -53,3 +56,40 @@ describe('bridge dashboard input boundaries', () => {
await expect(copyText('credential')).rejects.toThrow('permission denied')
})
})
describe('server configuration copying', () => {
const config: CrossGramServerConfig = {
name: 'CrossGram',
enable_special_config: false,
host: 'relay.example.test',
port: 4430,
rsa_key:
'-----BEGIN RSA PUBLIC KEY-----\nPLACEHOLDER_KEY\n-----END RSA PUBLIC KEY-----',
dcs: Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
ip: 'relay.example.test',
port: 4430,
})),
}
// Android clients reject the pasted configuration as invalid JSON while it contains line
// breaks, and import the identical document once it is a single line.
it('copies a single-line document without losing fields or escaping PEM newlines', () => {
const copied = serializeServerConfig(config)!
expect(copied).not.toMatch(/[\r\n\u2028\u2029]/)
expect(copied.trim()).toBe(copied)
expect(copied).toBe(JSON.stringify(config))
expect(copied).toContain('\\n')
expect(JSON.parse(copied)).toEqual(config)
expect(JSON.parse(copied).rsa_key).toContain('\n')
expect(JSON.parse(copied).dcs).toHaveLength(5)
})
it('keeps the readable multi-line form for on-page display only', () => {
const displayed = formatServerConfig(config)!
expect(displayed.split('\n').length).toBeGreaterThan(1)
expect(displayed.length).toBeGreaterThan(serializeServerConfig(config)!.length)
expect(JSON.parse(displayed)).toEqual(JSON.parse(serializeServerConfig(config)!))
})
it('treats a missing configuration as nothing to copy or display', () => {
expect(serializeServerConfig(undefined)).toBeUndefined()
expect(formatServerConfig(undefined)).toBeUndefined()
})
})
18 changes: 18 additions & 0 deletions packages/bridge/client/bridge-model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CrossGramServerConfig } from '../src/dashboard-types.js'
export function parseTelegramLoginUrl(value: string): string | undefined {
try {
const url = new URL(value),
Expand Down Expand Up @@ -60,5 +61,22 @@ export function formatPhone(value?: string): string {
return '+888 ' + digits.slice(3).replace(/(\d)(?=(\d{3})+$)/g, '$1 ')
return '+' + digits.replace(/(\d)(?=(\d{3})+$)/g, '$1 ')
}
/**
* Clipboard form of the server configuration. Android clients report "invalid JSON" when
* the configuration is pasted with line breaks and import the identical document once the
* line breaks are removed, so the copy button emits exactly one line, with the same fields
* and values as the readable form rendered on the page.
*/
export function serializeServerConfig(
config: CrossGramServerConfig | undefined,
): string | undefined {
return config ? JSON.stringify(config) : undefined
}
/** Readable form of the server configuration, used for on-page display only. */
export function formatServerConfig(
config: CrossGramServerConfig | undefined,
): string | undefined {
return config ? JSON.stringify(config, null, 2) : undefined
}

export { sameOriginPath, copyText } from "cordis-webui-solidjs/utils"
37 changes: 33 additions & 4 deletions packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
enable_special_config: false,
host: 'example.test',
port: 4430,
rsa_key: 'PUBLIC_TEST_KEY',
rsa_key:
'-----BEGIN RSA PUBLIC KEY-----\nPUBLIC_TEST_KEY\n-----END RSA PUBLIC KEY-----',
dcs: [],
},
loginTokenApprovalUrl: '/bridge/login-tokens',
Expand Down Expand Up @@ -237,9 +238,37 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => {
await page
.getByRole('button', { name: 'Copy server configuration', exact: true })
.click()
expect(
JSON.parse(await page.evaluate(() => navigator.clipboard.readText())),
).toMatchObject({ host: 'example.test', rsa_key: 'PUBLIC_TEST_KEY' })
const copiedConfiguration = await page.evaluate(() =>
navigator.clipboard.readText(),
)
// The pasted value reaches the client's single-line import path, which answers
// "invalid JSON" once the document carries line breaks, so the copy must stay on one line.
expect(copiedConfiguration).not.toMatch(/[\r\n\u2028\u2029]/)
expect(copiedConfiguration.trim()).toBe(copiedConfiguration)
expect(JSON.parse(copiedConfiguration)).toEqual(data.serverConfig)
expect(copiedConfiguration).toContain('PUBLIC_TEST_KEY')
// Phones open the console over plain http, where navigator.clipboard is missing and
// copyText falls back to a hidden textarea; that path must produce the same single line.
await page.evaluate(() =>
Object.defineProperty(navigator.clipboard, 'writeText', {
value: undefined,
configurable: true,
}),
)
await page
.getByRole('button', { name: 'Configuration copied', exact: true })
.click()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
copiedConfiguration,
)
// The readable, indented document is still what the page displays.
const displayedConfiguration = await page
.getByLabel('Server configuration')
.textContent()
expect(displayedConfiguration).toContain('\n')
expect(JSON.parse(displayedConfiguration!)).toEqual(
JSON.parse(copiedConfiguration),
)
await page
.getByRole('button', { name: 'Approve QR login', exact: true })
.click()
Expand Down