Add public /install page with Obtainium button

Served unauthenticated at /install: one-tap "Add to Obtainium" deep link,
how to get Obtainium, direct-APK fallback to the Forgejo releases, and iPhone
Add-to-Home-Screen steps. Detects the visitor's platform and shows it first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 21:51:44 +00:00
parent 83e2af6ed3
commit d7fbb2a154
2 changed files with 119 additions and 0 deletions

View file

@ -0,0 +1,117 @@
import type { FastifyInstance } from "fastify";
// Public install landing page. Points Obtainium at the Forgejo repo and gives
// iPhone PWA instructions. Served unauthenticated at /install.
const REPO_URL = "https://git.mowden.top/Beartaria/CampgroundTickets";
const OBTAINIUM_ADD = `obtainium://add/${REPO_URL}`;
const RELEASES_URL = `${REPO_URL}/releases`;
const OBTAINIUM_GET = "https://github.com/ImranR98/Obtainium/releases/latest";
const PWA_URL = "https://scan.beartariacampgrounds.com/";
export async function installRoutes(app: FastifyInstance): Promise<void> {
app.get("/install", async (_req, reply) => {
reply.type("text/html").send(PAGE);
});
}
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" />
<title>Install Camp Scan</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; background: #0f1a12; color: #eaf2ec;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
line-height: 1.5; -webkit-font-smoothing: antialiased;
}
.wrap { max-width: 560px; margin: 0 auto; padding: 28px 20px 56px; }
header { text-align: center; margin-bottom: 28px; }
.logo { font-size: 60px; line-height: 1; }
h1 { font-size: 26px; margin: 10px 0 4px; }
.sub { color: #9db3a4; font-size: 15px; margin: 0; }
.card {
background: #16241a; border: 1px solid #24382a; border-radius: 16px;
padding: 20px; margin: 16px 0;
}
.card h2 { font-size: 19px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }
.card .lead { color: #c4d6c9; font-size: 14px; margin: 0 0 14px; }
ol { margin: 0; padding-left: 20px; }
ol li { margin: 8px 0; }
a.btn {
display: block; text-align: center; text-decoration: none;
background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px;
padding: 16px; border-radius: 13px; margin: 14px 0;
}
a.btn.secondary { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; font-weight: 600; font-size: 15px; padding: 13px; }
a.inline { color: #58d68d; text-decoration: none; font-weight: 600; }
.note { font-size: 13px; color: #9db3a4; margin-top: 12px; }
.divider { text-align: center; color: #6c8f74; font-size: 13px; margin: 8px 0; }
code { background: #0f1a12; border: 1px solid #24382a; border-radius: 6px; padding: 1px 6px; font-size: 13px; word-break: break-all; }
.platform-tag { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #58d68d; }
footer { text-align: center; color: #6c8f74; font-size: 12px; margin-top: 24px; }
.hide { display: none; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">🐻</div>
<h1>Install Camp Scan</h1>
<p class="sub">Ticket scanner for Beartaria Campgrounds gate staff</p>
</header>
<!-- ANDROID -->
<div class="card" id="android">
<span class="platform-tag">Android</span>
<h2>📲 Install &amp; auto-update via Obtainium</h2>
<p class="lead">Obtainium keeps the app updated straight from our server no Play Store needed.</p>
<ol>
<li>Don't have Obtainium yet? <a class="inline" href="${OBTAINIUM_GET}">Download it here</a> and install the APK (you may need to allow "install unknown apps").</li>
<li>Then tap the button below it opens Obtainium with Camp Scan ready to add:</li>
</ol>
<a class="btn" href="${OBTAINIUM_ADD}"> Add Camp Scan to Obtainium</a>
<p class="note">If the button doesn't open Obtainium: open Obtainium <b>Add App</b> paste <code>${REPO_URL}</code> Add.</p>
<div class="divider"> or </div>
<a class="btn secondary" href="${RELEASES_URL}"> Download the APK directly</a>
<p class="note">Direct installs won't auto-update Obtainium is recommended.</p>
</div>
<!-- IPHONE -->
<div class="card" id="iphone">
<span class="platform-tag">iPhone &amp; iPad</span>
<h2>🍎 Add to Home Screen</h2>
<p class="lead">No App Store needed it runs as a full-screen web app.</p>
<ol>
<li>Open <b>this page in Safari</b> (not Chrome): <code>${PWA_URL}install</code></li>
<li>Tap the <b>Share</b> button, then <b>Add to Home Screen</b> <b>Add</b>.</li>
<li>Launch <b>Camp Scan</b> from your home screen and allow the camera.</li>
</ol>
<a class="btn secondary" href="${PWA_URL}">Open Camp Scan now</a>
</div>
<div class="card">
<h2>🔑 First launch</h2>
<p class="lead" style="margin-bottom:0">Open the app, enter the gate <b>PIN</b>, then type <b>your name</b> (recorded with every check-in). You stay signed in for the event.</p>
</div>
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
</div>
<script>
// Reorder so the visitor's platform shows first.
try {
var ua = navigator.userAgent || "";
var isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (isIOS) {
var ip = document.getElementById("iphone");
ip.parentNode.insertBefore(ip, document.getElementById("android"));
}
} catch (e) {}
</script>
</body>
</html>`;

View file

@ -11,6 +11,7 @@ import { authRoutes } from "./routes/auth.js";
import { webhookRoutes } from "./routes/webhook.js";
import { ticketRoutes } from "./routes/tickets.js";
import { testRoutes } from "./routes/test.js";
import { installRoutes } from "./routes/install.js";
export async function build() {
const config = loadConfig();
@ -30,6 +31,7 @@ export async function build() {
await app.register(webhookRoutes);
await app.register(ticketRoutes);
await app.register(testRoutes);
await app.register(installRoutes);
// Serve the exported Expo web build (if present) with SPA fallback.
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");