import { Router } from 'express' import axios from 'axios' import { githubRepos } from '../config/watchlist' const router = Router() /* ── Simple in-memory cache ── */ interface CacheEntry { data: T; ts: number } const cache: Record> = {} function fromCache(key: string, ttlMs: number): T | null { const entry = cache[key] as CacheEntry | undefined if (entry && Date.now() - entry.ts < ttlMs) return entry.data return null } function toCache(key: string, data: T) { cache[key] = { data, ts: Date.now() } } /* ──────────────────────────────────────────────────────────────────────────── Docker image updates ─────────────────────────────────────────────────────────────────────────── */ const DOCKER_SOCKET = '/var/run/docker.sock' const HUB_TTL = 60 * 60 * 1000 // 1 hour interface ContainerInfo { name: string image: string tag: string repoDigest: string | null upToDate: boolean | null // null = couldn't determine registry: string } function parseImage(fullImage: string): { registry: string; name: string; tag: string } { // Strip digest if present const [imgPart] = fullImage.split('@') const colonIdx = imgPart.lastIndexOf(':') const slashIdx = imgPart.indexOf('/') let tag = 'latest' let img = imgPart if (colonIdx > slashIdx) { tag = imgPart.slice(colonIdx + 1) img = imgPart.slice(0, colonIdx) } // Detect registry const parts = img.split('/') const isCustomRegistry = parts[0].includes('.') || parts[0].includes(':') const registry = isCustomRegistry ? parts[0] : 'docker.io' // Normalise name for Docker Hub (no namespace → library/...) let name = isCustomRegistry ? parts.slice(1).join('/') : img if (registry === 'docker.io' && !name.includes('/')) name = `library/${name}` return { registry, name, tag } } async function getRegistryToken(image: string): Promise { const res = await axios.get( `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${image}:pull`, { timeout: 8000 } ) return res.data.token as string } async function getLatestDigest(image: string, tag: string): Promise { const cacheKey = `digest:${image}:${tag}` const cached = fromCache(cacheKey, HUB_TTL) if (cached) return cached try { const token = await getRegistryToken(image) const res = await axios.head( `https://registry-1.docker.io/v2/${image}/manifests/${tag}`, { headers: { Authorization: `Bearer ${token}`, Accept: [ 'application/vnd.docker.distribution.manifest.v2+json', 'application/vnd.docker.distribution.manifest.list.v2+json', 'application/vnd.oci.image.index.v1+json', ].join(', '), }, timeout: 10000, } ) const digest = res.headers['docker-content-digest'] as string | undefined if (digest) toCache(cacheKey, digest) return digest ?? null } catch { return null } } router.get('/docker', async (_req, res) => { try { // List running containers const containersRes = await axios.get('http://localhost/v1.41/containers/json', { socketPath: DOCKER_SOCKET, timeout: 5000, }) const containers: ContainerInfo[] = [] for (const c of containersRes.data) { const rawImage: string = c.Image ?? '' const containerName: string = (c.Names?.[0] ?? '').replace(/^\//, '') // Get repo digests from image inspect let repoDigest: string | null = null try { const imgRes = await axios.get(`http://localhost/v1.41/images/${c.ImageID}/json`, { socketPath: DOCKER_SOCKET, timeout: 5000, }) const digests: string[] = imgRes.data.RepoDigests ?? [] const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0])) repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null } catch { /* ignore */ } const { registry, name, tag } = parseImage(rawImage) const isDockerHub = registry === 'docker.io' let upToDate: boolean | null = null if (isDockerHub && repoDigest) { const latest = await getLatestDigest(name, tag) if (latest) upToDate = latest === repoDigest } containers.push({ name: containerName, image: rawImage.split(':')[0].split('/').slice(-1)[0], // short name tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest', repoDigest, upToDate, registry, }) } res.json({ containers }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error' res.status(500).json({ error: msg }) } }) /* ──────────────────────────────────────────────────────────────────────────── GitHub release tracking ─────────────────────────────────────────────────────────────────────────── */ const GH_TTL = 15 * 60 * 1000 // 15 min interface GithubRelease { repo: string version: string name: string publishedAt: string url: string } router.get('/github', async (_req, res) => { try { const token = process.env.GITHUB_TOKEN const headers: Record = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', } if (token) headers.Authorization = `Bearer ${token}` const releases: GithubRelease[] = await Promise.all( githubRepos.map(async repo => { const cacheKey = `gh:${repo}` const cached = fromCache(cacheKey, GH_TTL) if (cached) return cached const r = await axios.get( `https://api.github.com/repos/${repo}/releases/latest`, { headers, timeout: 8000 } ) const release: GithubRelease = { repo, version: r.data.tag_name, name: r.data.name || r.data.tag_name, publishedAt: r.data.published_at, url: r.data.html_url, } toCache(cacheKey, release) return release }) ) res.json({ releases }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error' res.status(500).json({ error: msg }) } }) export default router