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
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import React, { useState, useCallback } from 'react'
import { JSONTree } from 'react-json-tree'
import Button from 'react-bootstrap/Button'
import Card from 'react-bootstrap/Card'
import Col from 'react-bootstrap/Col'
import Form from 'react-bootstrap/Form'
import Row from 'react-bootstrap/Row'
import Spinner from 'react-bootstrap/Spinner'
import { useLoginStatus } from '../../store'

interface VersionData {
version: string
data: unknown
}

interface AppDataResult {
global: VersionData[]
user: VersionData[]
}

const expandAll = () => true
const expandRoot = (
_keyPath: readonly (string | number)[],
_data: unknown,
level: number
) => level < 1

const ApplicationDataBrowser: React.FC = () => {
const [appId, setAppId] = useState('')
const [expanded, setExpanded] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<AppDataResult | null>(null)
const [expandAllNodes, setExpandAllNodes] = useState(false)
const loginStatus = useLoginStatus()

const isLoggedIn = loginStatus.status === 'loggedIn'

const fetchData = useCallback(async () => {
const trimmed = appId.trim()
if (!trimmed) return

setLoading(true)
setError(null)
setResult(null)

try {
const fetched: AppDataResult = { global: [], user: [] }

// Fetch global versions
const globalVersionsRes = await fetch(
`/signalk/v1/applicationData/global/${encodeURIComponent(trimmed)}`,
{ credentials: 'include' }
)
if (globalVersionsRes.ok) {
const versions: string[] = await globalVersionsRes.json()
for (const version of versions) {
const dataRes = await fetch(
`/signalk/v1/applicationData/global/${encodeURIComponent(trimmed)}/${encodeURIComponent(version)}`,
{ credentials: 'include' }
)
if (dataRes.ok) {
fetched.global.push({ version, data: await dataRes.json() })
}
}
}

// Fetch user versions (only if logged in)
if (isLoggedIn) {
const userVersionsRes = await fetch(
`/signalk/v1/applicationData/user/${encodeURIComponent(trimmed)}`,
{ credentials: 'include' }
)
if (userVersionsRes.ok) {
const versions: string[] = await userVersionsRes.json()
for (const version of versions) {
const dataRes = await fetch(
`/signalk/v1/applicationData/user/${encodeURIComponent(trimmed)}/${encodeURIComponent(version)}`,
{ credentials: 'include' }
)
if (dataRes.ok) {
fetched.user.push({ version, data: await dataRes.json() })
}
}
}
}

setResult(fetched)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to fetch')
} finally {
setLoading(false)
}
}, [appId, isLoggedIn])

const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
fetchData()
},
[fetchData]
)

const hasGlobal = result && result.global.length > 0
const hasUser = result && result.user.length > 0
const hasNoData = result && !hasGlobal && !hasUser

return (
<Card>
<Card.Header
style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => setExpanded((prev) => !prev)}
>
Application Data {expanded ? '[-]' : '[+]'}
</Card.Header>
{expanded && (
<Card.Body>
<Form onSubmit={handleSubmit}>
<Form.Group as={Row} className="mb-3">
<Col xs="12" md="6">
<Form.Control
type="text"
placeholder="Enter application ID (e.g. unitpreferences)"
value={appId}
onChange={(e) => setAppId(e.target.value)}
autoComplete="off"
/>
</Col>
<Col xs="12" md="2">
<Button
variant="primary"
type="submit"
disabled={loading || !appId.trim()}
>
{loading ? <Spinner animation="border" size="sm" /> : 'Fetch'}
</Button>
</Col>
</Form.Group>
</Form>

{(hasGlobal || hasUser) && (
<Form.Check
type="checkbox"
id="appdata-expand-all"
label="Expand all"
checked={expandAllNodes}
onChange={(e) => setExpandAllNodes(e.target.checked)}
className="mb-2"
/>
)}

{error && <div className="text-danger mb-2">{error}</div>}

{hasNoData && (
<div className="text-muted">
No data found for &quot;{appId.trim()}&quot;
</div>
)}

{hasGlobal && (
<div className="mb-3">
<h6>Global</h6>
{result.global.map(({ version, data }) => (
<div key={version} className="mb-2">
<strong>{version}</strong>
<JSONTree
key={`global-${version}-${expandAllNodes}`}
data={data}
theme="default"
invertTheme={true}
sortObjectKeys
hideRoot
shouldExpandNodeInitially={
expandAllNodes ? expandAll : expandRoot
}
/>
</div>
))}
</div>
)}

{hasUser && (
<div>
<h6>User ({loginStatus.username})</h6>
{result.user.map(({ version, data }) => (
<div key={version} className="mb-2">
<strong>{version}</strong>
<JSONTree
key={`user-${version}-${expandAllNodes}`}
data={data}
theme="default"
invertTheme={true}
sortObjectKeys
hideRoot
shouldExpandNodeInitially={
expandAllNodes ? expandAll : expandRoot
}
/>
</div>
))}
</div>
)}
</Card.Body>
)}
</Card>
)
}

export default ApplicationDataBrowser
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import VirtualizedDataTable from './VirtualizedDataTable'
import type { PathData, MetaData } from '../../store'
import granularSubscriptionManager from './GranularSubscriptionManager'
import { getPath$SourceKey } from './pathUtils'
import ApplicationDataBrowser from './ApplicationDataBrowser'
import {
useWebSocket,
useDeltaMessages,
Expand Down Expand Up @@ -716,6 +717,8 @@ const DataBrowser: React.FC = () => {
)}
</Card>
)}

<ApplicationDataBrowser />
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface ServerSettingsData {
runFromSystemd?: boolean
options?: Record<string, boolean>
interfaces?: Record<string, boolean>
anonymousApplicationDataAccess?: string
pruneContextsMinutes?: string
loggingDirectory?: string
keepMostRecentLogsOnly?: boolean
Expand Down Expand Up @@ -57,10 +58,14 @@ const ServerSettings: React.FC = () => {
}, [fetchSettings])

const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
(
event: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const value =
event.target.type === 'checkbox'
? event.target.checked
? (event.target as HTMLInputElement).checked
: event.target.value
setSettings((prev) => ({ ...prev, [event.target.name]: value }))
},
Expand Down Expand Up @@ -280,6 +285,30 @@ const ServerSettings: React.FC = () => {
})}
</Col>
</Form.Group>
<Form.Group as={Row}>
<Col md="2">
<Form.Label htmlFor="anonymousApplicationDataAccess">
Anonymous Application Data Access
</Form.Label>
</Col>
<Col xs="12" md={fieldColWidthMd}>
<Form.Select
id="anonymousApplicationDataAccess"
name="anonymousApplicationDataAccess"
onChange={handleChange}
value={settings.anonymousApplicationDataAccess || 'none'}
style={{ width: 'auto' }}
>
<option value="none">No access</option>
<option value="readonly">Read Only</option>
<option value="readwrite">Read &amp; Write</option>
</Form.Select>
<Form.Text muted>
Allow unauthenticated access to global application data.
Requires restart.
</Form.Text>
</Col>
</Form.Group>
<Form.Group as={Row}>
<Col md="2">
<Form.Label htmlFor="pruneContextsMinutes">
Expand Down
1 change: 1 addition & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export interface Config {
loggingDirectory?: string
sourcePriorities?: any
trustProxy?: boolean | string | number
anonymousApplicationDataAccess?: 'none' | 'readonly' | 'readwrite'
courseApi?: {
apiOnly?: boolean
}
Expand Down
73 changes: 64 additions & 9 deletions src/interfaces/applicationData.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,21 @@ const userApplicationDataUrls = [
]

module.exports = function (app) {
function requireAdmin(req, res, next) {
if (
req.skIsAuthenticated === true &&
req.skPrincipal &&
req.skPrincipal.permissions === 'admin'
) {
return next()
}
res.status(401).json({ error: 'Permission Denied' })
}

const anonAccess = app.config.settings.anonymousApplicationDataAccess

if (app.securityStrategy.isDummy()) {
debug('ApplicationData disabled because security is off')
debug('ApplicationData: security is off, writes are disabled')

app.post(userApplicationDataUrls, (req, res) => {
res.status(405).send('security is not enabled')
Expand All @@ -78,16 +91,58 @@ module.exports = function (app) {
res.status(405).send('security is not enabled')
})

return
}
if (anonAccess !== 'readonly' && anonAccess !== 'readwrite') {
applicationDataUrls.forEach((url) => {
app.get(url, (req, res) => {
res.status(403).send('anonymous application data access is disabled')
})
})
app.get(`${prefix}/global/:appid`, (req, res) => {
res.status(403).send('anonymous application data access is disabled')
})
}
} else {

// rejectAnonymous blocks the AUTO readonly principal that http_authorize
// creates when allow_readonly is true, so applicationData can enforce its
// own anonymous-access policy independently.
function rejectAnonymous(req, res, next) {
if (
req.skIsAuthenticated === true &&
req.skPrincipal &&
req.skPrincipal.identifier !== 'AUTO'
) {
return next()
}
res.status(401).json({ error: 'Permission Denied' })
}

applicationDataUrls.forEach((url) => {
app.securityStrategy.addAdminWriteMiddleware(url)
})
const globalAllUrls = [...applicationDataUrls, `${prefix}/global/:appid`]

userApplicationDataUrls.forEach((url) => {
app.securityStrategy.addWriteMiddleware(url)
})
if (anonAccess === 'readonly' || anonAccess === 'readwrite') {
// Allow anonymous access to global applicationData.
// Only restrict writes when readonly.
if (anonAccess === 'readonly') {
applicationDataUrls.forEach((url) => {
app.post(url, requireAdmin)
app.put(url, requireAdmin)
})
}
} else {
// No anonymous access: block the AUTO readonly principal on all global
// applicationData routes, then apply normal admin-write middleware.
globalAllUrls.forEach((url) => {
app.use(url, rejectAnonymous)
})
applicationDataUrls.forEach((url) => {
app.securityStrategy.addAdminWriteMiddleware(url)
})
}

userApplicationDataUrls.forEach((url) => {
app.securityStrategy.addWriteMiddleware(url)
})
}

app.get(userApplicationDataUrls, (req, res) => {
getApplicationData(req, res, true)
Expand Down
3 changes: 3 additions & 0 deletions src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@ export interface WithSecurityStrategy {
securityStrategy: SecurityStrategy
}

export type AnonymousApplicationDataAccess = 'none' | 'readonly' | 'readwrite'

export interface LoginStatusResponse {
status: string // 'loggedIn' 'notLoggedIn'
readOnlyAccess?: boolean
authenticationRequired?: boolean
allowNewUserRegistration?: boolean
allowDeviceAccessRequests?: boolean
anonymousApplicationDataAccess?: AnonymousApplicationDataAccess
userLevel?: any
username?: string
}
Expand Down
Loading
Loading