Merge pull request 'feat(mobile): proxie le téléchargement de l'APK via l'API (pas de CORS)' (#77) from feat/mobile-download-proxy into main
Some checks failed
CI / lint (push) Successful in 5m55s
CI / intent-service-test (push) Successful in 6m29s
CI / build (push) Successful in 6m36s
CI / test (push) Failing after 10m23s
CI / e2e (push) Successful in 13m7s
CI / build-android (push) Has been skipped

Reviewed-on: #77
This commit is contained in:
kyuno 2026-09-06 23:58:20 +02:00
commit 6d25faa633
8 changed files with 162 additions and 43 deletions

View file

@ -10,6 +10,7 @@ import { authRouter } from "./modules/auth/auth.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.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 { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.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 // 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. // recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter); 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("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);

View file

@ -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 `<a>`,
// 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());
}),
);

View file

@ -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<ForgejoRelease> {
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<string> {
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<string> {
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;
}
}

View file

@ -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 // The Android build is distributed as a Forgejo release asset with a stable
// filename (issue #64 builds/attaches it). Points at the release page // filename (issue #64 builds/attaches it). Points at this API's own
// itself, not a direct file URL: Forgejo has no GitHub-style // `/mobile/download` (a plain server-side redirect to the actual latest
// `/releases/latest/download/<file>` shortcut, only `/releases/latest` // `.apk`) rather than a Forgejo URL directly: Forgejo has neither a
// resolves (verified live against the first real release). We only assert // GitHub-style `/releases/latest/download/<file>` shortcut nor CORS headers
// the link is there and points at the right URL — the file itself is // on its Releases API, so resolving the real asset URL client-side isn't
// external to the suite. // 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", () => { Then("I should see the Android download link", () => {
cy.get(".landing__hero .landing__mobile-link") cy.get(".landing__hero .landing__mobile-link")
.should("be.visible") .should("be.visible")
.and("have.attr", "href", "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest"); .and("have.attr", "href", "/mobile/download");
}); });

View file

@ -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) * in `apps/web/.env`, since the Vite dev server (5173) and the API (3000)
* are on different origins there. * 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 * The token store {@link ApiClient} consults on every request. Defaults to
@ -499,6 +499,17 @@ export class ApiClient {
body: JSON.stringify({ skipped }), 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. */ /** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE_URL } from "../../api/client";
import "./update-banner.scss"; import "./update-banner.scss";
import { useAppUpdateCheck } from "./useAppUpdateCheck"; import { useAppUpdateCheck } from "./useAppUpdateCheck";
@ -10,14 +11,13 @@ import { useAppUpdateCheck } from "./useAppUpdateCheck";
* `<dialog>`: a version check is informative, never something that should * `<dialog>`: a version check is informative, never something that should
* block the rest of the page. * block the rest of the page.
* *
* "Mettre à jour" opens the Forgejo release page externally (not a direct * "Mettre à jour" opens this API's own `/mobile/download` (a plain
* file link — Forgejo has no GitHub-style `/releases/latest/download/<file>` * redirect straight to the latest release's actual `.apk`, resolved
* shortcut, only `/releases/latest` itself resolves; verified live against * server-side — see `mobile.service.ts`) externally, rather than an in-app
* the first real release) rather than an in-app `PackageInstaller` flow — * `PackageInstaller` flow: no `REQUEST_INSTALL_PACKAGES` permission, no
* no `REQUEST_INSTALL_PACKAGES` permission, no extra native code, for a * extra native code, for a marginal UX gain (see issue #66's own
* marginal UX gain (see issue #66's own hors-scope). One extra tap on the * hors-scope). The system browser follows the redirect and handles the
* release page to reach the actual asset, then the system browser handles * download + Android's own install prompt.
* the download + Android's own install prompt as before.
*/ */
export function UpdateBanner() { export function UpdateBanner() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -33,7 +33,7 @@ export function UpdateBanner() {
<div className="update-banner__actions"> <div className="update-banner__actions">
<a <a
className="update-banner__update" className="update-banner__update"
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest" href={`${API_BASE_URL}/mobile/download`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >

View file

@ -1,17 +1,8 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiClient } from "../../api/client";
import { isNewerVersion } from "./version-compare"; 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. */ /** 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 const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
@ -63,15 +54,11 @@ export function useAppUpdateCheck(): {
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0; const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return; 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(), App.getInfo(),
fetch(LATEST_RELEASE_URL), apiClient.getLatestMobileVersion(),
]); ]);
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }); 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; if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return;
const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY }); const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY });

View file

@ -2,6 +2,7 @@ import { CalendarDays, HeartPulse, Smartphone, Sparkles, Users } from "lucide-re
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { API_BASE_URL } from "../../api/client";
import "./landing-page.scss"; import "./landing-page.scss";
/** /**
@ -53,19 +54,24 @@ export function LandingPage() {
{/* {/*
* Secondary action — an account is needed on any platform, so the * Secondary action — an account is needed on any platform, so the
* Android build is a discreet link under the CTA row, not a third * Android build is a discreet link under the CTA row, not a third
* button. Points at the Forgejo "latest release" page (NOT a * button. Points at this API's own `/mobile/download` — a plain
* direct-download URL): Forgejo, unlike GitHub, has no * redirect straight to the latest release's actual `.apk`,
* `/releases/latest/download/<file>` compound route — only * resolved server-side (see `mobile.service.ts`) — rather than a
* `/releases/latest` itself resolves (redirects to the release's * Forgejo URL directly: Forgejo, unlike GitHub, has no
* own page), verified live against the first real v0.2.0 release. * `/releases/latest/download/<file>` shortcut (only
* One extra click via Forgejo's own release page to reach the * `/releases/latest` itself resolves, to the release's HTML page,
* actual `batchcooking.apk` asset, but a link that will always * not a file), and its Releases API sends no CORS headers, so a
* resolve rather than a shortcut that 404s. The hint is honest * client-side fetch to resolve the direct URL ourselves isn't an
* that this is a sideloaded .apk, not a Play Store listing. * 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.
*/} */}
<a <a
className="landing__mobile-link" className="landing__mobile-link"
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest" href={`${API_BASE_URL}/mobile/download`}
target="_blank"
rel="noreferrer"
> >
<Smartphone aria-hidden /> <Smartphone aria-hidden />
{t("landing.hero.mobileDownload")} {t("landing.hero.mobileDownload")}