diff --git a/setup.sh b/setup.sh index 1968349e5..fdd500e39 100644 --- a/setup.sh +++ b/setup.sh @@ -39,6 +39,14 @@ fi echo "🔄 Updating system..." apt update && apt upgrade -y +# Install jq (used by update.sh for asset upload) +if command -v jq &> /dev/null; then + echo "jq is already installed" +else + echo "📦 Installing jq..." + apt install -y jq +fi + # Check if Docker is already installed if command -v docker &> /dev/null; then echo "Docker is already installed" diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index a27582dba..ff31957e8 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -371,15 +371,11 @@ export function copyRootPublicFiles( } } -export function writePublicAssetManifestModule( +export function writePublicAssetManifest( outDir: string, assetManifest: AssetManifest, ): void { - const manifestPath = path.join(outDir, "_assets", "asset-manifest.mjs"); + const manifestPath = path.join(outDir, "_assets", "asset-manifest.json"); fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); - const serializedManifest = JSON.stringify(assetManifest, null, 2); - fs.writeFileSync( - manifestPath, - `const assetManifest = ${serializedManifest};\nexport { assetManifest };\nexport default assetManifest;\n`, - ); + fs.writeFileSync(manifestPath, `${JSON.stringify(assetManifest, null, 2)}\n`); } diff --git a/src/server/RuntimeAssetManifest.ts b/src/server/RuntimeAssetManifest.ts index 24fdeed7a..ce118946c 100644 --- a/src/server/RuntimeAssetManifest.ts +++ b/src/server/RuntimeAssetManifest.ts @@ -1,40 +1,34 @@ import fs from "fs"; import path from "path"; -import { fileURLToPath, pathToFileURL } from "url"; +import { fileURLToPath } from "url"; import type { AssetManifest } from "../core/AssetUrls"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const staticDir = path.join(__dirname, "../../static"); -const manifestPath = path.join(staticDir, "_assets", "asset-manifest.mjs"); +const manifestPath = path.join(staticDir, "_assets", "asset-manifest.json"); -let manifestPromise: Promise | null = null; -let manifestVersion = 0; - -async function importRuntimeAssetManifest( - version: number, -): Promise { - const manifestModule = (await import( - `${pathToFileURL(manifestPath).href}?v=${version}` - )) as { - assetManifest?: AssetManifest; - default?: AssetManifest; - }; - return manifestModule.assetManifest ?? manifestModule.default ?? {}; -} +let cachedManifest: AssetManifest | null = null; export async function getRuntimeAssetManifest(): Promise { - if (!fs.existsSync(manifestPath)) { - return {}; + if (cachedManifest !== null) { + return cachedManifest; } - - manifestPromise ??= importRuntimeAssetManifest(manifestVersion).catch( - () => ({}), - ); - return manifestPromise; + if (!fs.existsSync(manifestPath)) { + cachedManifest = {}; + return cachedManifest; + } + try { + cachedManifest = JSON.parse( + fs.readFileSync(manifestPath, "utf8"), + ) as AssetManifest; + } catch (err) { + console.error(`Failed to parse asset manifest at ${manifestPath}:`, err); + cachedManifest = {}; + } + return cachedManifest; } export function clearRuntimeAssetManifestCache(): void { - manifestVersion++; - manifestPromise = null; + cachedManifest = null; } diff --git a/update.sh b/update.sh index f4c4cd5db..4f198f44d 100755 --- a/update.sh +++ b/update.sh @@ -1,6 +1,7 @@ #!/bin/bash # update.sh - Script to update Docker container on Hetzner server # Called by deploy.sh after uploading Docker image to Docker Hub +set -eo pipefail # Check if environment file is provided if [ $# -ne 1 ]; then @@ -31,6 +32,117 @@ CONTAINER_NAME="openfront-${ENV}-${SUBDOMAIN}" echo "Pulling ${GHCR_IMAGE} from GitHub Container Registry..." docker pull "${GHCR_IMAGE}" +# Upload hashed assets to R2 before swapping containers. If this fails the old +# container keeps serving — better than a stop-then-fail outage. +echo "======================================================" +echo "📦 Uploading assets to R2 for ${DOMAIN}..." +echo "======================================================" + +if [ -z "$DOMAIN" ] || [ -z "$API_KEY" ]; then + echo "❌ DOMAIN or API_KEY not set; cannot upload assets." + exit 1 +fi +for cmd in jq curl xargs; do + if ! command -v "$cmd" > /dev/null 2>&1; then + echo "❌ Required tool '$cmd' not found. Install via setup.sh." + exit 1 + fi +done + +EXTRACT_DIR="$(mktemp -d -t openfront-assets-XXXXXX)" +trap 'rm -rf "$EXTRACT_DIR"' EXIT + +TMP_CONTAINER="$(docker create "${GHCR_IMAGE}")" +if ! docker cp "${TMP_CONTAINER}:/usr/src/app/static/_assets" "$EXTRACT_DIR/"; then + echo "❌ docker cp failed" + docker rm "${TMP_CONTAINER}" > /dev/null 2>&1 || true + exit 1 +fi +docker rm "${TMP_CONTAINER}" > /dev/null + +echo "Extracted to $EXTRACT_DIR; top-level contents:" +ls -la "$EXTRACT_DIR/" || true + +R2_ENDPOINT="https://api.${DOMAIN}" +MANIFEST="$EXTRACT_DIR/_assets/asset-manifest.json" +if [ ! -f "$MANIFEST" ]; then + echo "❌ Manifest not found at $MANIFEST" + exit 1 +fi + +# Manifest values are like "/_assets/foo/bar..png"; strip the leading "/". +KEYS_JSON="$(jq '[.[] | sub("^/"; "")]' "$MANIFEST")" +TOTAL="$(echo "$KEYS_JSON" | jq 'length')" +echo "Checking $TOTAL asset keys against $R2_ENDPOINT..." + +CHECK_BODY="$(mktemp)" +HTTP_CODE="$(curl -sS --connect-timeout 10 --max-time 120 \ + -o "$CHECK_BODY" -w "%{http_code}" -X POST "$R2_ENDPOINT/game_assets/check" \ + -H "X-API-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"keys\": $KEYS_JSON}")" +if [ "$HTTP_CODE" != "200" ]; then + echo "❌ /check returned HTTP $HTTP_CODE:" + cat "$CHECK_BODY" + rm -f "$CHECK_BODY" + exit 1 +fi +if ! jq -e '.missing | type == "array"' "$CHECK_BODY" > /dev/null; then + echo "❌ /check response missing '.missing' array:" + cat "$CHECK_BODY" + rm -f "$CHECK_BODY" + exit 1 +fi +MISSING="$(jq -r '.missing[]' "$CHECK_BODY")" +rm -f "$CHECK_BODY" + +if [ -z "$MISSING" ]; then + echo "✅ All $TOTAL assets already in R2; nothing to upload." +else + MISSING_COUNT="$(echo "$MISSING" | wc -l | tr -d ' ')" + echo "Uploading $MISSING_COUNT missing asset(s)..." + export R2_ENDPOINT API_KEY EXTRACT_DIR + # KEY from the manifest is URL-encoded per segment (e.g. flags/C%C3%B4te.png). + # Files on disk live at the *decoded* path, so decode KEY before reading the + # file, then encode the whole decoded path as one URL segment for the POST. + if ! echo "$MISSING" | xargs -P 16 -I{} bash -euc ' + KEY="$1" + # Validate KEY: only chars that encodeURIComponent leaves literal + # (A-Z a-z 0-9 - _ . ! ~ * ( ) plus apostrophe), "/" between segments, + # and well-formed %HH escapes. The %HH check makes the printf-based + # decoder below safe by rejecting partial escapes; excluding "\" keeps + # printf "%b" from interpreting unexpected backslash sequences. The + # regex lives in a variable so the literal apostrophe sits inside a + # double-quoted assignment instead of being parsed as a shell quote. + RE="^([A-Za-z0-9._/~!*'\''()-]|%[0-9A-Fa-f]{2})+\$" + [[ "$KEY" =~ $RE ]] || { + echo "❌ invalid key from server: $KEY" >&2; exit 1 + } + DECODED="$(printf "%b" "${KEY//%/\\x}")" + # Defense-in-depth: refuse any decoded path that escapes the asset tree, + # in case the trusted /check endpoint is ever compromised. + case "$DECODED" in + /* | *..* ) echo "❌ refusing unsafe path: $DECODED" >&2; exit 1 ;; + esac + ENC="$(jq -rn --arg k "$DECODED" "\$k|@uri")" + if ! curl -fsS \ + --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 10 --max-time 120 \ + -X PUT \ + "$R2_ENDPOINT/game_assets/upload/$ENC" \ + -H "X-API-Key: $API_KEY" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$EXTRACT_DIR/$DECODED" > /dev/null; then + echo "❌ Failed to upload: $DECODED" >&2 + exit 1 + fi + ' _ {}; then + echo "❌ One or more asset uploads failed." + exit 1 + fi + echo "✅ Uploaded $MISSING_COUNT asset(s) to R2." +fi + echo "Checking for existing container..." # Use docker ps with filter for exact name match RUNNING_CONTAINER="$(docker ps --filter "name=^${CONTAINER_NAME}$" -q)" diff --git a/vite.config.ts b/vite.config.ts index 3e7c48275..0a520a93a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,7 +13,7 @@ import { createHashedPublicAssetFiles, getProprietaryDir, getResourcesDir, - writePublicAssetManifestModule, + writePublicAssetManifest, } from "./src/server/PublicAssetManifest"; // Vite already handles these, but its good practice to define them explicitly @@ -78,7 +78,7 @@ export default defineConfig(({ mode }) => { const outDir = path.join(__dirname, "static"); copyRootPublicFiles(resourcesDir, outDir); createHashedPublicAssetFiles(sourceDirs, outDir, assetManifest); - writePublicAssetManifestModule(outDir, assetManifest); + writePublicAssetManifest(outDir, assetManifest); }, });