From 3abb7217dd40daef3bcd9b2e7a88255159b567cb Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 23:40:06 +0200 Subject: [PATCH] feat(mobile): proxie le telechargement de l'APK via l'API (pas de CORS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite au fix de /releases/latest (precedent), l'utilisateur voulait un lien qui declenche le telechargement du .apk directement, sans quitter l'appli web. Verifie en direct : fetch("https://git.dev.kyuno.fr/api/...") depuis le navigateur echoue avec "Failed to fetch" (confirme via un essai en mode no-cors : la requete reseau aboutit bien, seule la reponse est bloquee) — Forgejo n'envoie aucun header CORS sur son API Releases, publique ou non. Ca ne concerne pas que la page d'accueil : le hook de verification de mise a jour (#66, useAppUpdateCheck) faisait exactement le meme fetch cross-origin depuis la WebView native, et echouait donc tout aussi silencieusement en production sans que personne ne puisse s'en rendre compte (le hook avale toute erreur reseau par design). Ajoute un petit module apps/api/src/modules/mobile/ (2 routes publiques, aucune ne touche la base de donnees) : - GET /mobile/latest-release -> { version } (proxy JSON, remplace le fetch direct de useAppUpdateCheck) - GET /mobile/download -> redirection 302 vers l'asset .apk reel de la derniere release (ou vers la page /releases/latest en repli si Forgejo est injoignable ou qu'aucun .apk n'est encore attache) Cote frontend : LandingPage.tsx et UpdateBanner.tsx pointent maintenant vers ce proxy (`${API_BASE_URL}/mobile/download`, target="_blank") au lieu d'une URL Forgejo directe — un clic, telechargement immediat, jamais de navigation hors de l'appli. useAppUpdateCheck.ts appelle desormais apiClient.getLatestMobileVersion() au lieu d'un fetch direct. Verifie en local (sans Postgres, ces routes n'en ont pas besoin) contre les vraies donnees Forgejo : GET /mobile/latest-release -> {"version":"v0.2.0"} GET /mobile/download -> 302, Location vers le vrai .apk signe pnpm lint / tsc (api + web) / pnpm --filter web build : tous verts. Le scenario Cypress "offers the Android app download" echoue encore en local (meme souci pre-existant deja documente sur la PR precedente, confirme independant du code par CI reel) ; mis a jour pour asserter le nouveau href relatif. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/app.ts | 5 ++ apps/api/src/modules/mobile/mobile.routes.ts | 31 ++++++++ apps/api/src/modules/mobile/mobile.service.ts | 75 +++++++++++++++++++ apps/web/cypress/e2e/landing.ts | 18 +++-- apps/web/src/api/client.ts | 13 +++- .../src/features/app-update/UpdateBanner.tsx | 18 ++--- .../features/app-update/useAppUpdateCheck.ts | 19 +---- apps/web/src/pages/landing/LandingPage.tsx | 26 ++++--- 8 files changed, 162 insertions(+), 43 deletions(-) create mode 100644 apps/api/src/modules/mobile/mobile.routes.ts create mode 100644 apps/api/src/modules/mobile/mobile.service.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9154300..95726a9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { authRouter } from "./modules/auth/auth.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { houseRouter } from "./modules/house/house.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; +import { mobileRouter } from "./modules/mobile/mobile.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js"; @@ -64,6 +65,10 @@ export function createServer(): ExpressServer { // rather than nested under one of them since it isn't scoped to a single // recipe/step the way `recipeRouter`'s own correction routes are. server.mountRouter("/internal/tech-steps", techStepWorkerRouter); + // Public proxy for Forgejo's Releases API (issues #65/#66) — see + // mobile.service.ts's doc comment for why this can't just be called + // directly from the browser. + server.mountRouter("/mobile", mobileRouter); server.mountRouter("/planning", planningRouter); server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/profile", profileRouter); diff --git a/apps/api/src/modules/mobile/mobile.routes.ts b/apps/api/src/modules/mobile/mobile.routes.ts new file mode 100644 index 0000000..f34fac7 --- /dev/null +++ b/apps/api/src/modules/mobile/mobile.routes.ts @@ -0,0 +1,31 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { Router } from "express"; +import { getLatestApkUrl, getLatestVersion } from "./mobile.service.js"; + +/** + * Router mounted at `/mobile` in app.ts. Both routes are deliberately + * public (no `requireAuth`): the landing page's download link is shown to + * logged-out visitors, and the native update check (issue #66) runs on + * every app start, session or not. Both exist purely to work around + * Forgejo's Releases API sending no CORS headers — see `mobile.service.ts`'s + * own doc comment for the full story. + */ +export const mobileRouter = Router(); + +mobileRouter.get( + "/latest-release", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json({ version: await getLatestVersion() }); + }), +); + +// A plain redirect, not JSON — lets any caller (the landing page's ``, +// the update banner's "Mettre à jour" link) use a static href with zero +// client-side fetch, and always resolves to something useful even if +// Forgejo is briefly unreachable (see getLatestApkUrl's fallback). +mobileRouter.get( + "/download", + wrapAsyncHandler(async (_req, res) => { + res.redirect(302, await getLatestApkUrl()); + }), +); diff --git a/apps/api/src/modules/mobile/mobile.service.ts b/apps/api/src/modules/mobile/mobile.service.ts new file mode 100644 index 0000000..6dce252 --- /dev/null +++ b/apps/api/src/modules/mobile/mobile.service.ts @@ -0,0 +1,75 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode } from "@batch-cooking/shared"; + +/** + * Forgejo's own Releases API for this repo — public, unauthenticated (the + * same fact `local-verification-while-ci-disabled` already leans on for + * CI-status checks without a token). **Must be called server-side, not + * from the browser**: verified live that Forgejo sends no + * `Access-Control-Allow-Origin` header on it, so a direct `fetch()` from + * `apps/web` fails with an opaque CORS error even though the endpoint + * itself is perfectly reachable — this whole module exists to proxy it + * from a context CORS doesn't apply to. + */ +const LATEST_RELEASE_API_URL = + "https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest"; + +/** + * Human-facing Forgejo release page — safe fallback for {@link getLatestApkUrl} + * when no matching asset is found (a release with nothing attached yet, + * Forgejo briefly unreachable, ...). Never a dead link, just a slightly + * less direct one: the visitor lands on the release itself and can find + * the asset from there. + */ +const LATEST_RELEASE_PAGE_URL = "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest"; + +/** Stable filename `release.yml` always attaches the signed APK under — see that workflow's own comment. */ +const APK_ASSET_NAME = "batchcooking.apk"; + +interface ForgejoReleaseAsset { + name: string; + browser_download_url: string; +} + +interface ForgejoRelease { + tag_name: string; + assets: ForgejoReleaseAsset[]; +} + +async function fetchLatestRelease(): Promise { + const response = await fetch(LATEST_RELEASE_API_URL); + if (!response.ok) { + throw new HttpError( + 502, + ErrorCode.INTERNAL_ERROR, + `Forgejo releases API responded ${response.status}`, + ); + } + return (await response.json()) as ForgejoRelease; +} + +/** + * The latest tagged Android build's version (e.g. `"v1.4.2"`) — used by the + * native app's update check (issue #66, `useAppUpdateCheck.ts`). + */ +export async function getLatestVersion(): Promise { + const release = await fetchLatestRelease(); + return release.tag_name; +} + +/** + * Where a "download the app" click should end up right now: the actual + * signed-APK asset of the latest release if one is attached, else + * {@link LATEST_RELEASE_PAGE_URL} as a safe fallback. Never throws — a + * download link must always go *somewhere* useful, even when Forgejo is + * briefly unreachable. + */ +export async function getLatestApkUrl(): Promise { + try { + const release = await fetchLatestRelease(); + const apk = release.assets.find((asset) => asset.name === APK_ASSET_NAME); + return apk?.browser_download_url ?? LATEST_RELEASE_PAGE_URL; + } catch { + return LATEST_RELEASE_PAGE_URL; + } +} diff --git a/apps/web/cypress/e2e/landing.ts b/apps/web/cypress/e2e/landing.ts index 4067516..067aecc 100644 --- a/apps/web/cypress/e2e/landing.ts +++ b/apps/web/cypress/e2e/landing.ts @@ -16,14 +16,18 @@ Then("I should see the landing call to action", () => { }); // The Android build is distributed as a Forgejo release asset with a stable -// filename (issue #64 builds/attaches it). Points at the release page -// itself, not a direct file URL: Forgejo has no GitHub-style -// `/releases/latest/download/` shortcut, only `/releases/latest` -// resolves (verified live against the first real release). We only assert -// the link is there and points at the right URL — the file itself is -// external to the suite. +// filename (issue #64 builds/attaches it). Points at this API's own +// `/mobile/download` (a plain server-side redirect to the actual latest +// `.apk`) rather than a Forgejo URL directly: Forgejo has neither a +// GitHub-style `/releases/latest/download/` shortcut nor CORS headers +// on its Releases API, so resolving the real asset URL client-side isn't +// possible either way (both verified live). `VITE_API_URL` is unset in this +// environment, so `API_BASE_URL` (see `api/client.ts`) is `""` — same +// origin, hence the bare `/mobile/download` path here rather than an +// absolute URL. We only assert the link is there and points at the right +// path — the redirect target itself is external to the suite. Then("I should see the Android download link", () => { cy.get(".landing__hero .landing__mobile-link") .should("be.visible") - .and("have.attr", "href", "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest"); + .and("have.attr", "href", "/mobile/download"); }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 6126374..e14bf43 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -44,7 +44,7 @@ import { noopTokenStore, type TokenStore } from "./token-store"; * in `apps/web/.env`, since the Vite dev server (5173) and the API (3000) * are on different origins there. */ -const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; +export const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; /** * The token store {@link ApiClient} consults on every request. Defaults to @@ -499,6 +499,17 @@ export class ApiClient { body: JSON.stringify({ skipped }), }); } + + /** + * The latest tagged Android release's version (e.g. `"v1.4.2"`) — used by + * {@link useAppUpdateCheck}. Proxied through this API rather than fetched + * directly from Forgejo: Forgejo's Releases API sends no CORS headers, so + * a browser-side `fetch()` to it fails outright. Public — no session + * required. + */ + public getLatestMobileVersion(): Promise<{ version: string }> { + return this._request("/mobile/latest-release"); + } } /** Single shared instance — this client is stateless, no need for one per caller. */ diff --git a/apps/web/src/features/app-update/UpdateBanner.tsx b/apps/web/src/features/app-update/UpdateBanner.tsx index 966de16..38f2d88 100644 --- a/apps/web/src/features/app-update/UpdateBanner.tsx +++ b/apps/web/src/features/app-update/UpdateBanner.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { API_BASE_URL } from "../../api/client"; import "./update-banner.scss"; import { useAppUpdateCheck } from "./useAppUpdateCheck"; @@ -10,14 +11,13 @@ import { useAppUpdateCheck } from "./useAppUpdateCheck"; * ``: a version check is informative, never something that should * block the rest of the page. * - * "Mettre à jour" opens the Forgejo release page externally (not a direct - * file link — Forgejo has no GitHub-style `/releases/latest/download/` - * shortcut, only `/releases/latest` itself resolves; verified live against - * the first real release) rather than an in-app `PackageInstaller` flow — - * no `REQUEST_INSTALL_PACKAGES` permission, no extra native code, for a - * marginal UX gain (see issue #66's own hors-scope). One extra tap on the - * release page to reach the actual asset, then the system browser handles - * the download + Android's own install prompt as before. + * "Mettre à jour" opens this API's own `/mobile/download` (a plain + * redirect straight to the latest release's actual `.apk`, resolved + * server-side — see `mobile.service.ts`) externally, rather than an in-app + * `PackageInstaller` flow: no `REQUEST_INSTALL_PACKAGES` permission, no + * extra native code, for a marginal UX gain (see issue #66's own + * hors-scope). The system browser follows the redirect and handles the + * download + Android's own install prompt. */ export function UpdateBanner() { const { t } = useTranslation(); @@ -33,7 +33,7 @@ export function UpdateBanner() {
diff --git a/apps/web/src/features/app-update/useAppUpdateCheck.ts b/apps/web/src/features/app-update/useAppUpdateCheck.ts index 247cb43..ddccf7b 100644 --- a/apps/web/src/features/app-update/useAppUpdateCheck.ts +++ b/apps/web/src/features/app-update/useAppUpdateCheck.ts @@ -1,17 +1,8 @@ import { Capacitor } from "@capacitor/core"; import { useEffect, useState } from "react"; +import { apiClient } from "../../api/client"; import { isNewerVersion } from "./version-compare"; -/** - * Forgejo's own Releases API for this repo — public, unauthenticated (the - * same fact `local-verification-while-ci-disabled` already leans on for - * CI-status checks without a token). Zero new backend endpoint needed: the - * tag of the latest *tagged* release is exactly "the latest version" this - * check needs, and it's already publicly readable. - */ -const LATEST_RELEASE_URL = - "https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest"; - /** How often a background check is allowed to actually hit the network — cold start and every resume-from-background otherwise re-checks needlessly. */ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h @@ -63,15 +54,11 @@ export function useAppUpdateCheck(): { const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0; if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return; - const [{ version: installedVersion }, response] = await Promise.all([ + const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([ App.getInfo(), - fetch(LATEST_RELEASE_URL), + apiClient.getLatestMobileVersion(), ]); await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }); - if (!response.ok) return; - - const release = (await response.json()) as { tag_name?: string }; - const latestTag = release.tag_name; if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return; const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY }); diff --git a/apps/web/src/pages/landing/LandingPage.tsx b/apps/web/src/pages/landing/LandingPage.tsx index 689dcd0..ba9be8a 100644 --- a/apps/web/src/pages/landing/LandingPage.tsx +++ b/apps/web/src/pages/landing/LandingPage.tsx @@ -2,6 +2,7 @@ import { CalendarDays, HeartPulse, Smartphone, Sparkles, Users } from "lucide-re import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import { API_BASE_URL } from "../../api/client"; import "./landing-page.scss"; /** @@ -53,19 +54,24 @@ export function LandingPage() { {/* * Secondary action — an account is needed on any platform, so the * Android build is a discreet link under the CTA row, not a third - * button. Points at the Forgejo "latest release" page (NOT a - * direct-download URL): Forgejo, unlike GitHub, has no - * `/releases/latest/download/` compound route — only - * `/releases/latest` itself resolves (redirects to the release's - * own page), verified live against the first real v0.2.0 release. - * One extra click via Forgejo's own release page to reach the - * actual `batchcooking.apk` asset, but a link that will always - * resolve rather than a shortcut that 404s. The hint is honest - * that this is a sideloaded .apk, not a Play Store listing. + * button. Points at this API's own `/mobile/download` — a plain + * redirect straight to the latest release's actual `.apk`, + * resolved server-side (see `mobile.service.ts`) — rather than a + * Forgejo URL directly: Forgejo, unlike GitHub, has no + * `/releases/latest/download/` shortcut (only + * `/releases/latest` itself resolves, to the release's HTML page, + * not a file), and its Releases API sends no CORS headers, so a + * client-side fetch to resolve the direct URL ourselves isn't an + * option either — both verified live. `target="_blank"` so the + * redirect-then-download happens in a new tab, never navigating + * this one away. The hint is honest that this is a sideloaded + * .apk, not a Play Store listing. */} {t("landing.hero.mobileDownload")}