add ci (#1)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-02-12 19:50:44 +00:00
parent b7046dc0e6
commit 28ac43e47b
32 changed files with 2089 additions and 2973 deletions

View File

@@ -1 +1 @@
setups.@nuxt/test-utils="3.23.0" setups.@nuxt/test-utils="4.0.0"

58
.woodpecker/ci.yml Normal file
View 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

View File

@@ -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

View File

@@ -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 machines 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 browsers “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 machines 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 4000049999 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 4000049999 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 dont 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

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 ?? []"

View File

@@ -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

View File

@@ -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',
},
},
})

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View File

@@ -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)
}) })

View File

@@ -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, {

View File

@@ -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()
}) })

View File

@@ -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
View 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`)
}
}

View File

@@ -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()
} }

View File

@@ -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 */
/** /**

View File

@@ -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)
}

View File

@@ -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
} }

View File

@@ -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())
}

View File

@@ -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],
)
}
}

View File

@@ -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 }
} }

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
}, },

View File

@@ -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)
})
})

View File

@@ -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')
})
})
})

View File

@@ -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()

View File

@@ -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')
})
})

View File

@@ -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/**',