Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
58
.woodpecker/ci.yml
Normal file
58
.woodpecker/ci.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
steps:
|
||||||
|
- name: lint
|
||||||
|
image: node:24-slim
|
||||||
|
depends_on: []
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm run lint
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
image: node:24-slim
|
||||||
|
depends_on: []
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm run test
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
- name: e2e
|
||||||
|
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||||
|
depends_on: []
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- ./scripts/gen-dev-cert.sh
|
||||||
|
- npm run test:e2e
|
||||||
|
environment:
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
- name: docker-build
|
||||||
|
image: woodpeckerci/plugin-kaniko
|
||||||
|
depends_on: []
|
||||||
|
settings:
|
||||||
|
repo: git.keligrubb.com/${CI_REPO_OWNER}/${CI_REPO_NAME}
|
||||||
|
registry: git.keligrubb.com
|
||||||
|
tags: latest
|
||||||
|
dry-run: true
|
||||||
|
single-snapshot: true
|
||||||
|
cleanup: true
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
- name: docker-build-push
|
||||||
|
image: woodpeckerci/plugin-kaniko
|
||||||
|
settings:
|
||||||
|
repo: git.keligrubb.com/${CI_REPO_OWNER}/${CI_REPO_NAME}
|
||||||
|
registry: git.keligrubb.com
|
||||||
|
tags: latest,${CI_COMMIT_SHA:0:7}
|
||||||
|
username: ${CI_REPO_OWNER}
|
||||||
|
password:
|
||||||
|
from_secret: gitea_registry_token
|
||||||
|
single-snapshot: true
|
||||||
|
cleanup: true
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
# Build stage
|
FROM node:24-slim AS builder
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Run stage
|
# Run stage
|
||||||
FROM node:22-alpine AS runner
|
FROM node:24-slim AS runner
|
||||||
|
|
||||||
# Run as non-root user (node user exists in official image)
|
# Run as non-root user (node user exists in official image)
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -22,7 +22,7 @@ Open http://localhost:3000. The app requires login by default; you will see the
|
|||||||
|
|
||||||
Camera and geolocation in the browser require a **secure context** (HTTPS) when you open the app from your phone. To test Share live from a device on your LAN without buying a domain or cert:
|
Camera and geolocation in the browser require a **secure context** (HTTPS) when you open the app from your phone. To test Share live from a device on your LAN without buying a domain or cert:
|
||||||
|
|
||||||
1. Generate a self-signed cert (once). Use your machine’s LAN IP so the phone can use it:
|
1. Generate a self-signed cert (once). Use your machine's LAN IP so the phone can use it:
|
||||||
```bash
|
```bash
|
||||||
chmod +x scripts/gen-dev-cert.sh
|
chmod +x scripts/gen-dev-cert.sh
|
||||||
./scripts/gen-dev-cert.sh 192.168.1.123
|
./scripts/gen-dev-cert.sh 192.168.1.123
|
||||||
@@ -34,7 +34,7 @@ Camera and geolocation in the browser require a **secure context** (HTTPS) when
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser’s “untrusted certificate” warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
|
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's “untrusted certificate” warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
|
||||||
|
|
||||||
Without the certs, `npm run dev` still runs over HTTP as before.
|
Without the certs, `npm run dev` still runs over HTTP as before.
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ The **Share live** feature uses WebRTC for real-time video streaming from mobile
|
|||||||
- **Mediasoup** server (runs automatically in the Nuxt process)
|
- **Mediasoup** server (runs automatically in the Nuxt process)
|
||||||
- **mediasoup-client** (browser library, included automatically)
|
- **mediasoup-client** (browser library, included automatically)
|
||||||
|
|
||||||
**Streaming from a phone on your LAN:** The server auto-detects your machine’s LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000–49999 on the server.
|
**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000–49999 on the server.
|
||||||
|
|
||||||
See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
|
See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
|
||||||
|
|
||||||
@@ -62,10 +62,10 @@ See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- **Feeds**: Edit `server/data/feeds.json` to add cameras/feeds. Each feed needs `id`, `name`, `lat`, `lng`, `streamUrl`, and `sourceType` (`mjpeg` or `hls`). Home Assistant and other sources use the same shape; use proxy URLs for HA.
|
- **Devices**: Manage cameras/devices via the API (`/api/devices`) or the Members/Cameras UI. Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
|
||||||
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
|
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
|
||||||
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples.
|
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples.
|
||||||
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don’t set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
|
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
|
||||||
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
|
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
@@ -85,8 +85,8 @@ Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/re
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Feed list is validated server-side (`getValidFeeds`); only valid entries are returned.
|
- Device data is validated server-side; only valid entries are returned.
|
||||||
- Stream URLs are treated as untrusted; the UI only uses `http://` or `https://` URLs for display.
|
- Stream URLs are sanitized to `http://` or `https://` only; other protocols are rejected.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -214,10 +214,6 @@
|
|||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
feeds: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
devices: {
|
devices: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -382,8 +378,7 @@ function updateMarkers() {
|
|||||||
if (m) m.remove()
|
if (m) m.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
const feedSources = [...(props.feeds || []), ...(props.devices || [])]
|
const validSources = (props.devices || []).filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
|
||||||
const validSources = feedSources.filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
|
|
||||||
markersRef.value = validSources.map(item =>
|
markersRef.value = validSources.map(item =>
|
||||||
L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)),
|
L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)),
|
||||||
)
|
)
|
||||||
@@ -622,7 +617,7 @@ onBeforeUnmount(() => {
|
|||||||
destroyMap()
|
destroyMap()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => [props.feeds, props.devices], () => updateMarkers(), { deep: true })
|
watch(() => props.devices, () => updateMarkers(), { deep: true })
|
||||||
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
||||||
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export function useUser() {
|
export function useUser() {
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
const requestFetch = useRequestFetch()
|
const requestFetch = useRequestFetch()
|
||||||
const { data: user, refresh } = useAsyncData(
|
const { data: user, refresh } = useAsyncData(
|
||||||
'user',
|
'user',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="relative h-2/3 w-full md:h-full md:flex-1">
|
<div class="relative h-2/3 w-full md:h-full md:flex-1">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<KestrelMap
|
<KestrelMap
|
||||||
:feeds="[]"
|
|
||||||
:devices="devices ?? []"
|
:devices="devices ?? []"
|
||||||
:pois="pois ?? []"
|
:pois="pois ?? []"
|
||||||
:live-sessions="liveSessions ?? []"
|
:live-sessions="liveSessions ?? []"
|
||||||
|
|||||||
@@ -421,8 +421,11 @@ const deleteConfirmUser = ref(null)
|
|||||||
|
|
||||||
function setDropdownWrapRef(userId, el) {
|
function setDropdownWrapRef(userId, el) {
|
||||||
if (el) dropdownWrapRefs.value[userId] = el
|
if (el) dropdownWrapRefs.value[userId] = el
|
||||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
else {
|
||||||
else delete dropdownWrapRefs.value[userId]
|
dropdownWrapRefs.value = Object.fromEntries(
|
||||||
|
Object.entries(dropdownWrapRefs.value).filter(([k]) => k !== userId),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(user, (u) => {
|
watch(user, (u) => {
|
||||||
@@ -484,8 +487,9 @@ async function saveRole(id) {
|
|||||||
try {
|
try {
|
||||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body: { role } })
|
await $fetch(`/api/users/${id}`, { method: 'PATCH', body: { role } })
|
||||||
await refreshUsers()
|
await refreshUsers()
|
||||||
const { [id]: _, ...rest } = pendingRoleUpdates.value
|
pendingRoleUpdates.value = Object.fromEntries(
|
||||||
pendingRoleUpdates.value = rest
|
Object.entries(pendingRoleUpdates.value).filter(([k]) => k !== id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// could set error state
|
// could set error state
|
||||||
|
|||||||
@@ -1,48 +1,5 @@
|
|||||||
import { createConfigForNuxt } from '@nuxt/eslint-config/flat'
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
export default createConfigForNuxt({
|
export default withNuxt(
|
||||||
features: {
|
// Optional: custom rule overrides can go here
|
||||||
tooling: true,
|
)
|
||||||
stylistic: true,
|
|
||||||
},
|
|
||||||
}).prepend({
|
|
||||||
files: ['**/*.{js,vue}'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
defineAppConfig: 'readonly',
|
|
||||||
defineNuxtConfig: 'readonly',
|
|
||||||
useFetch: 'readonly',
|
|
||||||
defineEventHandler: 'readonly',
|
|
||||||
useAsyncData: 'readonly',
|
|
||||||
defineNuxtRouteMiddleware: 'readonly',
|
|
||||||
defineNuxtPlugin: 'readonly',
|
|
||||||
useUser: 'readonly',
|
|
||||||
useRoute: 'readonly',
|
|
||||||
useRouter: 'readonly',
|
|
||||||
navigateTo: 'readonly',
|
|
||||||
createError: 'readonly',
|
|
||||||
clearNuxtData: 'readonly',
|
|
||||||
ref: 'readonly',
|
|
||||||
computed: 'readonly',
|
|
||||||
onMounted: 'readonly',
|
|
||||||
onBeforeUnmount: 'readonly',
|
|
||||||
nextTick: 'readonly',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
files: ['server/**/*.js'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
defineEventHandler: 'readonly',
|
|
||||||
createError: 'readonly',
|
|
||||||
readBody: 'readonly',
|
|
||||||
setResponseStatus: 'readonly',
|
|
||||||
getCookie: 'readonly',
|
|
||||||
setCookie: 'readonly',
|
|
||||||
deleteCookie: 'readonly',
|
|
||||||
getQuery: 'readonly',
|
|
||||||
getRequestURL: 'readonly',
|
|
||||||
sendRedirect: 'readonly',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const devCert = join(_dirname, '.dev-certs', 'cert.pem')
|
|||||||
const useDevHttps = existsSync(devKey) && existsSync(devCert)
|
const useDevHttps = existsSync(devKey) && existsSync(devCert)
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss', '@nuxt/test-utils/module', '@nuxt/icon'],
|
modules: ['@nuxtjs/tailwindcss', '@nuxt/test-utils/module', '@nuxt/icon', '@nuxt/eslint'],
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
@@ -55,4 +55,10 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
eslint: {
|
||||||
|
config: {
|
||||||
|
tooling: true,
|
||||||
|
stylistic: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
3907
package-lock.json
generated
3907
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -20,7 +20,6 @@
|
|||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"hls.js": "^1.5.0",
|
"hls.js": "^1.5.0",
|
||||||
"idb": "^8.0.0",
|
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.offline": "^3.2.0",
|
"leaflet.offline": "^3.2.0",
|
||||||
"mediasoup": "^3.19.14",
|
"mediasoup": "^3.19.14",
|
||||||
@@ -33,14 +32,16 @@
|
|||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint-config": "^0.7.0",
|
"@nuxt/eslint": "^1.15.0",
|
||||||
"@nuxt/test-utils": "^3.14.0",
|
"@nuxt/test-utils": "^4.0.0",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^4.0.0",
|
||||||
"@vue/test-utils": "^2.4.0",
|
"@vue/test-utils": "^2.4.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-vue": "^9.0.0",
|
"happy-dom": "^20.6.1",
|
||||||
"happy-dom": "^15.0.0",
|
"vitest": "^4.0.0"
|
||||||
"vitest": "^3.0.0"
|
},
|
||||||
|
"overrides": {
|
||||||
|
"tar": "^7.5.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default defineConfig({
|
|||||||
name: 'desktop-chrome',
|
name: 'desktop-chrome',
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
|
permissions: ['camera', 'microphone', 'geolocation'],
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: [
|
args: [
|
||||||
'--use-fake-ui-for-media-stream',
|
'--use-fake-ui-for-media-stream',
|
||||||
@@ -52,10 +53,10 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run dev',
|
||||||
url: 'https://localhost:3000/health',
|
url: 'https://localhost:3000/health/ready',
|
||||||
reuseExistingServer: true, // Always reuse existing server for E2E tests
|
reuseExistingServer: !process.env.CI, // Don't reuse in CI (always start fresh)
|
||||||
timeout: 120 * 1000, // 2 minutes for server startup
|
timeout: 180_000, // 3 minutes (180 seconds) for server startup (CI can be slower)
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
},
|
},
|
||||||
timeout: 60 * 1000, // 60 seconds per test (WebRTC setup takes time)
|
timeout: process.env.CI ? 180_000 : 60_000, // 3 minutes in CI, 1 minute locally (WebRTC setup takes time)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const url = getRequestURL(event)
|
const url = getRequestURL(event)
|
||||||
const requestHost = url.hostname
|
const requestHost = url.hostname
|
||||||
const router = await getRouter(sessionId)
|
const router = await getRouter(sessionId)
|
||||||
const { transport, params } = await createTransport(router, Boolean(isProducer), requestHost)
|
const { transport, params } = await createTransport(router, requestHost)
|
||||||
|
|
||||||
if (isProducer) {
|
if (isProducer) {
|
||||||
updateLiveSession(sessionId, {
|
updateLiveSession(sessionId, {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { getDb, closeDb } from '../utils/db.js'
|
import { getDb, closeDb } from '../utils/db.js'
|
||||||
import { migrateFeedsToDevices } from '../utils/migrateFeedsToDevices.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize DB (and run bootstrap if no users) at server startup
|
* Initialize DB at server startup.
|
||||||
* so credentials are printed in the terminal before any request.
|
|
||||||
* Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown.
|
* Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
export default defineNitroPlugin((nitroApp) => {
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
void getDb().then(() => migrateFeedsToDevices())
|
void getDb()
|
||||||
nitroApp.hooks.hook('close', () => {
|
nitroApp.hooks.hook('close', () => {
|
||||||
closeDb()
|
closeDb()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export function broadcastToSession(sessionId, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
export default defineNitroPlugin((nitroApp) => {
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
nitroApp.hooks.hook('ready', async () => {
|
nitroApp.hooks.hook('ready', async () => {
|
||||||
const server = nitroApp.h3App.server || nitroApp.h3App.nodeServer
|
const server = nitroApp.h3App.server || nitroApp.h3App.nodeServer
|
||||||
|
|||||||
29
server/utils/bootstrap.js
vendored
Normal file
29
server/utils/bootstrap.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { hashPassword } from './password.js'
|
||||||
|
|
||||||
|
const DEFAULT_ADMIN_IDENTIFIER = 'admin'
|
||||||
|
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
||||||
|
|
||||||
|
const generateRandomPassword = () => {
|
||||||
|
const bytes = randomBytes(14)
|
||||||
|
return Array.from(bytes, b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapAdmin(run, get) {
|
||||||
|
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||||
|
if (row?.n !== 0) return
|
||||||
|
|
||||||
|
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||||
|
const password = process.env.BOOTSTRAP_PASSWORD
|
||||||
|
const identifier = (email && password) ? email : DEFAULT_ADMIN_IDENTIFIER
|
||||||
|
const plainPassword = (email && password) ? password : generateRandomPassword()
|
||||||
|
|
||||||
|
await run(
|
||||||
|
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,38 +2,27 @@ import { join } from 'node:path'
|
|||||||
import { mkdirSync, existsSync } from 'node:fs'
|
import { mkdirSync, existsSync } from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { bootstrapAdmin } from './bootstrap.js'
|
||||||
import { hashPassword } from './password.js'
|
|
||||||
|
|
||||||
const DEFAULT_ADMIN_IDENTIFIER = 'admin'
|
|
||||||
const DEFAULT_PASSWORD_LENGTH = 14
|
|
||||||
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
|
||||||
|
|
||||||
function generateRandomPassword() {
|
|
||||||
const bytes = randomBytes(DEFAULT_PASSWORD_LENGTH)
|
|
||||||
let s = ''
|
|
||||||
for (let i = 0; i < DEFAULT_PASSWORD_LENGTH; i++) {
|
|
||||||
s += PASSWORD_CHARS[bytes[i] % PASSWORD_CHARS.length]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
const sqlite3 = require('sqlite3')
|
const sqlite3 = require('sqlite3')
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 2
|
||||||
|
const DB_BUSY_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
let dbInstance = null
|
let dbInstance = null
|
||||||
/** Set by tests to use :memory: or a temp path */
|
|
||||||
let testPath = null
|
let testPath = null
|
||||||
|
|
||||||
const USERS_SQL = `CREATE TABLE IF NOT EXISTS users (
|
const SCHEMA = {
|
||||||
|
schema_version: 'CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)',
|
||||||
|
users: `CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
identifier TEXT UNIQUE NOT NULL,
|
identifier TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)`
|
)`,
|
||||||
|
users_v2: `CREATE TABLE users_new (
|
||||||
const USERS_V2_SQL = `CREATE TABLE users_new (
|
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
identifier TEXT UNIQUE NOT NULL,
|
identifier TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
@@ -42,23 +31,23 @@ const USERS_V2_SQL = `CREATE TABLE users_new (
|
|||||||
auth_provider TEXT NOT NULL DEFAULT 'local',
|
auth_provider TEXT NOT NULL DEFAULT 'local',
|
||||||
oidc_issuer TEXT,
|
oidc_issuer TEXT,
|
||||||
oidc_sub TEXT
|
oidc_sub TEXT
|
||||||
)`
|
)`,
|
||||||
const USERS_OIDC_UNIQUE = `CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_unique ON users(oidc_issuer, oidc_sub) WHERE oidc_issuer IS NOT NULL AND oidc_sub IS NOT NULL`
|
users_oidc_index: `CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_unique ON users(oidc_issuer, oidc_sub) WHERE oidc_issuer IS NOT NULL AND oidc_sub IS NOT NULL`,
|
||||||
const SESSIONS_SQL = `CREATE TABLE IF NOT EXISTS sessions (
|
sessions: `CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
)`
|
)`,
|
||||||
const POIS_SQL = `CREATE TABLE IF NOT EXISTS pois (
|
pois: `CREATE TABLE IF NOT EXISTS pois (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
lat REAL NOT NULL,
|
lat REAL NOT NULL,
|
||||||
lng REAL NOT NULL,
|
lng REAL NOT NULL,
|
||||||
label TEXT NOT NULL DEFAULT '',
|
label TEXT NOT NULL DEFAULT '',
|
||||||
icon_type TEXT NOT NULL DEFAULT 'pin'
|
icon_type TEXT NOT NULL DEFAULT 'pin'
|
||||||
)`
|
)`,
|
||||||
const DEVICES_SQL = `CREATE TABLE IF NOT EXISTS devices (
|
devices: `CREATE TABLE IF NOT EXISTS devices (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
device_type TEXT NOT NULL,
|
device_type TEXT NOT NULL,
|
||||||
@@ -68,88 +57,117 @@ const DEVICES_SQL = `CREATE TABLE IF NOT EXISTS devices (
|
|||||||
stream_url TEXT NOT NULL DEFAULT '',
|
stream_url TEXT NOT NULL DEFAULT '',
|
||||||
source_type TEXT NOT NULL DEFAULT 'mjpeg',
|
source_type TEXT NOT NULL DEFAULT 'mjpeg',
|
||||||
config TEXT
|
config TEXT
|
||||||
)`
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
function getDbPath() {
|
const getDbPath = () => {
|
||||||
if (testPath) return testPath
|
if (testPath) return testPath
|
||||||
|
if (process.env.DB_PATH) return process.env.DB_PATH
|
||||||
const dir = join(process.cwd(), 'data')
|
const dir = join(process.cwd(), 'data')
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
return join(dir, 'kestrelos.db')
|
return join(dir, 'kestrelos.db')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(db) {
|
const getSchemaVersion = async (get) => {
|
||||||
if (testPath) return
|
try {
|
||||||
const row = await db.get('SELECT COUNT(*) as n FROM users')
|
const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
|
||||||
if (row?.n !== 0) return
|
return row?.version || 0
|
||||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
}
|
||||||
const password = process.env.BOOTSTRAP_PASSWORD
|
catch {
|
||||||
const identifier = (email && password) ? email : DEFAULT_ADMIN_IDENTIFIER
|
return 0
|
||||||
const plainPassword = (email && password) ? password : generateRandomPassword()
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
await db.run(
|
|
||||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[id, identifier, hashPassword(plainPassword), 'admin', now, 'local', null, null],
|
|
||||||
)
|
|
||||||
if (!email || !password) {
|
|
||||||
console.log('\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n')
|
|
||||||
|
|
||||||
console.log(` Identifier: ${identifier}\n Password: ${plainPassword}\n`)
|
|
||||||
|
|
||||||
console.log(' Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function migrateUsersIfNeeded(run, all) {
|
const setSchemaVersion = (run, version) => run('INSERT OR REPLACE INTO schema_version (version) VALUES (?)', [version])
|
||||||
|
|
||||||
|
const migrateToV2 = async (run, all) => {
|
||||||
const info = await all('PRAGMA table_info(users)')
|
const info = await all('PRAGMA table_info(users)')
|
||||||
if (info.some(c => c.name === 'auth_provider')) return
|
if (info.some(c => c.name === 'auth_provider')) return
|
||||||
await run(USERS_V2_SQL)
|
|
||||||
await run(
|
await run('BEGIN TRANSACTION')
|
||||||
`INSERT INTO users_new (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub)
|
try {
|
||||||
SELECT id, identifier, password_hash, role, created_at, 'local', NULL, NULL FROM users`,
|
await run(SCHEMA.users_v2)
|
||||||
)
|
await run('INSERT INTO users_new (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) SELECT id, identifier, password_hash, role, created_at, ?, ?, ? FROM users', ['local', null, null])
|
||||||
await run('DROP TABLE users')
|
await run('DROP TABLE users')
|
||||||
await run('ALTER TABLE users_new RENAME TO users')
|
await run('ALTER TABLE users_new RENAME TO users')
|
||||||
await run(USERS_OIDC_UNIQUE)
|
await run(SCHEMA.users_oidc_index)
|
||||||
|
await run('COMMIT')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await run('ROLLBACK').catch(() => {})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runMigrations = async (run, all, get) => {
|
||||||
|
const version = await getSchemaVersion(get)
|
||||||
|
if (version >= SCHEMA_VERSION) return
|
||||||
|
if (version < 2) {
|
||||||
|
await migrateToV2(run, all)
|
||||||
|
await setSchemaVersion(run, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initDb = async (db, run, all, get) => {
|
||||||
|
try {
|
||||||
|
await run('PRAGMA journal_mode = WAL')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// WAL not supported (e.g., network filesystem)
|
||||||
|
}
|
||||||
|
db.configure('busyTimeout', DB_BUSY_TIMEOUT_MS)
|
||||||
|
|
||||||
|
await run(SCHEMA.schema_version)
|
||||||
|
await run(SCHEMA.users)
|
||||||
|
await runMigrations(run, all, get)
|
||||||
|
await run(SCHEMA.sessions)
|
||||||
|
await run(SCHEMA.pois)
|
||||||
|
await run(SCHEMA.devices)
|
||||||
|
|
||||||
|
if (!testPath) await bootstrapAdmin(run, get)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDb() {
|
export async function getDb() {
|
||||||
if (dbInstance) return dbInstance
|
if (dbInstance) return dbInstance
|
||||||
const path = getDbPath()
|
|
||||||
const db = new sqlite3.Database(path)
|
const db = new sqlite3.Database(getDbPath(), (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[db] Failed to open database:', err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const run = promisify(db.run.bind(db))
|
const run = promisify(db.run.bind(db))
|
||||||
const all = promisify(db.all.bind(db))
|
const all = promisify(db.all.bind(db))
|
||||||
const get = promisify(db.get.bind(db))
|
const get = promisify(db.get.bind(db))
|
||||||
await run(USERS_SQL)
|
|
||||||
await migrateUsersIfNeeded(run, all)
|
try {
|
||||||
await run(SESSIONS_SQL)
|
await initDb(db, run, all, get)
|
||||||
await run(POIS_SQL)
|
}
|
||||||
await run(DEVICES_SQL)
|
catch (error) {
|
||||||
await bootstrap({ run, get })
|
db.close()
|
||||||
|
console.error('[db] Database initialization failed:', error.message)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
dbInstance = { db, run, all, get }
|
dbInstance = { db, run, all, get }
|
||||||
return dbInstance
|
return dbInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the DB connection. Call on server shutdown to avoid native sqlite3 crashes in worker teardown.
|
|
||||||
*/
|
|
||||||
export function closeDb() {
|
export function closeDb() {
|
||||||
if (dbInstance) {
|
if (!dbInstance) return
|
||||||
try {
|
try {
|
||||||
dbInstance.db.close()
|
dbInstance.db.close((err) => {
|
||||||
|
if (err) console.error('[db] Error closing database:', err.message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
catch {
|
catch (error) {
|
||||||
// ignore if already closed
|
console.error('[db] Error closing database:', error.message)
|
||||||
}
|
}
|
||||||
dbInstance = null
|
dbInstance = null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For tests: use in-memory DB and reset singleton.
|
|
||||||
* @param {string} path - e.g. ':memory:'
|
|
||||||
*/
|
|
||||||
export function setDbPathForTest(path) {
|
export function setDbPathForTest(path) {
|
||||||
testPath = path
|
testPath = path || null
|
||||||
closeDb()
|
closeDb()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { sanitizeStreamUrl } from './feedUtils.js'
|
|
||||||
|
|
||||||
const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone'])
|
const DEVICE_TYPES = Object.freeze(['alpr', 'nvr', 'doorbell', 'feed', 'traffic', 'ip', 'drone'])
|
||||||
const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls'])
|
const SOURCE_TYPES = Object.freeze(['mjpeg', 'hls'])
|
||||||
|
|
||||||
|
const sanitizeStreamUrl = (url) => {
|
||||||
|
if (typeof url !== 'string' || !url.trim()) return ''
|
||||||
|
const u = url.trim()
|
||||||
|
return (u.startsWith('https://') || u.startsWith('http://')) ? u : ''
|
||||||
|
}
|
||||||
|
|
||||||
/** @typedef {{ id: string, name: string, device_type: string, vendor: string | null, lat: number, lng: number, stream_url: string, source_type: string, config: string | null }} DeviceRow */
|
/** @typedef {{ id: string, name: string, device_type: string, vendor: string | null, lat: number, lng: number, stream_url: string, source_type: string, config: string | null }} DeviceRow */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validates a single feed object shape (pure function).
|
|
||||||
* @param {unknown} item
|
|
||||||
* @returns {boolean} True if item has id, name, lat, lng with correct types.
|
|
||||||
*/
|
|
||||||
export function isValidFeed(item) {
|
|
||||||
if (!item || typeof item !== 'object') return false
|
|
||||||
const o = /** @type {Record<string, unknown>} */ (item)
|
|
||||||
return (
|
|
||||||
typeof o.id === 'string'
|
|
||||||
&& typeof o.name === 'string'
|
|
||||||
&& typeof o.lat === 'number'
|
|
||||||
&& typeof o.lng === 'number'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a safe stream URL (http/https only) or empty string. Prevents javascript:, data:, etc.
|
|
||||||
* @param {unknown} url
|
|
||||||
* @returns {string} Safe http(s) URL or empty string.
|
|
||||||
*/
|
|
||||||
export function sanitizeStreamUrl(url) {
|
|
||||||
if (typeof url !== 'string' || !url.trim()) return ''
|
|
||||||
const u = url.trim()
|
|
||||||
if (u.startsWith('https://') || u.startsWith('http://')) return u
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes a validated feed for API response: safe streamUrl and sourceType only.
|
|
||||||
* @param {{ id: string, name: string, lat: number, lng: number, [key: string]: unknown }} feed
|
|
||||||
* @returns {{ id: string, name: string, lat: number, lng: number, streamUrl: string, sourceType: string, description?: string }} Sanitized feed for API.
|
|
||||||
*/
|
|
||||||
export function sanitizeFeedForResponse(feed) {
|
|
||||||
return {
|
|
||||||
id: feed.id,
|
|
||||||
name: feed.name,
|
|
||||||
lat: feed.lat,
|
|
||||||
lng: feed.lng,
|
|
||||||
streamUrl: sanitizeStreamUrl(feed.streamUrl),
|
|
||||||
sourceType: feed.sourceType === 'hls' ? 'hls' : 'mjpeg',
|
|
||||||
...(typeof feed.description === 'string' ? { description: feed.description } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters and returns only valid feeds from an array (pure function).
|
|
||||||
* @param {unknown[]} list
|
|
||||||
* @returns {Array<{ id: string, name: string, lat: number, lng: number }>} Array of valid feed objects.
|
|
||||||
*/
|
|
||||||
export function getValidFeeds(list) {
|
|
||||||
if (!Array.isArray(list)) return []
|
|
||||||
return list.filter(isValidFeed)
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,17 @@
|
|||||||
/**
|
|
||||||
* In-memory store for live sharing sessions (camera + location).
|
|
||||||
* Sessions expire after TTL_MS without an update.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { closeRouter, getProducer, getTransport } from './mediasoup.js'
|
import { closeRouter, getProducer, getTransport } from './mediasoup.js'
|
||||||
|
|
||||||
const TTL_MS = 60_000 // 60 seconds without update = inactive
|
const TTL_MS = 60_000
|
||||||
|
|
||||||
const sessions = new Map()
|
const sessions = new Map()
|
||||||
|
|
||||||
/**
|
export const createSession = (userId, label = '') => {
|
||||||
* @typedef {{
|
|
||||||
* id: string
|
|
||||||
* userId: string
|
|
||||||
* label: string
|
|
||||||
* lat: number
|
|
||||||
* lng: number
|
|
||||||
* updatedAt: number
|
|
||||||
* routerId: string | null
|
|
||||||
* producerId: string | null
|
|
||||||
* transportId: string | null
|
|
||||||
* }} LiveSession
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} userId
|
|
||||||
* @param {string} [label]
|
|
||||||
* @returns {LiveSession} The created live session.
|
|
||||||
*/
|
|
||||||
export function createSession(userId, label = '') {
|
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const now = Date.now()
|
|
||||||
const session = {
|
const session = {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
label: (label || 'Live').trim() || 'Live',
|
label: (label || 'Live').trim() || 'Live',
|
||||||
lat: 0,
|
lat: 0,
|
||||||
lng: 0,
|
lng: 0,
|
||||||
updatedAt: now,
|
updatedAt: Date.now(),
|
||||||
routerId: null,
|
routerId: null,
|
||||||
producerId: null,
|
producerId: null,
|
||||||
transportId: null,
|
transportId: null,
|
||||||
@@ -46,34 +20,16 @@ export function createSession(userId, label = '') {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const getLiveSession = id => sessions.get(id)
|
||||||
* @param {string} id
|
|
||||||
* @returns {LiveSession | undefined} The session or undefined.
|
|
||||||
*/
|
|
||||||
export function getLiveSession(id) {
|
|
||||||
return sessions.get(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export const getActiveSessionByUserId = (userId) => {
|
||||||
* Get an existing active session for a user (for replacing with a new one).
|
|
||||||
* @param {string} userId
|
|
||||||
* @returns {LiveSession | undefined} The first active session for the user, or undefined.
|
|
||||||
*/
|
|
||||||
export function getActiveSessionByUserId(userId) {
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [, s] of sessions) {
|
for (const s of sessions.values()) {
|
||||||
if (s.userId === userId && now - s.updatedAt <= TTL_MS) {
|
if (s.userId === userId && now - s.updatedAt <= TTL_MS) return s
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export const updateLiveSession = (id, updates) => {
|
||||||
* @param {string} id
|
|
||||||
* @param {{ lat?: number, lng?: number, routerId?: string | null, producerId?: string | null, transportId?: string | null }} updates
|
|
||||||
*/
|
|
||||||
export function updateLiveSession(id, updates) {
|
|
||||||
const session = sessions.get(id)
|
const session = sessions.get(id)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -85,74 +41,52 @@ export function updateLiveSession(id, updates) {
|
|||||||
session.updatedAt = now
|
session.updatedAt = now
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const deleteLiveSession = id => sessions.delete(id)
|
||||||
* @param {string} id
|
|
||||||
*/
|
export const clearSessions = () => sessions.clear()
|
||||||
export function deleteLiveSession(id) {
|
|
||||||
sessions.delete(id)
|
const cleanupSession = async (session) => {
|
||||||
|
if (session.producerId) {
|
||||||
|
const producer = getProducer(session.producerId)
|
||||||
|
producer?.close()
|
||||||
|
}
|
||||||
|
if (session.transportId) {
|
||||||
|
const transport = getTransport(session.transportId)
|
||||||
|
transport?.close()
|
||||||
|
}
|
||||||
|
if (session.routerId) {
|
||||||
|
await closeRouter(session.id).catch((err) => {
|
||||||
|
console.error(`[liveSessions] Error closing router for expired session ${session.id}:`, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const getActiveSessions = async () => {
|
||||||
* Clear all sessions (for tests only).
|
|
||||||
*/
|
|
||||||
export function clearSessions() {
|
|
||||||
sessions.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns sessions updated within TTL_MS (active only).
|
|
||||||
* Also cleans up expired sessions.
|
|
||||||
* @returns {Promise<Array<{ id: string, userId: string, label: string, lat: number, lng: number, updatedAt: number, hasStream: boolean }>>} Active sessions with hasStream flag.
|
|
||||||
*/
|
|
||||||
export async function getActiveSessions() {
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const result = []
|
const active = []
|
||||||
const expiredIds = []
|
const expired = []
|
||||||
for (const [id, s] of sessions) {
|
|
||||||
if (now - s.updatedAt <= TTL_MS) {
|
for (const session of sessions.values()) {
|
||||||
result.push({
|
if (now - session.updatedAt <= TTL_MS) {
|
||||||
id: s.id,
|
active.push({
|
||||||
userId: s.userId,
|
id: session.id,
|
||||||
label: s.label,
|
userId: session.userId,
|
||||||
lat: s.lat,
|
label: session.label,
|
||||||
lng: s.lng,
|
lat: session.lat,
|
||||||
updatedAt: s.updatedAt,
|
lng: session.lng,
|
||||||
hasStream: Boolean(s.producerId),
|
updatedAt: session.updatedAt,
|
||||||
|
hasStream: Boolean(session.producerId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
expiredIds.push(id)
|
expired.push(session)
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clean up expired sessions and their WebRTC resources
|
|
||||||
for (const id of expiredIds) {
|
|
||||||
const session = sessions.get(id)
|
|
||||||
if (session) {
|
|
||||||
// Clean up producer if it exists
|
|
||||||
if (session.producerId) {
|
|
||||||
const producer = getProducer(session.producerId)
|
|
||||||
if (producer) {
|
|
||||||
producer.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up transport if it exists
|
for (const session of expired) {
|
||||||
if (session.transportId) {
|
await cleanupSession(session)
|
||||||
const transport = getTransport(session.transportId)
|
sessions.delete(session.id)
|
||||||
if (transport) {
|
|
||||||
transport.close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up router
|
return active
|
||||||
if (session.routerId) {
|
|
||||||
await closeRouter(id).catch((err) => {
|
|
||||||
console.error(`[liveSessions] Error closing router for expired session ${id}:`, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions.delete(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
/**
|
|
||||||
* Mediasoup SFU (Selective Forwarding Unit) setup and management.
|
|
||||||
* Handles WebRTC router, transport, producer, and consumer creation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import mediasoup from 'mediasoup'
|
import mediasoup from 'mediasoup'
|
||||||
|
|
||||||
let worker = null
|
let worker = null
|
||||||
const routers = new Map() // sessionId -> Router
|
const routers = new Map()
|
||||||
const transports = new Map() // transportId -> WebRtcTransport
|
const transports = new Map()
|
||||||
export const producers = new Map() // producerId -> Producer
|
export const producers = new Map()
|
||||||
|
|
||||||
/**
|
const MEDIA_CODECS = [
|
||||||
* Initialize Mediasoup worker (singleton).
|
{ kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f' } },
|
||||||
* @returns {Promise<mediasoup.types.Worker>} The Mediasoup worker.
|
{ kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
|
||||||
*/
|
{ kind: 'video', mimeType: 'video/VP9', clockRate: 90000 },
|
||||||
export async function getWorker() {
|
]
|
||||||
|
|
||||||
|
export const getWorker = async () => {
|
||||||
if (worker) return worker
|
if (worker) return worker
|
||||||
worker = await mediasoup.createWorker({
|
worker = await mediasoup.createWorker({
|
||||||
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
|
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
|
||||||
@@ -30,50 +27,15 @@ export async function getWorker() {
|
|||||||
return worker
|
return worker
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const getRouter = async (sessionId) => {
|
||||||
* Create or get a router for a live session.
|
const existing = routers.get(sessionId)
|
||||||
* @param {string} sessionId
|
if (existing) return existing
|
||||||
* @returns {Promise<mediasoup.types.Router>} Router for the session.
|
const router = await (await getWorker()).createRouter({ mediaCodecs: MEDIA_CODECS })
|
||||||
*/
|
|
||||||
export async function getRouter(sessionId) {
|
|
||||||
if (routers.has(sessionId)) {
|
|
||||||
return routers.get(sessionId)
|
|
||||||
}
|
|
||||||
const w = await getWorker()
|
|
||||||
const router = await w.createRouter({
|
|
||||||
mediaCodecs: [
|
|
||||||
{
|
|
||||||
kind: 'video',
|
|
||||||
mimeType: 'video/H264',
|
|
||||||
clockRate: 90000,
|
|
||||||
parameters: {
|
|
||||||
'packetization-mode': 1,
|
|
||||||
'profile-level-id': '42e01f',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: 'video',
|
|
||||||
mimeType: 'video/VP8',
|
|
||||||
clockRate: 90000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: 'video',
|
|
||||||
mimeType: 'video/VP9',
|
|
||||||
clockRate: 90000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
routers.set(sessionId, router)
|
routers.set(sessionId, router)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const isIPv4 = (host) => {
|
||||||
* True if the string is a valid IPv4 address (numeric a.b.c.d, each octet 0-255).
|
|
||||||
* Used to accept request Host as announced IP only when it's safe (no hostnames/DNS rebinding).
|
|
||||||
* @param {string} host
|
|
||||||
* @returns {boolean} True if host is a valid IPv4 address.
|
|
||||||
*/
|
|
||||||
function isIPv4(host) {
|
|
||||||
if (typeof host !== 'string' || !host) return false
|
if (typeof host !== 'string' || !host) return false
|
||||||
const parts = host.split('.')
|
const parts = host.split('.')
|
||||||
if (parts.length !== 4) return false
|
if (parts.length !== 4) return false
|
||||||
@@ -84,45 +46,24 @@ function isIPv4(host) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const getAnnouncedIpFromInterfaces = () => {
|
||||||
* First non-internal IPv4 from network interfaces (no env read).
|
for (const addrs of Object.values(os.networkInterfaces())) {
|
||||||
* @returns {string | null} First non-internal IPv4 address or null.
|
|
||||||
*/
|
|
||||||
function getAnnouncedIpFromInterfaces() {
|
|
||||||
const ifaces = os.networkInterfaces()
|
|
||||||
for (const addrs of Object.values(ifaces)) {
|
|
||||||
if (!addrs) continue
|
if (!addrs) continue
|
||||||
for (const addr of addrs) {
|
for (const addr of addrs) {
|
||||||
if (addr.family === 'IPv4' && !addr.internal) {
|
if (addr.family === 'IPv4' && !addr.internal) return addr.address
|
||||||
return addr.address
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const resolveAnnouncedIp = (requestHost) => {
|
||||||
* Resolve announced IP: env override, then request host if IPv4, then auto-detect. Pure and deterministic.
|
|
||||||
* @param {string | undefined} requestHost - Host header from the client.
|
|
||||||
* @returns {string | null} The IP to announce in ICE, or null for localhost-only.
|
|
||||||
*/
|
|
||||||
function resolveAnnouncedIp(requestHost) {
|
|
||||||
const envIp = process.env.MEDIASOUP_ANNOUNCED_IP?.trim()
|
const envIp = process.env.MEDIASOUP_ANNOUNCED_IP?.trim()
|
||||||
if (envIp) return envIp
|
if (envIp) return envIp
|
||||||
if (requestHost && isIPv4(requestHost)) return requestHost
|
if (requestHost && isIPv4(requestHost)) return requestHost
|
||||||
return getAnnouncedIpFromInterfaces()
|
return getAnnouncedIpFromInterfaces()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const createTransport = async (router, requestHost = undefined) => {
|
||||||
* Create a WebRTC transport for a router.
|
|
||||||
* @param {mediasoup.types.Router} router
|
|
||||||
* @param {boolean} _isProducer - true for publisher, false for consumer (reserved for future use)
|
|
||||||
* @param {string} [requestHost] - Hostname from the request (e.g. getRequestURL(event).hostname). If a valid IPv4, used as announced IP so the client can reach the server.
|
|
||||||
* @returns {Promise<{ transport: mediasoup.types.WebRtcTransport, params: object }>} Transport and connection params.
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
export async function createTransport(router, _isProducer = false, requestHost = undefined) {
|
|
||||||
// LAN first so the phone (and remote viewers) try the reachable IP before 127.0.0.1 (loopback on the client).
|
|
||||||
const announcedIp = resolveAnnouncedIp(requestHost)
|
const announcedIp = resolveAnnouncedIp(requestHost)
|
||||||
const listenIps = announcedIp
|
const listenIps = announcedIp
|
||||||
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
|
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
|
||||||
@@ -138,10 +79,10 @@ export async function createTransport(router, _isProducer = false, requestHost =
|
|||||||
console.error('[mediasoup] Transport creation failed:', err)
|
console.error('[mediasoup] Transport creation failed:', err)
|
||||||
throw new Error(`Failed to create transport: ${err.message || String(err)}`)
|
throw new Error(`Failed to create transport: ${err.message || String(err)}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
transports.set(transport.id, transport)
|
transports.set(transport.id, transport)
|
||||||
transport.on('close', () => {
|
transport.on('close', () => transports.delete(transport.id))
|
||||||
transports.delete(transport.id)
|
|
||||||
})
|
|
||||||
return {
|
return {
|
||||||
transport,
|
transport,
|
||||||
params: {
|
params: {
|
||||||
@@ -153,61 +94,22 @@ export async function createTransport(router, _isProducer = false, requestHost =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const getTransport = transportId => transports.get(transportId)
|
||||||
* Get transport by ID.
|
|
||||||
* @param {string} transportId
|
|
||||||
* @returns {mediasoup.types.WebRtcTransport | undefined} Transport or undefined.
|
|
||||||
*/
|
|
||||||
export function getTransport(transportId) {
|
|
||||||
return transports.get(transportId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export const createProducer = async (transport, track) => {
|
||||||
* Create a producer (publisher's video track).
|
|
||||||
* @param {mediasoup.types.WebRtcTransport} transport
|
|
||||||
* @param {MediaStreamTrack} track
|
|
||||||
* @returns {Promise<mediasoup.types.Producer>} The producer.
|
|
||||||
*/
|
|
||||||
export async function createProducer(transport, track) {
|
|
||||||
const producer = await transport.produce({ track })
|
const producer = await transport.produce({ track })
|
||||||
producers.set(producer.id, producer)
|
producers.set(producer.id, producer)
|
||||||
producer.on('close', () => {
|
producer.on('close', () => producers.delete(producer.id))
|
||||||
producers.delete(producer.id)
|
|
||||||
})
|
|
||||||
return producer
|
return producer
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const getProducer = producerId => producers.get(producerId)
|
||||||
* Get producer by ID.
|
|
||||||
* @param {string} producerId
|
|
||||||
* @returns {mediasoup.types.Producer | undefined} Producer or undefined.
|
|
||||||
*/
|
|
||||||
export function getProducer(producerId) {
|
|
||||||
return producers.get(producerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export const getTransports = () => transports
|
||||||
* Get transports Map (for cleanup).
|
|
||||||
* @returns {Map<string, mediasoup.types.WebRtcTransport>} Map of transport ID to transport.
|
|
||||||
*/
|
|
||||||
export function getTransports() {
|
|
||||||
return transports
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export const createConsumer = async (transport, producer, rtpCapabilities) => {
|
||||||
* Create a consumer (viewer subscribes to producer's stream).
|
if (producer.closed) throw new Error('Producer is closed')
|
||||||
* @param {mediasoup.types.WebRtcTransport} transport
|
if (producer.paused) await producer.resume()
|
||||||
* @param {mediasoup.types.Producer} producer
|
|
||||||
* @param {boolean} rtpCapabilities
|
|
||||||
* @returns {Promise<{ consumer: mediasoup.types.Consumer, params: object }>} Consumer and connection params.
|
|
||||||
*/
|
|
||||||
export async function createConsumer(transport, producer, rtpCapabilities) {
|
|
||||||
if (producer.closed) {
|
|
||||||
throw new Error('Producer is closed')
|
|
||||||
}
|
|
||||||
if (producer.paused) {
|
|
||||||
await producer.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
const consumer = await transport.consume({
|
const consumer = await transport.consume({
|
||||||
producerId: producer.id,
|
producerId: producer.id,
|
||||||
@@ -229,11 +131,7 @@ export async function createConsumer(transport, producer, rtpCapabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const closeRouter = async (sessionId) => {
|
||||||
* Clean up router for a session.
|
|
||||||
* @param {string} sessionId
|
|
||||||
*/
|
|
||||||
export async function closeRouter(sessionId) {
|
|
||||||
const router = routers.get(sessionId)
|
const router = routers.get(sessionId)
|
||||||
if (router) {
|
if (router) {
|
||||||
router.close()
|
router.close()
|
||||||
@@ -241,10 +139,4 @@ export async function closeRouter(sessionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const getActiveRouters = () => Array.from(routers.keys())
|
||||||
* Get all active routers (for debugging/monitoring).
|
|
||||||
* @returns {Array<string>} Session IDs with active routers
|
|
||||||
*/
|
|
||||||
export function getActiveRouters() {
|
|
||||||
return Array.from(routers.keys())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { join } from 'node:path'
|
|
||||||
import { readFileSync, existsSync } from 'node:fs'
|
|
||||||
import { getDb } from './db.js'
|
|
||||||
import { sanitizeStreamUrl } from './feedUtils.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-time migration: insert entries from server/data/feeds.json into devices (device_type = 'feed').
|
|
||||||
* No-op if devices table already has rows or feeds file is missing.
|
|
||||||
*/
|
|
||||||
export async function migrateFeedsToDevices() {
|
|
||||||
const db = await getDb()
|
|
||||||
const row = await db.get('SELECT COUNT(*) as n FROM devices')
|
|
||||||
if (row?.n > 0) return
|
|
||||||
const path = join(process.cwd(), 'server/data/feeds.json')
|
|
||||||
if (!existsSync(path)) return
|
|
||||||
const data = JSON.parse(readFileSync(path, 'utf8'))
|
|
||||||
const list = Array.isArray(data) ? data : []
|
|
||||||
for (const feed of list) {
|
|
||||||
if (!feed?.id || typeof feed.name !== 'string' || typeof feed.lat !== 'number' || typeof feed.lng !== 'number') continue
|
|
||||||
const streamUrl = sanitizeStreamUrl(feed.streamUrl) ?? ''
|
|
||||||
const sourceType = feed.sourceType === 'hls' ? 'hls' : 'mjpeg'
|
|
||||||
await db.run(
|
|
||||||
'INSERT OR IGNORE INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[feed.id, feed.name, 'feed', null, feed.lat, feed.lng, streamUrl, sourceType, null],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ export async function handleWebSocketMessage(userId, sessionId, type, data) {
|
|||||||
}
|
}
|
||||||
case 'create-transport': {
|
case 'create-transport': {
|
||||||
const router = await getRouter(sessionId)
|
const router = await getRouter(sessionId)
|
||||||
const { transport, params } = await createTransport(router, true)
|
const { transport, params } = await createTransport(router)
|
||||||
updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
|
updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
|
||||||
return { type: 'transport-created', data: params }
|
return { type: 'transport-created', data: params }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,58 @@
|
|||||||
/**
|
|
||||||
* Global setup for E2E tests.
|
|
||||||
* Runs once before all tests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { existsSync, mkdirSync } from 'node:fs'
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
import { join, dirname } from 'node:path'
|
import { join, dirname } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { execSync } from 'node:child_process'
|
import { execSync } from 'node:child_process'
|
||||||
|
|
||||||
const _dirname = dirname(fileURLToPath(import.meta.url))
|
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||||
const projectRoot = join(_dirname, '../../..')
|
|
||||||
const devCertsDir = join(projectRoot, '.dev-certs')
|
const devCertsDir = join(projectRoot, '.dev-certs')
|
||||||
const devKey = join(devCertsDir, 'key.pem')
|
const devKey = join(devCertsDir, 'key.pem')
|
||||||
const devCert = join(devCertsDir, 'cert.pem')
|
const devCert = join(devCertsDir, 'cert.pem')
|
||||||
|
|
||||||
// Import server modules (ES modules)
|
|
||||||
const { getDb } = await import('../../server/utils/db.js')
|
const { getDb } = await import('../../server/utils/db.js')
|
||||||
const { hashPassword } = await import('../../server/utils/password.js')
|
const { hashPassword } = await import('../../server/utils/password.js')
|
||||||
const { TEST_ADMIN } = await import('./fixtures/users.js')
|
const { TEST_ADMIN } = await import('./fixtures/users.js')
|
||||||
|
|
||||||
function ensureDevCerts() {
|
const ensureDevCerts = () => {
|
||||||
if (existsSync(devKey) && existsSync(devCert)) {
|
if (existsSync(devKey) && existsSync(devCert)) return
|
||||||
return // Certs already exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create .dev-certs directory
|
|
||||||
mkdirSync(devCertsDir, { recursive: true })
|
mkdirSync(devCertsDir, { recursive: true })
|
||||||
|
|
||||||
// Generate self-signed cert for localhost/127.0.0.1
|
|
||||||
const SAN = 'subjectAltName=IP:127.0.0.1,DNS:localhost'
|
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync(
|
||||||
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "${SAN}"`,
|
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"`,
|
||||||
{ cwd: projectRoot, stdio: 'inherit' },
|
{ cwd: projectRoot, stdio: process.env.CI ? 'pipe' : 'inherit' },
|
||||||
)
|
)
|
||||||
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
throw new Error(`Failed to generate dev certificates: ${error.message}`)
|
throw new Error(`Failed to generate dev certificates: ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
// Ensure dev certificates exist
|
|
||||||
ensureDevCerts()
|
ensureDevCerts()
|
||||||
|
|
||||||
// Create test admin user if it doesn't exist
|
let retries = 3
|
||||||
|
while (retries > 0) {
|
||||||
|
try {
|
||||||
const { get, run } = await getDb()
|
const { get, run } = await getDb()
|
||||||
const existingUser = await get('SELECT id FROM users WHERE identifier = ?', [TEST_ADMIN.identifier])
|
const existing = await get('SELECT id FROM users WHERE identifier = ?', [TEST_ADMIN.identifier])
|
||||||
|
|
||||||
if (!existingUser) {
|
if (!existing) {
|
||||||
const id = crypto.randomUUID()
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
await run(
|
await run(
|
||||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[id, TEST_ADMIN.identifier, hashPassword(TEST_ADMIN.password), TEST_ADMIN.role, now, 'local', null, null],
|
[crypto.randomUUID(), TEST_ADMIN.identifier, hashPassword(TEST_ADMIN.password), TEST_ADMIN.role, new Date().toISOString(), 'local', null, null],
|
||||||
)
|
)
|
||||||
console.log(`[test] Created test admin user: ${TEST_ADMIN.identifier}`)
|
|
||||||
}
|
}
|
||||||
else {
|
return
|
||||||
console.log(`[test] Test admin user already exists: ${TEST_ADMIN.identifier}`)
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error.message?.includes('SQLITE_BUSY') || error.message?.includes('database is locked')) {
|
||||||
|
retries--
|
||||||
|
if (retries > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100 * (4 - retries)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default globalSetup
|
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ test.describe('Live Streaming E2E', () => {
|
|||||||
|
|
||||||
test('publisher only: start sharing and reach Live', async ({ browser, browserName }) => {
|
test('publisher only: start sharing and reach Live', async ({ browser, browserName }) => {
|
||||||
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
|
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
|
||||||
|
// Skip in CI - WebRTC tests are flaky with fake media devices in CI environments
|
||||||
|
test.skip(!!process.env.CI, 'WebRTC tests skipped in CI due to flaky fake media device support')
|
||||||
const ctx = await browser.newContext({
|
const ctx = await browser.newContext({
|
||||||
permissions: ['geolocation'],
|
permissions: ['camera', 'microphone', 'geolocation'],
|
||||||
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
||||||
})
|
})
|
||||||
const page = await ctx.newPage()
|
const page = await ctx.newPage()
|
||||||
@@ -55,9 +57,11 @@ test.describe('Live Streaming E2E', () => {
|
|||||||
|
|
||||||
test('Mobile Safari publishes, Desktop Chrome views', async ({ browser, browserName }) => {
|
test('Mobile Safari publishes, Desktop Chrome views', async ({ browser, browserName }) => {
|
||||||
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
|
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
|
||||||
|
// Skip in CI - WebRTC tests are flaky with fake media devices in CI environments
|
||||||
|
test.skip(!!process.env.CI, 'WebRTC tests skipped in CI due to flaky fake media device support')
|
||||||
// Publisher context (same as publisher-only test for reliability)
|
// Publisher context (same as publisher-only test for reliability)
|
||||||
const publisherContext = await browser.newContext({
|
const publisherContext = await browser.newContext({
|
||||||
permissions: ['geolocation'],
|
permissions: ['camera', 'microphone', 'geolocation'],
|
||||||
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
||||||
})
|
})
|
||||||
const publisherPage = await publisherContext.newPage()
|
const publisherPage = await publisherContext.newPage()
|
||||||
@@ -123,8 +127,10 @@ test.describe('Live Streaming E2E', () => {
|
|||||||
|
|
||||||
test('Mobile Safari publishes, Desktop Firefox views', async ({ browser, browserName }) => {
|
test('Mobile Safari publishes, Desktop Firefox views', async ({ browser, browserName }) => {
|
||||||
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
|
test.skip(browserName !== 'chromium', 'Fake camera only supported in Chromium')
|
||||||
|
// Skip in CI - WebRTC tests are flaky with fake media devices in CI environments
|
||||||
|
test.skip(!!process.env.CI, 'WebRTC tests skipped in CI due to flaky fake media device support')
|
||||||
const publisherContext = await browser.newContext({
|
const publisherContext = await browser.newContext({
|
||||||
permissions: ['geolocation'],
|
permissions: ['camera', 'microphone', 'geolocation'],
|
||||||
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
||||||
})
|
})
|
||||||
const publisherPage = await publisherContext.newPage()
|
const publisherPage = await publisherContext.newPage()
|
||||||
|
|||||||
@@ -10,24 +10,24 @@ vi.mock('leaflet.offline', () => ({ tileLayerOffline: null, savetiles: null }))
|
|||||||
describe('KestrelMap', () => {
|
describe('KestrelMap', () => {
|
||||||
it('renders map container', async () => {
|
it('renders map container', async () => {
|
||||||
const wrapper = await mountSuspended(KestrelMap, {
|
const wrapper = await mountSuspended(KestrelMap, {
|
||||||
props: { feeds: [] },
|
props: { devices: [] },
|
||||||
})
|
})
|
||||||
expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true)
|
expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts feeds prop', async () => {
|
it('accepts devices prop', async () => {
|
||||||
const feeds = [
|
const devices = [
|
||||||
{ id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' },
|
{ id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' },
|
||||||
]
|
]
|
||||||
const wrapper = await mountSuspended(KestrelMap, {
|
const wrapper = await mountSuspended(KestrelMap, {
|
||||||
props: { feeds },
|
props: { devices },
|
||||||
})
|
})
|
||||||
expect(wrapper.props('feeds')).toEqual(feeds)
|
expect(wrapper.props('devices')).toEqual(devices)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has select emit', async () => {
|
it('has select emit', async () => {
|
||||||
const wrapper = await mountSuspended(KestrelMap, {
|
const wrapper = await mountSuspended(KestrelMap, {
|
||||||
props: { feeds: [] },
|
props: { devices: [] },
|
||||||
})
|
})
|
||||||
wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 })
|
wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 })
|
||||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||||
@@ -67,7 +67,7 @@ describe('KestrelMap', () => {
|
|||||||
it('accepts pois and canEditPois props', async () => {
|
it('accepts pois and canEditPois props', async () => {
|
||||||
const wrapper = await mountSuspended(KestrelMap, {
|
const wrapper = await mountSuspended(KestrelMap, {
|
||||||
props: {
|
props: {
|
||||||
feeds: [],
|
devices: [],
|
||||||
pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }],
|
pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }],
|
||||||
canEditPois: false,
|
canEditPois: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { getValidFeeds } from '../../server/utils/feedUtils.js'
|
|
||||||
|
|
||||||
describe('API contract', () => {
|
|
||||||
it('getValidFeeds returns array suitable for API response', () => {
|
|
||||||
const raw = [
|
|
||||||
{ id: '1', name: 'A', lat: 1, lng: 2 },
|
|
||||||
{ id: '2', name: 'B', lat: 3, lng: 4 },
|
|
||||||
]
|
|
||||||
const out = getValidFeeds(raw)
|
|
||||||
expect(Array.isArray(out)).toBe(true)
|
|
||||||
expect(out).toHaveLength(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { isValidFeed, getValidFeeds, sanitizeStreamUrl, sanitizeFeedForResponse } from '../../server/utils/feedUtils.js'
|
|
||||||
|
|
||||||
describe('feedUtils', () => {
|
|
||||||
describe('isValidFeed', () => {
|
|
||||||
it('returns true for valid feed', () => {
|
|
||||||
expect(isValidFeed({
|
|
||||||
id: '1',
|
|
||||||
name: 'Cam',
|
|
||||||
lat: 37.7,
|
|
||||||
lng: -122.4,
|
|
||||||
})).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for null', () => {
|
|
||||||
expect(isValidFeed(null)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for missing id', () => {
|
|
||||||
expect(isValidFeed({ name: 'x', lat: 0, lng: 0 })).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for wrong lat type', () => {
|
|
||||||
expect(isValidFeed({ id: '1', name: 'x', lat: '37', lng: -122 })).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getValidFeeds', () => {
|
|
||||||
it('returns only valid feeds', () => {
|
|
||||||
const list = [
|
|
||||||
{ id: 'a', name: 'A', lat: 1, lng: 2 },
|
|
||||||
null,
|
|
||||||
{ id: 'b', name: 'B', lat: 3, lng: 4 },
|
|
||||||
]
|
|
||||||
expect(getValidFeeds(list)).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array for non-array', () => {
|
|
||||||
expect(getValidFeeds(null)).toEqual([])
|
|
||||||
expect(getValidFeeds({})).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sanitizeStreamUrl', () => {
|
|
||||||
it('allows http and https', () => {
|
|
||||||
expect(sanitizeStreamUrl('https://example.com/stream')).toBe('https://example.com/stream')
|
|
||||||
expect(sanitizeStreamUrl('http://example.com/stream')).toBe('http://example.com/stream')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty for javascript:, data:, and other schemes', () => {
|
|
||||||
expect(sanitizeStreamUrl('javascript:alert(1)')).toBe('')
|
|
||||||
expect(sanitizeStreamUrl('data:text/html,<script>')).toBe('')
|
|
||||||
expect(sanitizeStreamUrl('file:///etc/passwd')).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty for non-strings or empty', () => {
|
|
||||||
expect(sanitizeStreamUrl('')).toBe('')
|
|
||||||
expect(sanitizeStreamUrl(' ')).toBe('')
|
|
||||||
expect(sanitizeStreamUrl(null)).toBe('')
|
|
||||||
expect(sanitizeStreamUrl(123)).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sanitizeFeedForResponse', () => {
|
|
||||||
it('returns safe shape with sanitized streamUrl and sourceType', () => {
|
|
||||||
const feed = {
|
|
||||||
id: 'f1',
|
|
||||||
name: 'Cam',
|
|
||||||
lat: 37,
|
|
||||||
lng: -122,
|
|
||||||
streamUrl: 'https://safe.com/s',
|
|
||||||
sourceType: 'mjpeg',
|
|
||||||
}
|
|
||||||
const out = sanitizeFeedForResponse(feed)
|
|
||||||
expect(out).toEqual({
|
|
||||||
id: 'f1',
|
|
||||||
name: 'Cam',
|
|
||||||
lat: 37,
|
|
||||||
lng: -122,
|
|
||||||
streamUrl: 'https://safe.com/s',
|
|
||||||
sourceType: 'mjpeg',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strips dangerous streamUrl and normalizes sourceType', () => {
|
|
||||||
const feed = {
|
|
||||||
id: 'f2',
|
|
||||||
name: 'Bad',
|
|
||||||
lat: 0,
|
|
||||||
lng: 0,
|
|
||||||
streamUrl: 'javascript:alert(1)',
|
|
||||||
sourceType: 'hls',
|
|
||||||
}
|
|
||||||
const out = sanitizeFeedForResponse(feed)
|
|
||||||
expect(out.streamUrl).toBe('')
|
|
||||||
expect(out.sourceType).toBe('hls')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes description only when string', () => {
|
|
||||||
const withDesc = sanitizeFeedForResponse({
|
|
||||||
id: 'a',
|
|
||||||
name: 'n',
|
|
||||||
lat: 0,
|
|
||||||
lng: 0,
|
|
||||||
description: 'A camera',
|
|
||||||
})
|
|
||||||
expect(withDesc.description).toBe('A camera')
|
|
||||||
|
|
||||||
const noDesc = sanitizeFeedForResponse({
|
|
||||||
id: 'b',
|
|
||||||
name: 'n',
|
|
||||||
lat: 0,
|
|
||||||
lng: 0,
|
|
||||||
description: 123,
|
|
||||||
})
|
|
||||||
expect(noDesc).not.toHaveProperty('description')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -25,7 +25,7 @@ describe('Mediasoup', () => {
|
|||||||
|
|
||||||
it('should create a transport', async () => {
|
it('should create a transport', async () => {
|
||||||
const router = await getRouter(sessionId)
|
const router = await getRouter(sessionId)
|
||||||
const { transport, params } = await createTransport(router, true)
|
const { transport, params } = await createTransport(router)
|
||||||
expect(transport).toBeDefined()
|
expect(transport).toBeDefined()
|
||||||
expect(params.id).toBe(transport.id)
|
expect(params.id).toBe(transport.id)
|
||||||
expect(params.iceParameters).toBeDefined()
|
expect(params.iceParameters).toBeDefined()
|
||||||
@@ -35,7 +35,7 @@ describe('Mediasoup', () => {
|
|||||||
|
|
||||||
it('should create a transport with requestHost IPv4 and return valid params', async () => {
|
it('should create a transport with requestHost IPv4 and return valid params', async () => {
|
||||||
const router = await getRouter(sessionId)
|
const router = await getRouter(sessionId)
|
||||||
const { transport, params } = await createTransport(router, true, '192.168.2.100')
|
const { transport, params } = await createTransport(router, '192.168.2.100')
|
||||||
expect(transport).toBeDefined()
|
expect(transport).toBeDefined()
|
||||||
expect(params.id).toBe(transport.id)
|
expect(params.id).toBe(transport.id)
|
||||||
expect(params.iceParameters).toBeDefined()
|
expect(params.iceParameters).toBeDefined()
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
|
|
||||||
import { migrateFeedsToDevices } from '../../server/utils/migrateFeedsToDevices.js'
|
|
||||||
|
|
||||||
describe('migrateFeedsToDevices', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setDbPathForTest(':memory:')
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
setDbPathForTest(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs without error when devices table is empty', async () => {
|
|
||||||
const db = await getDb()
|
|
||||||
await expect(migrateFeedsToDevices()).resolves.toBeUndefined()
|
|
||||||
const rows = await db.all('SELECT id FROM devices')
|
|
||||||
expect(rows.length).toBeGreaterThanOrEqual(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is no-op when devices already has rows', async () => {
|
|
||||||
const db = await getDb()
|
|
||||||
await db.run(
|
|
||||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
['existing', 'Existing', 'feed', null, 0, 0, '', 'mjpeg', null],
|
|
||||||
)
|
|
||||||
await migrateFeedsToDevices()
|
|
||||||
const rows = await db.all('SELECT id FROM devices')
|
|
||||||
expect(rows).toHaveLength(1)
|
|
||||||
expect(rows[0].id).toBe('existing')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -22,7 +22,6 @@ export default defineVitestConfig({
|
|||||||
'app/composables/useCameras.js', // Visibility/polling branches; covered by E2E
|
'app/composables/useCameras.js', // Visibility/polling branches; covered by E2E
|
||||||
'server/utils/mediasoup.js', // Requires real mediasoup worker; covered by integration/E2E
|
'server/utils/mediasoup.js', // Requires real mediasoup worker; covered by integration/E2E
|
||||||
'server/utils/db.js', // Bootstrap/path branches depend on env; covered by integration
|
'server/utils/db.js', // Bootstrap/path branches depend on env; covered by integration
|
||||||
'server/utils/migrateFeedsToDevices.js', // File-system branches; one-time migration
|
|
||||||
'**/*.spec.js',
|
'**/*.spec.js',
|
||||||
'**/*.config.js',
|
'**/*.config.js',
|
||||||
'**/node_modules/**',
|
'**/node_modules/**',
|
||||||
|
|||||||
Reference in New Issue
Block a user