diff --git a/server/routers/external.ts b/server/routers/external.ts index a17c88fb1..dfd0c2c7a 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1231,6 +1231,22 @@ authRouter.post( newt.getNewtToken ); +authRouter.post( + "/newt/version", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + keyGenerator: (req) => + `newtVersion:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only check the Newt version ${60} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + newt.getNewtVersion +); + authRouter.post( "/newt/register", rateLimit({ diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts new file mode 100644 index 000000000..96511d62a --- /dev/null +++ b/server/routers/newt/getNewtVersion.ts @@ -0,0 +1,228 @@ +import { db } from "@server/db"; +import { newts } from "@server/db"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import semver from "semver"; +import { verifyPassword } from "@server/auth/password"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import cache from "#dynamic/lib/cache"; +import config from "@server/lib/config"; + +// Stale-while-revalidate cache for the latest newt version. +let staleNewtVersion: string | null = null; + +async function getLatestNewtVersion(): Promise { + try { + const cachedVersion = await cache.get( + "cache:latestNewtVersion" + ); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const fetchResponse = await fetch( + "https://api.github.com/repos/fosrl/newt/tags", + { signal: controller.signal } + ); + + clearTimeout(timeoutId); + + if (!fetchResponse.ok) { + logger.warn( + `Failed to fetch latest Newt version from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}` + ); + return staleNewtVersion; + } + + let tags = await fetchResponse.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Newt repository"); + return staleNewtVersion; + } + + tags = tags.filter((tag: any) => !tag.name.includes("rc")); + tags.sort((a: any, b: any) => { + const va = semver.coerce(a.name); + const vb = semver.coerce(b.name); + if (!va && !vb) return 0; + if (!va) return 1; + if (!vb) return -1; + return semver.rcompare(va, vb); + }); + + const seen = new Set(); + tags = tags.filter((tag: any) => { + const normalised = semver.coerce(tag.name)?.version; + if (!normalised || seen.has(normalised)) return false; + seen.add(normalised); + return true; + }); + + if (tags.length === 0) { + logger.warn("No valid semver tags found for Newt repository"); + return staleNewtVersion; + } + + const latestVersion = tags[0].name; + staleNewtVersion = latestVersion; + await cache.set("cache:latestNewtVersion", latestVersion, 3600); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest Newt version timed out (1.5s)" + ); + } else { + logger.warn( + "Error fetching latest Newt version:", + error.message || error + ); + } + return staleNewtVersion; + } +} + +const bodySchema = z.object({ + newtId: z.string(), + secret: z.string(), + platform: z.string() // e.g. "linux_amd64", "darwin_arm64" +}); + +export type GetNewtVersionBody = z.infer; + +export type GetNewtVersionResponse = { + latestVersion: string; + currentIsLatest: boolean; + downloadUrl: string; +}; + +export async function getNewtVersion( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { newtId, secret, platform } = parsedBody.data; + + try { + // Verify newt credentials + const existingNewtRes = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + + if (!existingNewtRes || !existingNewtRes.length) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Newt version check: no newt found with ID ${newtId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid credentials" + ) + ); + } + + const existingNewt = existingNewtRes[0]; + + const validSecret = await verifyPassword( + secret, + existingNewt.secretHash + ); + if (!validSecret) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Newt version check: invalid secret for newt ID ${newtId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid credentials" + ) + ); + } + + // Fetch latest version + const latestVersion = await getLatestNewtVersion(); + + if (!latestVersion) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Unable to determine latest Newt version" + ) + ); + } + + // Normalise the tag (strip leading 'v' for the URL, but keep original for comparison) + const tagForUrl = latestVersion.startsWith("v") + ? latestVersion + : `v${latestVersion}`; + + // Binary name follows the get-newt.sh convention: newt_[.exe] + const binaryName = + platform.includes("windows") + ? `newt_${platform}.exe` + : `newt_${platform}`; + + const downloadUrl = `https://github.com/fosrl/newt/releases/download/${tagForUrl}/${binaryName}`; + + // Determine whether the newt that's asking is already up to date. + // We store the current version on the newt row when it registers. + const currentVersion = existingNewt.version ?? null; + let currentIsLatest = false; + if (currentVersion) { + try { + const latest = semver.coerce(latestVersion); + const current = semver.coerce(currentVersion); + if (latest && current) { + currentIsLatest = !semver.lt(current, latest); + } + } catch { + // If we can't compare, assume not latest + } + } + + return response(res, { + data: { + latestVersion, + currentIsLatest, + downloadUrl + }, + success: true, + error: false, + message: "Version info retrieved successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to retrieve version info" + ) + ); + } +} diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 368cdf636..1f6090358 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -1,5 +1,6 @@ export * from "./createNewt"; export * from "./getNewtToken"; +export * from "./getNewtVersion"; export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; export * from "./handleNewtGetConfigMessage";