mirror of
https://github.com/temetro/temetro.git
synced 2026-08-30 20:39:06 +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 { env } from "./env.js";
|
||||||
import { ac, roles } from "./lib/access.js";
|
import { ac, roles } from "./lib/access.js";
|
||||||
import { sendEmail } from "./lib/email.js";
|
import { sendEmail } from "./lib/email.js";
|
||||||
|
import { isAllowedOrigin } from "./lib/origins.js";
|
||||||
|
|
||||||
const WEEK = 60 * 60 * 24 * 7;
|
const WEEK = 60 * 60 * 24 * 7;
|
||||||
const DAY = 60 * 60 * 24;
|
const DAY = 60 * 60 * 24;
|
||||||
@@ -17,7 +18,18 @@ export const auth = betterAuth({
|
|||||||
appName: "temetro",
|
appName: "temetro",
|
||||||
baseURL: env.BETTER_AUTH_URL,
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
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, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ const schema = z.object({
|
|||||||
UPLOAD_DIR: z.string().min(1).default("./uploads"),
|
UPLOAD_DIR: z.string().min(1).default("./uploads"),
|
||||||
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
|
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
|
||||||
FRONTEND_URL: z.string().min(1).default("http://localhost:3000"),
|
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
|
// 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
|
// 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).
|
// (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 { auth } from "./auth.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
import { isAllowedOrigin } from "./lib/origins.js";
|
||||||
import { errorHandler, notFound } from "./middleware/error.js";
|
import { errorHandler, notFound } from "./middleware/error.js";
|
||||||
import { initRealtime } from "./realtime.js";
|
import { initRealtime } from "./realtime.js";
|
||||||
import { activityRouter } from "./routes/activity.js";
|
import { activityRouter } from "./routes/activity.js";
|
||||||
@@ -30,7 +31,9 @@ import { prescriptionsRouter } from "./routes/prescriptions.js";
|
|||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { signingRouter } from "./routes/signing.js";
|
import { signingRouter } from "./routes/signing.js";
|
||||||
import { staffRouter } from "./routes/staff.js";
|
import { staffRouter } from "./routes/staff.js";
|
||||||
|
import { networkRouter } from "./routes/network.js";
|
||||||
import { tasksRouter } from "./routes/tasks.js";
|
import { tasksRouter } from "./routes/tasks.js";
|
||||||
|
import { versionRouter } from "./routes/version.js";
|
||||||
import { beginQuickTunnelDiscovery } from "./services/relay-url.js";
|
import { beginQuickTunnelDiscovery } from "./services/relay-url.js";
|
||||||
import { sweepExpiredShares } from "./services/wallet-share.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.
|
// Behind docker / a reverse proxy we trust forwarding headers for client IPs.
|
||||||
app.set("trust proxy", true);
|
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(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: env.FRONTEND_URL,
|
origin: (origin, cb) =>
|
||||||
|
cb(null, !origin || isAllowedOrigin(origin)),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -71,6 +78,10 @@ app.get("/health", (_req, res) => {
|
|||||||
res.json({ status: "ok" });
|
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
|
// Mount the wallet import routes BEFORE the generic patients router so
|
||||||
// `/api/patients/wallet/...` isn't matched by patients' `/:fileNumber`.
|
// `/api/patients/wallet/...` isn't matched by patients' `/:fileNumber`.
|
||||||
app.use("/api/patients/wallet", patientsWalletRouter);
|
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