mirror of
https://github.com/temetro/temetro.git
synced 2026-07-28 20:58:57 +00:00
backend: version + network endpoints, LAN-friendly CORS/origins
Add public GET /api/version (current version + GitHub-release update check, cached, fail-soft) and GET /api/network (detected LAN addresses). Accept localhost/private-LAN origins in CORS and Better Auth trusted origins via a shared isAllowedOrigin helper, so staff can reach the app over the clinic network. The seed-demo.ts fake test data is removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -9,6 +9,7 @@ import * as authSchema from "./db/schema/auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { ac, roles } from "./lib/access.js";
|
||||
import { sendEmail } from "./lib/email.js";
|
||||
import { isAllowedOrigin } from "./lib/origins.js";
|
||||
|
||||
const WEEK = 60 * 60 * 24 * 7;
|
||||
const DAY = 60 * 60 * 24;
|
||||
@@ -17,7 +18,18 @@ export const auth = betterAuth({
|
||||
appName: "temetro",
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
trustedOrigins: [env.FRONTEND_URL],
|
||||
// Trust the configured frontend origin plus localhost/LAN hosts so staff can
|
||||
// sign in over the network (mirrors CORS; see src/lib/origins.ts). Reflecting
|
||||
// the request's own origin (when allowed) keeps Better Auth's CSRF check happy
|
||||
// without a per-deployment rebuild.
|
||||
trustedOrigins: (request) => {
|
||||
const origins = [env.FRONTEND_URL];
|
||||
const origin = request?.headers.get("origin");
|
||||
if (origin && isAllowedOrigin(origin) && !origins.includes(origin)) {
|
||||
origins.push(origin);
|
||||
}
|
||||
return origins;
|
||||
},
|
||||
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
|
||||
@@ -21,6 +21,13 @@ const schema = z.object({
|
||||
UPLOAD_DIR: z.string().min(1).default("./uploads"),
|
||||
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
|
||||
FRONTEND_URL: z.string().min(1).default("http://localhost:3000"),
|
||||
// Extra browser origins allowed to call the API with credentials, beyond
|
||||
// FRONTEND_URL and the auto-allowed private/LAN hosts (see src/lib/origins.ts).
|
||||
// Comma-separated; set to "*" to allow any origin (only for trusted networks).
|
||||
TRUSTED_ORIGINS: z.string().optional(),
|
||||
// Overrides the version reported by GET /api/version. Normally derived from
|
||||
// package.json; the release pipeline can pin it explicitly.
|
||||
APP_VERSION: z.string().optional(),
|
||||
// Public, device-reachable URL of this backend's wallet relay, baked into the
|
||||
// QR a patient scans. Optional — when unset we derive it from the request host
|
||||
// (so opening the web app over the LAN yields a reachable LAN URL).
|
||||
|
||||
+12
-1
@@ -6,6 +6,7 @@ import express from "express";
|
||||
|
||||
import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { isAllowedOrigin } from "./lib/origins.js";
|
||||
import { errorHandler, notFound } from "./middleware/error.js";
|
||||
import { initRealtime } from "./realtime.js";
|
||||
import { activityRouter } from "./routes/activity.js";
|
||||
@@ -30,7 +31,9 @@ import { prescriptionsRouter } from "./routes/prescriptions.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { signingRouter } from "./routes/signing.js";
|
||||
import { staffRouter } from "./routes/staff.js";
|
||||
import { networkRouter } from "./routes/network.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { versionRouter } from "./routes/version.js";
|
||||
import { beginQuickTunnelDiscovery } from "./services/relay-url.js";
|
||||
import { sweepExpiredShares } from "./services/wallet-share.js";
|
||||
|
||||
@@ -39,9 +42,13 @@ const app = express();
|
||||
// Behind docker / a reverse proxy we trust forwarding headers for client IPs.
|
||||
app.set("trust proxy", true);
|
||||
|
||||
// Allow the configured frontend origin plus localhost/LAN hosts, so other
|
||||
// departments can reach the app over the network (see src/lib/origins.ts).
|
||||
// Requests without an Origin header (curl, same-origin server calls) pass too.
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.FRONTEND_URL,
|
||||
origin: (origin, cb) =>
|
||||
cb(null, !origin || isAllowedOrigin(origin)),
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
@@ -71,6 +78,10 @@ app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// Public, unauthenticated: running version + update check, and LAN access info.
|
||||
app.use("/api/version", versionRouter);
|
||||
app.use("/api/network", networkRouter);
|
||||
|
||||
// Mount the wallet import routes BEFORE the generic patients router so
|
||||
// `/api/patients/wallet/...` isn't matched by patients' `/:fileNumber`.
|
||||
app.use("/api/patients/wallet", patientsWalletRouter);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Decides which browser origins may call the API with credentials.
|
||||
//
|
||||
// temetro is self-hosted: a clinic runs it on one machine and other departments
|
||||
// reach it over the LAN by the host's IP (e.g. http://192.168.1.20:3000). The
|
||||
// browser there sends that LAN origin, which must be allowed for both CORS and
|
||||
// Better Auth's CSRF/trusted-origin check. We allow:
|
||||
// - FRONTEND_URL (the configured origin),
|
||||
// - any explicitly listed TRUSTED_ORIGINS (or "*" for any),
|
||||
// - localhost and private/LAN hosts (so LAN access works with no config).
|
||||
import { env } from "../env.js";
|
||||
|
||||
const configured = (env.TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const allowAll = configured.includes("*");
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return true;
|
||||
}
|
||||
// Private IPv4 ranges (RFC 1918) + link-local.
|
||||
if (/^10\./.test(hostname)) return true;
|
||||
if (/^192\.168\./.test(hostname)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true;
|
||||
if (/^169\.254\./.test(hostname)) return true;
|
||||
// mDNS .local hostnames (e.g. clinic-pc.local).
|
||||
if (hostname.endsWith(".local")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if `origin` (an `Origin` header value) is allowed to call the API. */
|
||||
export function isAllowedOrigin(origin: string | undefined | null): boolean {
|
||||
if (!origin) return false;
|
||||
if (allowAll) return true;
|
||||
if (origin === env.FRONTEND_URL) return true;
|
||||
if (configured.includes(origin)) return true;
|
||||
try {
|
||||
return isPrivateHost(new URL(origin).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// GET /api/network — best-effort discovery of LAN addresses other departments
|
||||
// can use to reach temetro, for the Settings "Network access" panel.
|
||||
//
|
||||
// Caveat: inside Docker's default bridge network this sees the container's IPs,
|
||||
// not the host's LAN IP, so the frontend prefers the address the browser is
|
||||
// actually using (window.location) and treats this as a fallback/hint.
|
||||
import { networkInterfaces } from "node:os";
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
import { env } from "../env.js";
|
||||
|
||||
function frontendPort(): number {
|
||||
try {
|
||||
const port = new URL(env.FRONTEND_URL).port;
|
||||
return port ? Number(port) : 3000;
|
||||
} catch {
|
||||
return 3000;
|
||||
}
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const port = frontendPort();
|
||||
const addresses: string[] = [];
|
||||
for (const iface of Object.values(networkInterfaces())) {
|
||||
for (const net of iface ?? []) {
|
||||
// Node <18 reports family as "IPv4"; >=18 may report the number 4.
|
||||
const isV4 = net.family === "IPv4" || (net.family as unknown) === 4;
|
||||
if (isV4 && !net.internal) addresses.push(net.address);
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
port,
|
||||
addresses,
|
||||
urls: addresses.map((ip) => `http://${ip}:${port}`),
|
||||
});
|
||||
});
|
||||
|
||||
export const networkRouter = router;
|
||||
@@ -0,0 +1,76 @@
|
||||
// GET /api/version — reports the running version and whether a newer release
|
||||
// exists on GitHub. Public (no PHI); the frontend uses it for the Settings
|
||||
// "About & Updates" panel and the optional update banner.
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
import { env } from "../env.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// package.json is the source of truth for the running version (copied into the
|
||||
// runtime image). APP_VERSION can override it (set by the release pipeline).
|
||||
const pkg = require("../../package.json") as { version?: string };
|
||||
const CURRENT = env.APP_VERSION ?? pkg.version ?? "0.0.0";
|
||||
|
||||
const LATEST_RELEASE_URL =
|
||||
"https://api.github.com/repos/temetro/temetro/releases/latest";
|
||||
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6h — releases are infrequent.
|
||||
const ERROR_TTL = 10 * 60 * 1000; // back off ~10m after a failed lookup.
|
||||
|
||||
type LatestInfo = { latest: string | null; releaseUrl: string | null };
|
||||
let cache: { at: number; ttl: number; info: LatestInfo } | null = null;
|
||||
|
||||
function parseSemver(v: string): [number, number, number] | null {
|
||||
const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
}
|
||||
|
||||
/** True if `latest` is a strictly newer semver than `current`. */
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const a = parseSemver(latest);
|
||||
const b = parseSemver(current);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i]! > b[i]!) return true;
|
||||
if (a[i]! < b[i]!) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fetchLatest(): Promise<LatestInfo> {
|
||||
if (cache && Date.now() - cache.at < cache.ttl) return cache.info;
|
||||
try {
|
||||
const res = await fetch(LATEST_RELEASE_URL, {
|
||||
headers: { Accept: "application/vnd.github+json", "User-Agent": "temetro" },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub responded ${res.status}`);
|
||||
const body = (await res.json()) as { tag_name?: string; html_url?: string };
|
||||
const info: LatestInfo = {
|
||||
latest: body.tag_name ? body.tag_name.replace(/^v/, "") : null,
|
||||
releaseUrl: body.html_url ?? null,
|
||||
};
|
||||
cache = { at: Date.now(), ttl: CACHE_TTL, info };
|
||||
return info;
|
||||
} catch {
|
||||
// Fail soft: keep any prior value, briefly cache the miss to avoid hammering.
|
||||
const info: LatestInfo = cache?.info ?? { latest: null, releaseUrl: null };
|
||||
cache = { at: Date.now(), ttl: ERROR_TTL, info };
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const { latest, releaseUrl } = await fetchLatest();
|
||||
res.json({
|
||||
current: CURRENT,
|
||||
latest,
|
||||
updateAvailable: latest ? isNewer(latest, CURRENT) : false,
|
||||
releaseUrl,
|
||||
});
|
||||
});
|
||||
|
||||
export const versionRouter = router;
|
||||
Reference in New Issue
Block a user