v1
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
**/Dockerfile
|
||||
**/compose.y*ml
|
||||
**/docker-compose*
|
||||
**/node_modules
|
||||
**/.gitignore
|
||||
**/.env
|
||||
**/.package-lock.json
|
||||
**/template.env
|
||||
README.md
|
||||
@@ -0,0 +1,15 @@
|
||||
# Server configuration
|
||||
PORT=3000
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# Sensorbox JWT and bootstrap token
|
||||
SENSORBOX_BOOTSTRAP_TOKEN=kota-bootstrap-token-v1
|
||||
SENSORBOX_JWT_SECRET=change-this-secret
|
||||
SENSORBOX_TOKEN_LIFETIME=30d
|
||||
|
||||
# Admin OIDC / Authentik configuration
|
||||
ADMIN_OIDC_ISSUER=https://auth.example.com
|
||||
ADMIN_OIDC_AUDIENCE=kota-admin
|
||||
ADMIN_OIDC_CLIENT_ID=kota-admin-client
|
||||
ADMIN_OIDC_CLIENT_SECRET=change-me
|
||||
ADMIN_OIDC_REDIRECT_URI=http://localhost:3000/admin/oidc/callback
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: "Workspace agent for building and evolving the kota-api-server TypeScript Express app using minimal dependencies"
|
||||
tools: [read, edit, search]
|
||||
user-invocable: true
|
||||
argument-hint: "Use this agent to scaffold or extend the KOTA API server with small dependency footprint"
|
||||
---
|
||||
|
||||
You are a workspace specialist for the `kota-api-server` project. Your job is to build and maintain the minimal TypeScript Express API server described in `README.md`, honoring the existing design for OTA updates, JWT-based sensorbox API auth, and the admin panel surface.
|
||||
|
||||
## Constraints
|
||||
|
||||
- DO NOT introduce heavy frameworks, large dependency stacks, or unnecessary abstractions.
|
||||
- DO NOT invent features beyond what the README describes.
|
||||
- ONLY use the smallest set of dependencies needed to deliver a working, maintainable app skeleton.
|
||||
|
||||
## Approach
|
||||
|
||||
1. Read and interpret `README.md` to understand the intended API responsibilities and auth flows.
|
||||
2. Scaffold or evolve the project using simple TypeScript + Express foundations.
|
||||
3. Prefer basic packages like `express`, `typescript`, and minimal JWT or config tooling when necessary.
|
||||
4. Keep solutions straightforward: implement the shape of OTA flows, group/version handling, and auth stubs without building complex custom platforms from scratch.
|
||||
|
||||
## Output Format
|
||||
|
||||
- Summarize the files created or changed.
|
||||
- List the primary app features implemented.
|
||||
- Highlight remaining areas that need user review or more detailed integration.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
data/
|
||||
build/
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
ARG NODE_VERSION=25.2.0
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY ./src ./src
|
||||
COPY ./tsconfig.json ./tsconfig.json
|
||||
COPY ./public ./public
|
||||
COPY package.json package.json
|
||||
COPY package-lock.json package-lock.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
CMD [ "node", "build/src/index.js" ]
|
||||
@@ -0,0 +1,61 @@
|
||||
# Serwer API KOTA
|
||||
|
||||
Serwer napisany w języku TypeScript korzystając z biblioteki express, którego głównym zadaniem jest obsługa sensorboxów KOTA. Jest odpowiedzialny za:
|
||||
|
||||
- Informacje o wersji urządzenia (hardware i software)
|
||||
- Aktualizacje OTA
|
||||
- Możliwość dzielenia oprogramowania na grupy (alpha, beta, release)
|
||||
|
||||
## Autentykacja
|
||||
|
||||
### Panel administracyjny
|
||||
|
||||
Dostęp do panelu administracyjnego jest zabezpieczony OIDC. Użytkownicy muszą się zalogować poprzez Authentik, aby uzyskać dostęp do funkcji administracyjnych.
|
||||
|
||||
### API dla sensorboxów
|
||||
|
||||
Sensorboxy komunikują się z serwerem API KOTA za pomocą tokenów JWT. Domyślnie sensorbox posiada token do pierwszego logowania, który jest wspólny dla wszystkich urządzeń. Po pierwszym logowaniu, serwer generuje unikalny token dla każdego sensorboxa, który jest używany do dalszej komunikacji.
|
||||
|
||||
## Aktualizacje OTA
|
||||
|
||||
Każde urządzenie raportuje swoją aktualną wersję software oraz hardware w zapytaniu o aktualizację. Serwer API KOTA porównuje te informacje z dostępnymi aktualizacjami i decyduje, czy urządzenie kwalifikuje się do aktualizacji. Jeśli aktualizacja jest dostępna, serwer zwraca link do pobrania nowej wersji oprogramowania. Sam link jest generowany dynamicznie, ważny na określony czas i jednorazowy. Po aktualizacji, urządzenie raportuje sukces lub niepowodzenie aktualizacji, co pozwala na monitorowanie procesu aktualizacji i identyfikowanie potencjalnych problemów.
|
||||
|
||||
## Grupy aktualizacji
|
||||
|
||||
Serwer API KOTA umożliwia dzielenie oprogramowania na grupy, takie jak alpha, beta i release. Każda grupa może mieć różne wersje oprogramowania, a urządzenia mogą być przypisane do konkretnej grupy. Dzięki temu można testować nowe funkcje na mniejszej grupie urządzeń przed udostępnieniem ich wszystkim użytkownikom.
|
||||
|
||||
## Różne urządzenia
|
||||
|
||||
Serwer posiada podział na urządzenia, co pozwala w przyszłości na obsługę różnych typów sensorboxów, z różnymi wymaganiami dotyczącymi aktualizacji i funkcjonalności. Każde urządzenie może mieć swoje unikalne ustawienia i wymagania dotyczące aktualizacji, co pozwala na bardziej elastyczne zarządzanie oprogramowaniem.
|
||||
|
||||
## Panel administracyjny
|
||||
|
||||
W panelu widać listę typów urządzeń, i po wejściu w konkretny typ można zobaczyć różne aktualizacje, które są dostępne dla tego typu urządzenia. Można również zarządzać grupami aktualizacji, przypisywać urządzenia do grup i monitorować status aktualizacji dla poszczególnych urządzeń. Panel administracyjny umożliwia również przeglądanie logów aktualizacji, co pozwala na identyfikowanie i rozwiązywanie problemów związanych z aktualizacjami oprogramowania.
|
||||
|
||||
## Uruchomienie
|
||||
|
||||
1. Skopiuj plik `.env.example` do `.env` i uzupełnij wartości.
|
||||
2. Zainstaluj zależności:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Uruchom serwer w trybie deweloperskim:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Skompiluj produkcyjną wersję:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Panel administracyjny
|
||||
|
||||
- Otwórz panel pod adresem `/admin/panel`
|
||||
- Użyj przycisku `Login with OIDC` do uwierzytelnienia
|
||||
- Token OIDC jest zapisywany w `localStorage` i wykorzystywany do żądań HTMX
|
||||
@@ -0,0 +1,13 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
kota-bin-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:3002:3000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/usr/src/app/data
|
||||
Generated
+2392
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "kota-api-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal TypeScript Express API server for KOTA sensorbox OTA and admin panel.",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwks-rsa": "^3.0.1",
|
||||
"multer": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.12.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KOTA Admin Panel</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.10"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f5f7fb;
|
||||
color: #111;
|
||||
}
|
||||
.app-shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #dbe2ee;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.alert.success {
|
||||
border-color: #a4d4ae;
|
||||
background: #ecf6ed;
|
||||
}
|
||||
.admin-panel-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
section {
|
||||
margin-top: 24px;
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e3e8f0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table th,
|
||||
table td {
|
||||
border-bottom: 1px solid #e3e8f0;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.admin-panel-card {
|
||||
min-width: 320px;
|
||||
}
|
||||
.control-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header>
|
||||
<div>
|
||||
<h1>KOTA Admin Panel</h1>
|
||||
<p>Use an admin bearer token to manage devices, releases, and OTA logs.</p>
|
||||
</div>
|
||||
<div class="control-bar">
|
||||
<div id="login-button"></div>
|
||||
<span id="token-status">Loading token...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="admin-messages" class="alert">Use OIDC login to access the admin panel.</div>
|
||||
|
||||
<div hx-get="/admin/html/device-types" hx-trigger="load" hx-swap="innerHTML"></div>
|
||||
<div hx-get="/admin/html/devices" hx-trigger="load, every 5s" hx-swap="innerHTML"></div>
|
||||
<div hx-get="/admin/html/logs" hx-trigger="load, every 5s" hx-swap="innerHTML"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let adminToken = localStorage.getItem("kotaAdminToken") || "";
|
||||
|
||||
function updateTokenStatus() {
|
||||
const status = document.getElementById("token-status");
|
||||
status.textContent = adminToken ? "Admin token loaded" : "No token configured";
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:configRequest", function (event) {
|
||||
if (adminToken) {
|
||||
event.detail.headers["Authorization"] = "Bearer " + adminToken;
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", function () {
|
||||
updateTokenStatus();
|
||||
});
|
||||
|
||||
window.addEventListener("load", function () {
|
||||
updateTokenStatus();
|
||||
if (!adminToken) {
|
||||
loginWithOidc();
|
||||
}
|
||||
setLoginButton(!!adminToken);
|
||||
});
|
||||
|
||||
function setLoginButton(isLoggedIn) {
|
||||
const loginButton = document.getElementById("login-button");
|
||||
loginButton.outerHTML = isLoggedIn ?
|
||||
`<button id="login-button" type="button" onclick="logout()">Logout</button>` :
|
||||
`<button id="login-button" type="button" onclick="loginWithOidc()">Login with OIDC</button>`;
|
||||
}
|
||||
|
||||
function loginWithOidc() {
|
||||
window.location.href = "/admin/oidc/login?returnUrl=" + encodeURIComponent(window.location.pathname);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
adminToken = "";
|
||||
localStorage.removeItem("kotaAdminToken");
|
||||
updateTokenStatus();
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { config } from "./config";
|
||||
import { DeviceRecord, getDeviceByIdDb } from "./data";
|
||||
|
||||
interface JwtPayload {
|
||||
deviceId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
interface AdminJwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
device?: DeviceRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateDeviceToken(deviceId: string): string {
|
||||
const secret: jwt.Secret = config.sensorboxJwtSecret;
|
||||
return jwt.sign({ deviceId }, secret, {
|
||||
expiresIn: config.sensorboxTokenLifetime as jwt.SignOptions["expiresIn"],
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyDeviceToken(token: string): DeviceRecord | null {
|
||||
try {
|
||||
const payload = jwt.verify(token, config.sensorboxJwtSecret) as JwtPayload;
|
||||
if (!payload.deviceId) {
|
||||
return null;
|
||||
}
|
||||
const device = getDeviceByIdDb(payload.deviceId);
|
||||
return device || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateAdminToken(payload: AdminJwtPayload): string {
|
||||
const secret: jwt.Secret = config.adminJwtSecret;
|
||||
return jwt.sign(payload, secret, {
|
||||
expiresIn: config.adminTokenLifetime as jwt.SignOptions["expiresIn"],
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyAdminToken(token: string): AdminJwtPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, config.adminJwtSecret) as AdminJwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sensorboxAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Missing sensorbox bearer token" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
if (token === config.sensorboxBootstrapToken) {
|
||||
res
|
||||
.status(401)
|
||||
.json({ error: "Bootstrap token can only be used for registration" });
|
||||
return;
|
||||
}
|
||||
|
||||
const device = verifyDeviceToken(token);
|
||||
if (!device) {
|
||||
res.status(401).json({ error: "Invalid sensorbox token" });
|
||||
return;
|
||||
}
|
||||
|
||||
req.device = device;
|
||||
next();
|
||||
}
|
||||
|
||||
export async function adminAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Missing admin bearer token" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const payload = verifyAdminToken(token);
|
||||
if (!payload) {
|
||||
res.status(401).json({ error: "Invalid admin token" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
|
||||
export const config = {
|
||||
port: PORT,
|
||||
sensorboxBootstrapToken:
|
||||
process.env.SENSORBOX_BOOTSTRAP_TOKEN || "kota-bootstrap-token-v1",
|
||||
sensorboxJwtSecret: process.env.SENSORBOX_JWT_SECRET || "kota-jwt-secret",
|
||||
sensorboxTokenLifetime: process.env.SENSORBOX_TOKEN_LIFETIME || "30d",
|
||||
adminJwtSecret: process.env.ADMIN_JWT_SECRET || "kota-admin-jwt-secret",
|
||||
adminTokenLifetime: process.env.ADMIN_TOKEN_LIFETIME || "4h",
|
||||
adminOidcIssuer: process.env.ADMIN_OIDC_ISSUER || "",
|
||||
adminOidcClientId: process.env.ADMIN_OIDC_CLIENT_ID || "",
|
||||
adminOidcClientSecret: process.env.ADMIN_OIDC_CLIENT_SECRET || "",
|
||||
adminOidcRedirectUri:
|
||||
process.env.ADMIN_OIDC_REDIRECT_URI ||
|
||||
`http://localhost:${PORT}/admin/oidc/callback`,
|
||||
baseUrl: process.env.BASE_URL || `http://localhost:${PORT}`,
|
||||
};
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type DeviceGroup = "alpha" | "beta" | "release";
|
||||
|
||||
export interface DeviceType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
supportedGroups: DeviceGroup[];
|
||||
}
|
||||
|
||||
export interface UpdateRelease {
|
||||
id: string;
|
||||
type: string;
|
||||
group: DeviceGroup;
|
||||
version: string;
|
||||
hardwareVersion: string;
|
||||
notes: string;
|
||||
filePath: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DeviceRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
group: DeviceGroup;
|
||||
token: string;
|
||||
registeredAt: string;
|
||||
currentVersion?: string;
|
||||
hardwareVersion?: string;
|
||||
lastSeen?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLog {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
updateId: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DownloadLink {
|
||||
id: string;
|
||||
updateId: string;
|
||||
deviceId: string;
|
||||
expiresAt: number;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const randomId = () =>
|
||||
Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
|
||||
export const deviceGroups: DeviceGroup[] = ["alpha", "beta", "release"];
|
||||
|
||||
export const deviceTypes: DeviceType[] = [
|
||||
{
|
||||
id: "kota-sensorbox",
|
||||
name: "KOTA Sensorbox",
|
||||
description: "Standardowy sensor box",
|
||||
supportedGroups: ["alpha", "beta", "release"],
|
||||
},
|
||||
];
|
||||
|
||||
const dbDirectory = path.join(process.cwd(), "data");
|
||||
if (!fs.existsSync(dbDirectory)) {
|
||||
fs.mkdirSync(dbDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dbDirectory, "kota.db");
|
||||
const db = new Database(dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS releases (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
group_name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
hardwareVersion TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
filePath TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
group_name TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
registeredAt TEXT NOT NULL,
|
||||
currentVersion TEXT,
|
||||
hardwareVersion TEXT,
|
||||
lastSeen TEXT,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS update_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
deviceId TEXT NOT NULL,
|
||||
updateId TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
updateId TEXT NOT NULL,
|
||||
deviceId TEXT NOT NULL,
|
||||
expiresAt INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
export function generateId(): string {
|
||||
return randomId();
|
||||
}
|
||||
|
||||
export function compareVersion(a: string, b: string): number {
|
||||
const partsA = a.split(/[.-]/g).map((v) => parseInt(v, 10) || 0);
|
||||
const partsB = b.split(/[.-]/g).map((v) => parseInt(v, 10) || 0);
|
||||
const length = Math.max(partsA.length, partsB.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const x = partsA[i] || 0;
|
||||
const y = partsB[i] || 0;
|
||||
if (x > y) return 1;
|
||||
if (x < y) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function createReleaseDb(
|
||||
release: Omit<UpdateRelease, "id" | "createdAt">,
|
||||
): UpdateRelease {
|
||||
const entry: UpdateRelease = {
|
||||
id: generateId(),
|
||||
createdAt: nowIso(),
|
||||
...release,
|
||||
};
|
||||
db.prepare(
|
||||
"INSERT INTO releases (id, type, group_name, version, hardwareVersion, notes, filePath, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(
|
||||
entry.id,
|
||||
entry.type,
|
||||
entry.group,
|
||||
entry.version,
|
||||
entry.hardwareVersion,
|
||||
entry.notes,
|
||||
entry.filePath,
|
||||
entry.createdAt,
|
||||
);
|
||||
return entry;
|
||||
}
|
||||
|
||||
const releaseRowToObject = (row: any): UpdateRelease => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
group: row.group_name as DeviceGroup,
|
||||
version: row.version,
|
||||
hardwareVersion: row.hardwareVersion,
|
||||
notes: row.notes,
|
||||
filePath: row.filePath,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
|
||||
const deviceRowToObject = (row: any): DeviceRecord => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
group: row.group_name as DeviceGroup,
|
||||
token: row.token,
|
||||
registeredAt: row.registeredAt,
|
||||
currentVersion: row.currentVersion || undefined,
|
||||
hardwareVersion: row.hardwareVersion || undefined,
|
||||
lastSeen: row.lastSeen || undefined,
|
||||
status: row.status || undefined,
|
||||
});
|
||||
|
||||
const updateLogRowToObject = (row: any): UpdateLog => ({
|
||||
id: row.id,
|
||||
deviceId: row.deviceId,
|
||||
updateId: row.updateId,
|
||||
success: row.success === 1,
|
||||
message: row.message,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
|
||||
const downloadLinkRowToObject = (row: any): DownloadLink => ({
|
||||
id: row.id,
|
||||
updateId: row.updateId,
|
||||
deviceId: row.deviceId,
|
||||
expiresAt: row.expiresAt,
|
||||
used: row.used === 1,
|
||||
});
|
||||
|
||||
export function getAllReleases(): UpdateRelease[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM releases ORDER BY createdAt DESC")
|
||||
.all()
|
||||
.map(releaseRowToObject);
|
||||
}
|
||||
|
||||
export function getReleasesByType(type: string): UpdateRelease[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM releases WHERE type = ? ORDER BY createdAt DESC")
|
||||
.all(type)
|
||||
.map(releaseRowToObject);
|
||||
}
|
||||
|
||||
export function getUpdateByIdDb(updateId: string): UpdateRelease | undefined {
|
||||
const row = db.prepare("SELECT * FROM releases WHERE id = ?").get(updateId);
|
||||
return row ? releaseRowToObject(row) : undefined;
|
||||
}
|
||||
|
||||
export function updateReleaseGroupDb(
|
||||
updateId: string,
|
||||
group: DeviceGroup,
|
||||
): UpdateRelease | undefined {
|
||||
db.prepare("UPDATE releases SET group_name = ? WHERE id = ?").run(
|
||||
group,
|
||||
updateId,
|
||||
);
|
||||
return getUpdateByIdDb(updateId);
|
||||
}
|
||||
|
||||
export function deleteReleaseDb(updateId: string): boolean {
|
||||
const result = db.prepare("DELETE FROM releases WHERE id = ?").run(updateId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
const fallbackGroups: Record<DeviceGroup, DeviceGroup[]> = {
|
||||
alpha: ["alpha", "beta", "release"],
|
||||
beta: ["beta", "release"],
|
||||
release: ["release"],
|
||||
};
|
||||
|
||||
export function findLatestReleaseDb(
|
||||
type: string,
|
||||
group: DeviceGroup,
|
||||
hardwareVersion: string,
|
||||
): UpdateRelease | undefined {
|
||||
const groups = fallbackGroups[group] ?? [group];
|
||||
const placeholders = groups.map(() => "?").join(", ");
|
||||
|
||||
const candidates: UpdateRelease[] = db
|
||||
.prepare(
|
||||
`SELECT * FROM releases WHERE type = ? AND group_name IN (${placeholders}) AND hardwareVersion = ?`,
|
||||
)
|
||||
.all(type, ...groups, hardwareVersion)
|
||||
.map(releaseRowToObject);
|
||||
|
||||
return candidates.sort((a, b) => compareVersion(b.version, a.version))[0];
|
||||
}
|
||||
|
||||
export function saveDevice(device: DeviceRecord): DeviceRecord {
|
||||
db.prepare(
|
||||
"INSERT INTO devices (id, type, group_name, token, registeredAt, currentVersion, hardwareVersion, lastSeen, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(id) DO UPDATE SET type=excluded.type, group_name=excluded.group_name, token=excluded.token, currentVersion=excluded.currentVersion, hardwareVersion=excluded.hardwareVersion, lastSeen=excluded.lastSeen, status=excluded.status",
|
||||
).run(
|
||||
device.id,
|
||||
device.type,
|
||||
device.group,
|
||||
device.token,
|
||||
device.registeredAt,
|
||||
device.currentVersion || null,
|
||||
device.hardwareVersion || null,
|
||||
device.lastSeen || null,
|
||||
device.status || null,
|
||||
);
|
||||
return device;
|
||||
}
|
||||
|
||||
export function getDeviceByIdDb(deviceId: string): DeviceRecord | undefined {
|
||||
const row = db.prepare("SELECT * FROM devices WHERE id = ?").get(deviceId);
|
||||
return row ? deviceRowToObject(row) : undefined;
|
||||
}
|
||||
|
||||
export function deleteDeviceDb(deviceId: string): boolean {
|
||||
const result = db.prepare("DELETE FROM devices WHERE id = ?").run(deviceId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getAllDevices(): DeviceRecord[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM devices ORDER BY lastSeen DESC")
|
||||
.all()
|
||||
.map(deviceRowToObject);
|
||||
}
|
||||
|
||||
export function createDownloadLinkDb(
|
||||
deviceId: string,
|
||||
updateId: string,
|
||||
durationSeconds = 600,
|
||||
): DownloadLink {
|
||||
const link: DownloadLink = {
|
||||
id: randomId(),
|
||||
updateId,
|
||||
deviceId,
|
||||
expiresAt: Date.now() + durationSeconds * 1000,
|
||||
used: false,
|
||||
};
|
||||
db.prepare(
|
||||
"INSERT INTO download_links (id, updateId, deviceId, expiresAt, used) VALUES (?, ?, ?, ?, ?)",
|
||||
).run(link.id, link.updateId, link.deviceId, link.expiresAt, 0);
|
||||
return link;
|
||||
}
|
||||
|
||||
export function getDownloadLinkByIdDb(
|
||||
linkId: string,
|
||||
): DownloadLink | undefined {
|
||||
const row = db
|
||||
.prepare("SELECT * FROM download_links WHERE id = ?")
|
||||
.get(linkId);
|
||||
return row ? downloadLinkRowToObject(row) : undefined;
|
||||
}
|
||||
|
||||
export function markLinkUsedDb(linkId: string): void {
|
||||
db.prepare("UPDATE download_links SET used = 1 WHERE id = ?").run(linkId);
|
||||
}
|
||||
|
||||
export function registerUpdateLogDb(
|
||||
deviceId: string,
|
||||
updateId: string,
|
||||
success: boolean,
|
||||
message: string,
|
||||
): void {
|
||||
db.prepare(
|
||||
"INSERT INTO update_logs (id, deviceId, updateId, success, message, createdAt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(generateId(), deviceId, updateId, success ? 1 : 0, message, nowIso());
|
||||
}
|
||||
|
||||
export function getUpdateLogsDb(limit = 50): UpdateLog[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM update_logs ORDER BY createdAt DESC LIMIT ?")
|
||||
.all(limit)
|
||||
.map(updateLogRowToObject);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import express, { json, urlencoded } from "express";
|
||||
import path from "path";
|
||||
import { config } from "./config";
|
||||
import { adminRouter } from "./routes/admin";
|
||||
import { adminOidcRouter } from "./routes/adminOidc";
|
||||
import { adminPanelRouter } from "./routes/adminPanel";
|
||||
import { downloadRouter } from "./routes/download";
|
||||
import { sensorboxRouter } from "./routes/sensorbox";
|
||||
|
||||
const app = express();
|
||||
app.use(json());
|
||||
app.use(urlencoded({ extended: false }));
|
||||
|
||||
const publicDir = path.join(process.cwd(), "public");
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
const preferred = req.accepts(["html", "json"]);
|
||||
const userAgent = req.get("User-Agent") || "";
|
||||
const browserAgent = /Mozilla|Chrome|Safari|Firefox|Edge|Opera/i.test(
|
||||
userAgent,
|
||||
);
|
||||
|
||||
if (preferred === "html" || browserAgent) {
|
||||
return res.redirect("/admin/oidc/login?returnUrl=/admin/panel");
|
||||
}
|
||||
|
||||
res.json({
|
||||
service: "KOTA API Server",
|
||||
description: "Sensorbox OTA and admin API",
|
||||
version: "0.1.0",
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/admin/panel", (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, "admin.html"));
|
||||
});
|
||||
|
||||
app.use("/admin/oidc", adminOidcRouter);
|
||||
app.use("/admin/html", adminPanelRouter);
|
||||
app.use("/admin", adminRouter);
|
||||
app.use("/sensorbox", sensorboxRouter);
|
||||
app.use("/download", downloadRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`KOTA API server listening on port ${config.port}`);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import express from "express";
|
||||
import { adminAuth } from "../auth";
|
||||
import {
|
||||
createReleaseDb,
|
||||
deleteReleaseDb,
|
||||
deviceGroups,
|
||||
deviceTypes,
|
||||
getAllDevices,
|
||||
getReleasesByType,
|
||||
getUpdateLogsDb,
|
||||
getDeviceByIdDb,
|
||||
saveDevice,
|
||||
updateReleaseGroupDb,
|
||||
} from "../data";
|
||||
|
||||
const router = express.Router();
|
||||
router.use(adminAuth);
|
||||
|
||||
router.get("/device-types", (_req, res) => {
|
||||
res.json({ deviceTypes, groups: deviceGroups });
|
||||
});
|
||||
|
||||
router.get("/device-types/:type/updates", (req, res) => {
|
||||
const type = req.params.type;
|
||||
const updates = getReleasesByType(type);
|
||||
res.json({ updates });
|
||||
});
|
||||
|
||||
router.post("/device-types/:type/updates", (req, res) => {
|
||||
const type = req.params.type;
|
||||
const deviceType = deviceTypes.find((entry) => entry.id === type);
|
||||
|
||||
if (!deviceType) {
|
||||
res.status(400).json({ error: "Unknown device type" });
|
||||
return;
|
||||
}
|
||||
|
||||
const group = String(req.body.group || "release");
|
||||
const version = String(req.body.version || "").trim();
|
||||
const hardwareVersion = String(req.body.hardwareVersion || "").trim();
|
||||
const notes = String(req.body.notes || "");
|
||||
const filePath = String(req.body.filePath || "").trim();
|
||||
|
||||
if (!version || !hardwareVersion) {
|
||||
res.status(400).json({ error: "version and hardwareVersion are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!deviceType.supportedGroups.includes(group as (typeof deviceGroups)[number])
|
||||
) {
|
||||
res.status(400).json({ error: "Unsupported group for this device type" });
|
||||
return;
|
||||
}
|
||||
|
||||
const release = createReleaseDb({
|
||||
type,
|
||||
group: group as (typeof deviceGroups)[number],
|
||||
version,
|
||||
hardwareVersion,
|
||||
notes,
|
||||
filePath,
|
||||
});
|
||||
|
||||
res.status(201).json({ release });
|
||||
});
|
||||
|
||||
router.patch("/device-types/:type/updates/:updateId/group", (req, res) => {
|
||||
const type = req.params.type;
|
||||
const updateId = req.params.updateId;
|
||||
const deviceType = deviceTypes.find((entry) => entry.id === type);
|
||||
|
||||
if (!deviceType) {
|
||||
res.status(400).json({ error: "Unknown device type" });
|
||||
return;
|
||||
}
|
||||
|
||||
const group = String(
|
||||
req.body.group || "",
|
||||
).trim() as (typeof deviceGroups)[number];
|
||||
if (!deviceType.supportedGroups.includes(group)) {
|
||||
res.status(400).json({ error: "Unsupported group for this device type" });
|
||||
return;
|
||||
}
|
||||
|
||||
const release = updateReleaseGroupDb(updateId, group);
|
||||
if (!release) {
|
||||
res.status(404).json({ error: "Release not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ release });
|
||||
});
|
||||
|
||||
router.delete("/device-types/:type/updates/:updateId", (req, res) => {
|
||||
const updateId = req.params.updateId;
|
||||
const deleted = deleteReleaseDb(updateId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: "Release not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
router.get("/devices", (_req, res) => {
|
||||
res.json({ devices: getAllDevices() });
|
||||
});
|
||||
|
||||
router.patch("/devices/:deviceId/group", (req, res) => {
|
||||
const deviceId = req.params.deviceId;
|
||||
const group = String(
|
||||
req.body.group || "",
|
||||
).trim() as (typeof deviceGroups)[number];
|
||||
|
||||
const device = getDeviceByIdDb(deviceId);
|
||||
if (!device) {
|
||||
res.status(404).json({ error: "Device not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceGroups.includes(group)) {
|
||||
res.status(400).json({ error: "Invalid update group" });
|
||||
return;
|
||||
}
|
||||
|
||||
device.group = group;
|
||||
saveDevice(device);
|
||||
res.json({ device });
|
||||
});
|
||||
|
||||
router.get("/logs", (_req, res) => {
|
||||
res.json({ logs: getUpdateLogsDb() });
|
||||
});
|
||||
|
||||
export { router as adminRouter };
|
||||
@@ -0,0 +1,212 @@
|
||||
import express from "express";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import jwksRsa from "jwks-rsa";
|
||||
import crypto from "crypto";
|
||||
import { config } from "../config";
|
||||
import { generateAdminToken } from "../auth";
|
||||
|
||||
const router = express.Router();
|
||||
const stateStore = new Map<string, string>();
|
||||
let oidcConfigCache: {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksUri: string;
|
||||
issuer: string;
|
||||
} | null = null;
|
||||
|
||||
async function getOidcConfig(): Promise<{
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksUri: string;
|
||||
issuer: string;
|
||||
}> {
|
||||
if (oidcConfigCache) {
|
||||
return oidcConfigCache;
|
||||
}
|
||||
|
||||
if (!config.adminOidcIssuer) {
|
||||
throw new Error("OIDC issuer is not configured");
|
||||
}
|
||||
|
||||
const discoveryUrl = `${config.adminOidcIssuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
|
||||
const response = await fetch(discoveryUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to fetch OIDC configuration from ${discoveryUrl}`);
|
||||
}
|
||||
|
||||
const metadata = await response.json();
|
||||
if (
|
||||
!metadata.authorization_endpoint ||
|
||||
!metadata.token_endpoint ||
|
||||
!metadata.jwks_uri ||
|
||||
!metadata.issuer
|
||||
) {
|
||||
throw new Error("OIDC discovery document is missing required endpoints");
|
||||
}
|
||||
|
||||
oidcConfigCache = {
|
||||
authorizationEndpoint: metadata.authorization_endpoint,
|
||||
tokenEndpoint: metadata.token_endpoint,
|
||||
jwksUri: metadata.jwks_uri,
|
||||
issuer: metadata.issuer,
|
||||
};
|
||||
|
||||
return oidcConfigCache;
|
||||
}
|
||||
|
||||
function ensureOidcEnabled(): void {
|
||||
if (
|
||||
!config.adminOidcIssuer ||
|
||||
!config.adminOidcClientId ||
|
||||
!config.adminOidcClientSecret
|
||||
) {
|
||||
throw new Error("OIDC login is not properly configured");
|
||||
}
|
||||
}
|
||||
|
||||
function createJwksClient(jwksUri: string) {
|
||||
return jwksRsa({
|
||||
jwksUri,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 10,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveProviderSigningKey(
|
||||
kid: string | undefined,
|
||||
): Promise<string> {
|
||||
const oidcConfig = await getOidcConfig();
|
||||
const client = createJwksClient(oidcConfig.jwksUri);
|
||||
const key = kid
|
||||
? await client.getSigningKey(kid)
|
||||
: await client.getSigningKey(null);
|
||||
return key.getPublicKey();
|
||||
}
|
||||
|
||||
router.get("/login", async (req, res) => {
|
||||
try {
|
||||
ensureOidcEnabled();
|
||||
const oidcConfig = await getOidcConfig();
|
||||
const state = crypto.randomUUID();
|
||||
const returnUrl = String(req.query.returnUrl || "/admin/panel");
|
||||
|
||||
stateStore.set(state, returnUrl);
|
||||
const authorizeUrl = new URL(oidcConfig.authorizationEndpoint);
|
||||
authorizeUrl.searchParams.set("client_id", config.adminOidcClientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", config.adminOidcRedirectUri);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("scope", "openid profile email");
|
||||
authorizeUrl.searchParams.set("state", state);
|
||||
|
||||
res.redirect(authorizeUrl.toString());
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: "OIDC login is not available",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/callback", async (req, res) => {
|
||||
try {
|
||||
ensureOidcEnabled();
|
||||
const code = String(req.query.code || "");
|
||||
const state = String(req.query.state || "");
|
||||
|
||||
if (!code || !state) {
|
||||
res
|
||||
.status(400)
|
||||
.send("Missing OIDC authorization code or returned state.");
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = stateStore.get(state) || "/admin/panel";
|
||||
stateStore.delete(state);
|
||||
|
||||
const oidcConfig = await getOidcConfig();
|
||||
const tokenResponse = await fetch(oidcConfig.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${config.adminOidcClientId}:${config.adminOidcClientSecret}`).toString("base64")}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: config.adminOidcRedirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorPayload = await tokenResponse.text();
|
||||
res.status(502).send(`OIDC token exchange failed: ${errorPayload}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
const idToken = String(tokens.id_token || "");
|
||||
|
||||
if (!idToken) {
|
||||
res.status(502).send("OIDC provider did not return an ID token.");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(idToken, { complete: true }) as {
|
||||
header?: jwt.JwtHeader;
|
||||
} | null;
|
||||
|
||||
if (!decoded?.header) {
|
||||
res.status(502).send("Unable to decode provider ID token header.");
|
||||
return;
|
||||
}
|
||||
|
||||
const providerSigningKey = await resolveProviderSigningKey(
|
||||
decoded.header.kid,
|
||||
);
|
||||
|
||||
let verifiedPayload: jwt.JwtPayload | string | undefined;
|
||||
try {
|
||||
verifiedPayload = jwt.verify(idToken, providerSigningKey, {
|
||||
algorithms: ["RS256", "RS384", "RS512"],
|
||||
audience: config.adminOidcClientId || undefined,
|
||||
issuer: oidcConfig.issuer,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
res.status(401).send(`OIDC token verification failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof verifiedPayload === "string") {
|
||||
res.status(502).send("OIDC provider returned invalid token payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
const adminToken = generateAdminToken({
|
||||
sub: String(verifiedPayload.sub || ""),
|
||||
email: String(verifiedPayload.email || ""),
|
||||
name: String(verifiedPayload.name || ""),
|
||||
});
|
||||
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<title>KOTA OIDC Login</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
localStorage.setItem("kotaAdminToken", ${JSON.stringify(adminToken)});
|
||||
window.location.href = ${JSON.stringify(returnUrl)};
|
||||
</script>
|
||||
<p>Logging in…</p>
|
||||
</body>
|
||||
</html>`);
|
||||
} catch (error) {
|
||||
res.status(500).send(`OIDC callback failure: ${(error as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as adminOidcRouter };
|
||||
@@ -0,0 +1,332 @@
|
||||
import express from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import multer from "multer";
|
||||
import { adminAuth } from "../auth";
|
||||
import {
|
||||
createReleaseDb,
|
||||
deleteReleaseDb,
|
||||
deviceGroups,
|
||||
deviceTypes,
|
||||
getAllDevices,
|
||||
getAllReleases,
|
||||
getReleasesByType,
|
||||
getUpdateLogsDb,
|
||||
getUpdateByIdDb,
|
||||
deleteDeviceDb,
|
||||
updateReleaseGroupDb,
|
||||
getDeviceByIdDb,
|
||||
saveDevice,
|
||||
DeviceRecord,
|
||||
UpdateRelease,
|
||||
} from "../data";
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "data", "uploads");
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const upload = multer({ dest: uploadDir });
|
||||
const router = express.Router();
|
||||
router.use(adminAuth);
|
||||
|
||||
function renderDeviceRow(device: DeviceRecord): string {
|
||||
const groupOptions = deviceGroups
|
||||
.map(
|
||||
(group) =>
|
||||
`<option value="${group}" ${group === device.group ? "selected" : ""}>${group}</option>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<tr id="device-row-${device.id}">
|
||||
<td>${device.id}</td>
|
||||
<td>${device.type}</td>
|
||||
<td>${device.hardwareVersion || "-"}</td>
|
||||
<td>${device.currentVersion || "-"}</td>
|
||||
<td>${device.group}</td>
|
||||
<td>${device.status || "-"}</td>
|
||||
<td>${device.lastSeen || "-"}</td>
|
||||
<td>
|
||||
<form hx-patch="/admin/html/devices/${device.id}/group" hx-target="#device-row-${device.id}" hx-swap="outerHTML">
|
||||
<select name="group">${groupOptions}</select>
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
<button hx-delete="/admin/html/devices/${device.id}" hx-target="#device-row-${device.id}" hx-swap="outerHTML" style="display:inline">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUpdateRow(update: UpdateRelease): string {
|
||||
const releaseType = deviceTypes.find((item) => item.id === update.type);
|
||||
const groupOptions = (releaseType?.supportedGroups || deviceGroups)
|
||||
.map(
|
||||
(group) =>
|
||||
`<option value="${group}" ${group === update.group ? "selected" : ""}>${group}</option>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<tr id="update-row-${update.id}">
|
||||
<td>${update.id}</td>
|
||||
<td>${update.type}</td>
|
||||
<td>
|
||||
<form hx-patch="/admin/html/releases/${update.id}/group" hx-target="#update-row-${update.id}" hx-swap="outerHTML">
|
||||
<select name="group">${groupOptions}</select>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>${update.version}</td>
|
||||
<td>${update.hardwareVersion}</td>
|
||||
<td>${update.notes}</td>
|
||||
<td>${update.createdAt}</td>
|
||||
<td>
|
||||
<button hx-delete="/admin/html/releases/${update.id}" hx-target="#update-row-${update.id}" hx-swap="outerHTML">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
router.get("/device-types", (_req, res) => {
|
||||
const types = deviceTypes
|
||||
.map(
|
||||
(type) =>
|
||||
`<tr>
|
||||
<td>${type.id}</td>
|
||||
<td>${type.name}</td>
|
||||
<td>${type.description}</td>
|
||||
<td>${type.supportedGroups.join(", ")}</td>
|
||||
<td><button hx-get="/admin/html/updates?type=${type.id}" hx-target="#update-list" hx-swap="innerHTML">Show updates</button></td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const releaseOptions = deviceTypes
|
||||
.map((type) => `<option value="${type.id}">${type.id}</option>`)
|
||||
.join("");
|
||||
|
||||
const groupOptions = deviceGroups
|
||||
.map((group) => `<option value="${group}">${group}</option>`)
|
||||
.join("");
|
||||
|
||||
res.send(`
|
||||
<section>
|
||||
<h2>Device types</h2>
|
||||
<div class="admin-panel-row">
|
||||
<div class="admin-panel-column">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Name</th><th>Description</th><th>Groups</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>${types}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="admin-panel-column admin-panel-card">
|
||||
<h3>Create release</h3>
|
||||
<form hx-post="/admin/html/releases" hx-target="#admin-messages" hx-swap="innerHTML" enctype="multipart/form-data" hx-encoding="multipart/form-data">
|
||||
<label>Device type
|
||||
<select name="type">${releaseOptions}</select>
|
||||
</label>
|
||||
<label>Group
|
||||
<select name="group">${groupOptions}</select>
|
||||
</label>
|
||||
<label>Version
|
||||
<input name="version" type="text" required />
|
||||
</label>
|
||||
<label>Hardware version
|
||||
<input name="hardwareVersion" type="text" required />
|
||||
</label>
|
||||
<label>Release notes
|
||||
<textarea name="notes"></textarea>
|
||||
</label>
|
||||
<label>Release file
|
||||
<input name="releaseFile" type="file" required />
|
||||
</label>
|
||||
<button type="submit">Create release</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="update-list"></div>
|
||||
</section>
|
||||
`);
|
||||
});
|
||||
|
||||
router.get("/updates", (req, res) => {
|
||||
const type = String(req.query.type || "");
|
||||
const filtered = type ? getReleasesByType(type) : getAllReleases();
|
||||
|
||||
const rows = filtered.map(renderUpdateRow).join("");
|
||||
res.send(`
|
||||
<section>
|
||||
<h2>Release list ${type ? `for ${type}` : ""}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Id</th><th>Type</th><th>Group</th><th>Version</th><th>Hardware</th><th>Notes</th><th>Created at</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
`);
|
||||
});
|
||||
|
||||
router.get("/devices", (_req, res) => {
|
||||
const rows = getAllDevices().map(renderDeviceRow).join("");
|
||||
res.send(`
|
||||
<section>
|
||||
<h2>Registered devices</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Type</th><th>Hardware</th><th>Software</th><th>Group</th><th>Status</th><th>Last seen</th><th>Action</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
`);
|
||||
});
|
||||
|
||||
router.patch("/devices/:deviceId/group", (req, res) => {
|
||||
const deviceId = req.params.deviceId;
|
||||
const group = String(
|
||||
req.body.group || "",
|
||||
).trim() as (typeof deviceGroups)[number];
|
||||
const device = getDeviceByIdDb(deviceId);
|
||||
|
||||
if (!device) {
|
||||
res.status(404).send(`<div class="alert">Device not found.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceGroups.includes(group)) {
|
||||
res.status(400).send(`<div class="alert">Invalid group selection.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
device.group = group;
|
||||
saveDevice(device);
|
||||
res.send(renderDeviceRow(device));
|
||||
});
|
||||
|
||||
router.delete("/devices/:deviceId", (req, res) => {
|
||||
const deviceId = req.params.deviceId;
|
||||
const device = getDeviceByIdDb(deviceId);
|
||||
|
||||
if (!device) {
|
||||
res.status(404).send(`<div class="alert">Device not found.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteDeviceDb(deviceId);
|
||||
res.send("");
|
||||
});
|
||||
|
||||
router.patch("/releases/:releaseId/group", (req, res) => {
|
||||
const releaseId = req.params.releaseId;
|
||||
const group = String(
|
||||
req.body.group || "",
|
||||
).trim() as (typeof deviceGroups)[number];
|
||||
const release = getUpdateByIdDb(releaseId);
|
||||
|
||||
if (!release) {
|
||||
res.status(404).send(`<div class="alert">Release not found.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceType = deviceTypes.find((entry) => entry.id === release.type);
|
||||
if (!deviceType || !deviceType.supportedGroups.includes(group)) {
|
||||
res.status(400).send(`<div class="alert">Invalid group selection.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = updateReleaseGroupDb(releaseId, group);
|
||||
if (!updated) {
|
||||
res.status(404).send(`<div class="alert">Release not found.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(renderUpdateRow(updated));
|
||||
});
|
||||
|
||||
router.delete("/releases/:releaseId", (req, res) => {
|
||||
const releaseId = req.params.releaseId;
|
||||
const deleted = deleteReleaseDb(releaseId);
|
||||
if (!deleted) {
|
||||
res.status(404).send(`<div class="alert">Release not found.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.send("");
|
||||
});
|
||||
|
||||
router.get("/logs", (_req, res) => {
|
||||
const rows = getUpdateLogsDb()
|
||||
.map(
|
||||
(log) => `
|
||||
<tr>
|
||||
<td>${log.createdAt}</td>
|
||||
<td>${log.deviceId}</td>
|
||||
<td>${log.updateId}</td>
|
||||
<td>${log.success}</td>
|
||||
<td>${log.message}</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
res.send(`
|
||||
<section>
|
||||
<h2>OTA update logs</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>When</th><th>Device</th><th>Update ID</th><th>Success</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
`);
|
||||
});
|
||||
|
||||
router.post("/releases", upload.single("releaseFile"), (req, res) => {
|
||||
const type = String(req.body.type || "").trim();
|
||||
const group = String(
|
||||
req.body.group || "release",
|
||||
).trim() as (typeof deviceGroups)[number];
|
||||
const version = String(req.body.version || "").trim();
|
||||
const hardwareVersion = String(req.body.hardwareVersion || "").trim();
|
||||
const notes = String(req.body.notes || "").trim();
|
||||
const file = req.file;
|
||||
|
||||
if (!type || !version || !hardwareVersion || !file) {
|
||||
res
|
||||
.status(400)
|
||||
.send(
|
||||
`<div class="alert">Type, version, hardware version and release file are required.</div>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceType = deviceTypes.find((entry) => entry.id === type);
|
||||
if (!deviceType || !deviceType.supportedGroups.includes(group)) {
|
||||
res
|
||||
.status(400)
|
||||
.send(`<div class="alert">Invalid device type or update group.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const release = createReleaseDb({
|
||||
type,
|
||||
group,
|
||||
version,
|
||||
hardwareVersion,
|
||||
notes,
|
||||
filePath: file.path,
|
||||
});
|
||||
|
||||
res.send(`
|
||||
<div class="alert success">Release ${release.version} created for ${release.type}.</div>
|
||||
`);
|
||||
});
|
||||
|
||||
export { router as adminPanelRouter };
|
||||
@@ -0,0 +1,50 @@
|
||||
import express from "express";
|
||||
import {
|
||||
getDownloadLinkByIdDb,
|
||||
getUpdateByIdDb,
|
||||
markLinkUsedDb,
|
||||
} from "../data";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/:linkId", (req, res) => {
|
||||
const { linkId } = req.params;
|
||||
const link = getDownloadLinkByIdDb(linkId);
|
||||
|
||||
if (!link) {
|
||||
res.status(404).json({ error: "Download link not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (link.used) {
|
||||
res.status(410).json({ error: "Download link has already been used" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() > link.expiresAt) {
|
||||
res.status(410).json({ error: "Download link has expired" });
|
||||
return;
|
||||
}
|
||||
|
||||
const update = getUpdateByIdDb(link.updateId);
|
||||
if (!update) {
|
||||
res.status(404).json({ error: "Update release not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
markLinkUsedDb(linkId);
|
||||
res.download(
|
||||
update.filePath,
|
||||
`${update.type}-${update.version}.bin`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error("Download failed", err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Could not download release file" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export { router as downloadRouter };
|
||||
@@ -0,0 +1,190 @@
|
||||
import express from "express";
|
||||
import { config } from "../config";
|
||||
import {
|
||||
createDownloadLinkDb,
|
||||
deviceGroups,
|
||||
deviceTypes,
|
||||
findLatestReleaseDb,
|
||||
getDeviceByIdDb,
|
||||
getUpdateByIdDb,
|
||||
registerUpdateLogDb,
|
||||
saveDevice,
|
||||
} from "../data";
|
||||
import { generateDeviceToken, sensorboxAuth, verifyDeviceToken } from "../auth";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const authorization = req.headers.authorization;
|
||||
const deviceId = String(req.body.deviceId || "").trim();
|
||||
const type = String(req.body.type || "").trim();
|
||||
const hardwareVersion = String(req.body.hardwareVersion || "");
|
||||
const softwareVersion = String(req.body.softwareVersion || "");
|
||||
const group = String("release") as (typeof deviceGroups)[number];
|
||||
const token = authorization?.startsWith("Bearer ")
|
||||
? authorization.slice(7)
|
||||
: String(req.body.token || "");
|
||||
|
||||
if (!deviceId || !type) {
|
||||
console.log("Missing deviceId or type in registration request");
|
||||
res.status(400).send("deviceId and type are required");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceType = deviceTypes.find((entry) => entry.id === type);
|
||||
if (!deviceType) {
|
||||
console.log(`Unknown device type: ${type}`);
|
||||
res.status(400).send("Unknown device type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceType.supportedGroups.includes(group)) {
|
||||
console.log(`Unsupported group: ${group}`);
|
||||
res.status(400).send("Unsupported group for device type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (token !== config.sensorboxBootstrapToken) {
|
||||
const existingDevice = verifyDeviceToken(token);
|
||||
if (!existingDevice || existingDevice.id !== deviceId) {
|
||||
console.log("Invalid bootstrap token or device token");
|
||||
res.status(401).send("Invalid bootstrap token or existing device token");
|
||||
return;
|
||||
}
|
||||
console.log(`Device with ID ${deviceId} already registered, updating info`);
|
||||
// Regenerate token for existing device
|
||||
const jwtToken = generateDeviceToken(existingDevice.id);
|
||||
existingDevice.token = jwtToken;
|
||||
existingDevice.lastSeen = new Date().toISOString();
|
||||
saveDevice(existingDevice);
|
||||
res.send(jwtToken);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDevice = getDeviceByIdDb(deviceId);
|
||||
if (existingDevice) {
|
||||
console.log(`Device with ID ${deviceId} already exists`);
|
||||
res.status(409).send("Device already registered");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceRecord = {
|
||||
id: deviceId,
|
||||
type,
|
||||
group,
|
||||
token: "",
|
||||
registeredAt: new Date().toISOString(),
|
||||
currentVersion: softwareVersion || undefined,
|
||||
hardwareVersion: hardwareVersion || undefined,
|
||||
status: "registered",
|
||||
lastSeen: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const jwtToken = generateDeviceToken(deviceRecord.id);
|
||||
deviceRecord.token = jwtToken;
|
||||
deviceRecord.type = type;
|
||||
deviceRecord.group = group;
|
||||
deviceRecord.currentVersion = softwareVersion || deviceRecord.currentVersion;
|
||||
deviceRecord.hardwareVersion =
|
||||
hardwareVersion || deviceRecord.hardwareVersion;
|
||||
deviceRecord.lastSeen = new Date().toISOString();
|
||||
|
||||
saveDevice(deviceRecord);
|
||||
// Add Content-Length header to prevent chunked encoding which can cause issues for some clients
|
||||
res.contentType("text/plain").send(jwtToken);
|
||||
});
|
||||
|
||||
router.get("/ota/check", sensorboxAuth, (req, res) => {
|
||||
const device = req.device!;
|
||||
const hardwareVersion = String(
|
||||
req.query.hardwareVersion || device.hardwareVersion || "",
|
||||
);
|
||||
const softwareVersion = String(
|
||||
req.query.softwareVersion || device.currentVersion || "0.0.0",
|
||||
);
|
||||
const group = String(
|
||||
req.query.group || device.group,
|
||||
) as (typeof deviceGroups)[number];
|
||||
|
||||
if (!hardwareVersion) {
|
||||
res.status(400).send("hardwareVersion is required");
|
||||
return;
|
||||
}
|
||||
|
||||
device.lastSeen = new Date().toISOString();
|
||||
device.currentVersion = softwareVersion;
|
||||
device.hardwareVersion = hardwareVersion;
|
||||
|
||||
// Update the device
|
||||
saveDevice(device);
|
||||
|
||||
const update = findLatestReleaseDb(device.type, group, hardwareVersion);
|
||||
if (!update || update.version === softwareVersion) {
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = createDownloadLinkDb(device.id, update.id);
|
||||
const downloadUrl = `${config.baseUrl}/download/${link.id}`;
|
||||
// Get size of the update file to include in the response header
|
||||
const fs = require("fs");
|
||||
let fileSize = 0;
|
||||
try {
|
||||
const stats = fs.statSync(update.filePath);
|
||||
fileSize = stats.size;
|
||||
} catch (err) {
|
||||
console.error("Error getting update file size:", err);
|
||||
}
|
||||
|
||||
res
|
||||
.contentType("text/plain")
|
||||
.send(
|
||||
"" +
|
||||
update.id +
|
||||
"\n" +
|
||||
update.version +
|
||||
"\n" +
|
||||
downloadUrl +
|
||||
"\n" +
|
||||
new Date(link.expiresAt).toISOString() +
|
||||
"\n" +
|
||||
fileSize,
|
||||
);
|
||||
});
|
||||
|
||||
router.post("/ota/report", sensorboxAuth, (req, res) => {
|
||||
const device = req.device!;
|
||||
const updateId = String(req.body.updateId || "");
|
||||
const success = req.body.success === true;
|
||||
const message = String(req.body.message || "");
|
||||
|
||||
if (!updateId) {
|
||||
res.status(400).send("updateId is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const update = getUpdateByIdDb(updateId);
|
||||
if (!update) {
|
||||
res.status(404).send("Update not found");
|
||||
return;
|
||||
}
|
||||
|
||||
registerUpdateLogDb(device.id, updateId, success, message);
|
||||
|
||||
if (success) {
|
||||
device.currentVersion = update.version;
|
||||
device.hardwareVersion = update.hardwareVersion;
|
||||
device.status = "updated";
|
||||
} else {
|
||||
device.status = "update_failed";
|
||||
}
|
||||
|
||||
device.lastSeen = new Date().toISOString();
|
||||
res.send(true);
|
||||
});
|
||||
|
||||
router.get("/device", sensorboxAuth, (req, res) => {
|
||||
res.json({ device: req.device });
|
||||
});
|
||||
|
||||
export { router as sensorboxRouter };
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "better-sqlite3" {
|
||||
const Database: any;
|
||||
export default Database;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user