diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index f3e78b6..0e7f52d 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -33,6 +33,7 @@ import { registerAccountRoutes } from "./routes-account.js"; import { registerFirmwareRoutes } from "./routes-firmware.js"; import { registerOsUpdateRoutes } from "./routes-os-updates.js"; import { registerStaticRoutes } from "./routes-static.js"; +import { registerCloudRoutes } from "./routes-cloud.js"; // ---- Config ----------------------------------------------------------------- @@ -167,6 +168,7 @@ export class Plugin extends BSBService, typeof Event registerAccountRoutes(app, deps); registerFirmwareRoutes(app, deps); registerOsUpdateRoutes(app, deps); + registerCloudRoutes(app, deps); // Auth-check endpoint for Angie auth_request subrequest. // Returns 200 if session cookie is valid + admin role, 401 otherwise. diff --git a/server/src/plugins/service-admin-http/routes-cloud.ts b/server/src/plugins/service-admin-http/routes-cloud.ts new file mode 100644 index 0000000..a2c0baf --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-cloud.ts @@ -0,0 +1,225 @@ +/** + * Admin cloud camera account routes. + * + * /admin/cloud-accounts — list + add form + * /admin/cloud-accounts/:id/sync — trigger camera sync + * /admin/cloud-accounts/:id/delete — remove account + * /admin/cloud-accounts/:id/import — import discovered cameras as BF cameras + */ +import { type H3, getRouterParam, readBody, createError } from "h3"; +import { randomUUID } from "node:crypto"; + +import { htmlPage } from "./html-response.js"; +import type { AdminDeps } from "./index.js"; +import { CLOUD_VENDORS, VENDOR_LABELS, getProvider, listProviders, type CloudVendor } from "../../shared/cloud-cameras/index.js"; + +export function registerCloudRoutes(app: H3, deps: AdminDeps): void { + + app.get("/admin/cloud-accounts", async (event) => { + const user = event.context.user!; + const accounts = await deps.repo.listCloudAccounts(); + const providers = listProviders(); + + return htmlPage(`Cloud Accounts + + +

Cloud Camera Accounts

+

Link your camera vendor cloud accounts. Server syncs cameras + delivers streaming URLs to kiosks. Credentials stored encrypted — never leave the server.

+ +
+

Add Account

+
+
+ + +
+
+ + +
+
+ ${providers.map((p) => p.credentialFields().map((f) => + `
+ + +
` + ).join("")).join("")} +
+ +
+ +
+ +

Linked Accounts

+ ${accounts.length === 0 + ? '

No cloud accounts linked yet.

' + : ` + + + + + + + + + ${accounts.map((a) => ` + + + + + + `).join("")} + +
NameVendorCamerasLast Sync
${a.name}${VENDOR_LABELS[a.vendor as CloudVendor] ?? a.vendor}${a.camera_count} + ${a.last_sync_at ?? '—'} + ${a.last_sync_error ? `
${a.last_sync_error}` : ''} +
+
+ +
+
+ +
+
+ +
+
` + } +

← Back to Cameras

+ `); + }); + + app.post("/admin/cloud-accounts/add", async (event) => { + const body = await readBody>(event); + const vendor = (body?.["vendor"] ?? "").trim() as CloudVendor; + const name = (body?.["name"] ?? "").trim(); + if (!CLOUD_VENDORS.includes(vendor) || !name) { + throw createError({ statusCode: 400, statusMessage: "vendor + name required" }); + } + + const provider = getProvider(vendor); + if (!provider) throw createError({ statusCode: 400, statusMessage: `unknown vendor ${vendor}` }); + + // Extract credential fields. + const creds: Record = {}; + for (const f of provider.credentialFields()) { + const v = (body?.[`cred_${f.name}`] ?? "").trim(); + if (f.required && !v) throw createError({ statusCode: 400, statusMessage: `${f.label} is required` }); + if (v) creds[f.name] = v; + } + + // Test credentials. + const test = await provider.testCredentials(creds); + if (!test.ok) { + throw createError({ statusCode: 400, statusMessage: `Credential test failed: ${test.error}` }); + } + + // Store encrypted. + const encrypted = deps.secrets.encryptString(JSON.stringify(creds), "cloud-creds"); + await deps.repo.createCloudAccount({ + id: randomUUID(), + vendor, + name, + credentials_encrypted: encrypted, + }); + + return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + }); + + app.post("/admin/cloud-accounts/:id/sync", async (event) => { + const id = String(getRouterParam(event, "id")); + const account = await deps.repo.getCloudAccount(id); + if (!account) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + + const provider = getProvider(account.vendor as CloudVendor); + if (!provider) { + await deps.repo.updateCloudAccount(id, { last_sync_error: "unknown vendor" }); + return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + } + + let creds: Record; + try { + creds = JSON.parse(deps.secrets.decryptString(account.credentials_encrypted, "cloud-creds")); + } catch { + await deps.repo.updateCloudAccount(id, { last_sync_error: "credential decrypt failed" }); + return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + } + + try { + const cameras = await provider.listCameras(creds); + await deps.repo.updateCloudAccount(id, { + camera_count: cameras.length, + last_sync_at: new Date().toISOString(), + last_sync_error: null, + } as any); + } catch (err) { + await deps.repo.updateCloudAccount(id, { + last_sync_error: (err as Error).message, + last_sync_at: new Date().toISOString(), + } as any); + } + + return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + }); + + app.post("/admin/cloud-accounts/:id/import", async (event) => { + const id = String(getRouterParam(event, "id")); + const account = await deps.repo.getCloudAccount(id); + if (!account) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + + const provider = getProvider(account.vendor as CloudVendor); + if (!provider) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + + let creds: Record; + try { + creds = JSON.parse(deps.secrets.decryptString(account.credentials_encrypted, "cloud-creds")); + } catch { + return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + } + + const cameras = await provider.listCameras(creds); + let imported = 0; + for (const cam of cameras) { + if (!cam.rtsp_url && !cam.relay_url) continue; + // Check if already imported (by vendor_id in camera name prefix). + const existingName = `${account.name}: ${cam.name}`; + const existing = await deps.repo.getCameraByName(existingName); + if (existing) continue; + + await deps.repo.createCamera({ + name: existingName, + type: "rtsp", + rtsp_url: cam.rtsp_url ?? cam.relay_url ?? null, + }); + imported++; + } + + await deps.repo.updateCloudAccount(id, { + camera_count: cameras.length, + last_sync_at: new Date().toISOString(), + last_sync_error: null, + } as any); + + return new Response(null, { status: 302, headers: { location: `/admin/cloud-accounts` } }); + }); + + app.post("/admin/cloud-accounts/:id/delete", async (event) => { + const id = String(getRouterParam(event, "id")); + await deps.repo.deleteCloudAccount(id); + return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); + }); +} diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 1efb7b8..0a7724c 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -12,6 +12,8 @@ import type { AuditEntry, AuditResult, Camera, + CloudAccount, + CloudVendor, CameraStream, CameraType, CellContentType, @@ -452,3 +454,17 @@ export function rowToKioskLog(r: Row): KioskLog { received_at: s(r["received_at"]), }; } + +export function rowToCloudAccount(r: Row): CloudAccount { + return { + id: s(r["id"]), + vendor: s(r["vendor"]) as CloudVendor, + name: s(r["name"]), + credentials_encrypted: s(r["credentials_encrypted"]), + is_active: b(r["is_active"]), + last_sync_at: sn(r["last_sync_at"]), + last_sync_error: sn(r["last_sync_error"]), + camera_count: n(r["camera_count"]), + created_at: s(r["created_at"]), + }; +} diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 94f069f..2591765 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -20,6 +20,7 @@ import type { Camera, CameraStream, CameraType, + CloudAccount, Display, Entity, EntityType, @@ -59,6 +60,7 @@ import { rowToApiKey, rowToAuditEntry, rowToCamera, + rowToCloudAccount, rowToCameraStream, rowToDisplay, rowToEntity, @@ -2279,4 +2281,50 @@ export class Repository { await this._run(`UPDATE labels SET ${sets.join(", ")} WHERE id = ?`, vals); void this.notify("labels", "update", id); } + + // =========================================================================== + // cloud_accounts + // =========================================================================== + + async listCloudAccounts(): Promise { + const rs = await this._all("SELECT * FROM cloud_accounts ORDER BY vendor, name"); + return rs.map((r) => rowToCloudAccount(r as Record)); + } + + async getCloudAccount(id: string): Promise { + const r = await this._get("SELECT * FROM cloud_accounts WHERE id = ?", [id]); + return r ? rowToCloudAccount(r as Record) : null; + } + + async createCloudAccount(input: { + id: string; + vendor: string; + name: string; + credentials_encrypted: string; + }): Promise { + await this._run( + `INSERT INTO cloud_accounts (id, vendor, name, credentials_encrypted) VALUES (?, ?, ?, ?)`, + [input.id, input.vendor, input.name, input.credentials_encrypted], + ); + const a = await this.getCloudAccount(input.id); + if (!a) throw new Error("cloud account vanished after insert"); + return a; + } + + async updateCloudAccount(id: string, patch: Partial): Promise { + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(patch)) { + if (k === "id" || k === "created_at") continue; + sets.push(`${k} = ?`); + vals.push(v === undefined ? null : v); + } + if (sets.length === 0) return; + vals.push(id); + await this._run(`UPDATE cloud_accounts SET ${sets.join(", ")} WHERE id = ?`, vals); + } + + async deleteCloudAccount(id: string): Promise { + await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]); + } } diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index bbbf3ce..904c936 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -333,6 +333,20 @@ export interface OsUpdateRollout { created_by: number | null; } +export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink"; + +export interface CloudAccount { + id: string; + vendor: CloudVendor; + name: string; + credentials_encrypted: string; + is_active: boolean; + last_sync_at: string | null; + last_sync_error: string | null; + camera_count: number; + created_at: string; +} + export interface Label { id: number; name: string; diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index 1a2fe82..8a71d77 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -52,6 +52,7 @@ function Sidebar(props: { activeNav?: string }) { +