From 892a578abbd09bf17397fac4bbc8f1eaec045259 Mon Sep 17 00:00:00 2001 From: wilson360-labs Date: Wed, 14 Oct 2026 09:34:20 -0600 Subject: [PATCH 1/2] feat(extensions): catalogo privado + proxy de extensiones de Echo El dispositivo solo habla con CodeHub: el backend resuelve y reenvia el .eapk del origen, sin exponer nunca la URL/repositorio real. - backend/modules/extensions: GET /api/extensions (lista), GET /api/extensions/pkg/:id (proxy + check de update), POST /refresh (admin). Cachea catalogo (6h) y asset (30min); replica el criterio de Echo (tag != version, asset .apk con ABI preferido). Allowlist/blocklist por env. - echo: CODEHUB_BACKEND buildConfigField; CodeHubExtensions (catalogo privado); AppUpdater acepta URL del proxy; AddSheet carga el catalogo solo al abrir; updates de instaladas pasan por el proxy si el id esta en el catalogo. - deploy.yml: node --check de los modulos nuevos (gate previo al deploy de Render). - docs/ECHO-EXTENSIONS.md. --- .github/workflows/deploy.yml | 5 + apk/android/echo/build.gradle.kts | 8 + .../echo/extensions/CodeHubExtensions.kt | 60 +++++ .../echo/ui/extensions/ExtensionsViewModel.kt | 6 +- .../add/ExtensionsAddBottomSheet.kt | 7 + .../brahmkshatriya/echo/utils/AppUpdater.kt | 35 ++- backend/modules/extensions/catalog.js | 217 ++++++++++++++++++ backend/modules/extensions/config.js | 51 ++++ backend/modules/extensions/index.js | 14 ++ backend/modules/extensions/routes.js | 110 +++++++++ backend/server.js | 7 + docs/ECHO-EXTENSIONS.md | 79 +++++++ 12 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/CodeHubExtensions.kt create mode 100644 backend/modules/extensions/catalog.js create mode 100644 backend/modules/extensions/config.js create mode 100644 backend/modules/extensions/index.js create mode 100644 backend/modules/extensions/routes.js create mode 100644 docs/ECHO-EXTENSIONS.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5dc96c26..50c6c8d7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,6 +44,11 @@ jobs: run: cd backend && npm install - name: Verificar sintaxis run: node --check backend/server.js && echo "Sintaxis OK" + - name: Verificar sintaxis modulos + run: | + for f in backend/modules/extensions/*.js; do + node --check "$f" && echo "OK $f" + done - name: Verificar archivos run: | test -f backend/server.js && echo "OK server.js" diff --git a/apk/android/echo/build.gradle.kts b/apk/android/echo/build.gradle.kts index 7623ea73..6cddcbb3 100644 --- a/apk/android/echo/build.gradle.kts +++ b/apk/android/echo/build.gradle.kts @@ -13,6 +13,13 @@ plugins { id("org.jetbrains.kotlin.plugin.serialization") } +// Backend público de CodeHub. Se usa para el catálogo privado de extensiones: +// el dispositivo solo habla con CodeHub, nunca con el origen real. +// Se puede sobreescribir con -PCODEHUB_BACKEND=... o en gradle.properties. +val codehubBackend: String = + (findProperty("CODEHUB_BACKEND") as String?)?.trim()?.takeIf { it.isNotEmpty() } + ?: "https://codehub-98s6.onrender.com" + android { namespace = "dev.brahmkshatriya.echo" compileSdk = 36 @@ -24,6 +31,7 @@ android { // ContextUtils.appVersion() y en la versión de las extensiones built-in. buildConfigField("String", "VERSION_NAME", "\"7.18\"") buildConfigField("int", "VERSION_CODE", "10") + buildConfigField("String", "CODEHUB_BACKEND", "\"$codehubBackend\"") } buildFeatures { diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/CodeHubExtensions.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/CodeHubExtensions.kt new file mode 100644 index 00000000..e910e780 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/CodeHubExtensions.kt @@ -0,0 +1,60 @@ +package dev.brahmkshatriya.echo.extensions + +import dev.brahmkshatriya.echo.BuildConfig +import dev.brahmkshatriya.echo.common.helpers.ContinuationCallback.Companion.await +import dev.brahmkshatriya.echo.utils.Serializer.toData +import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Catálogo privado de extensiones alojado por CodeHub. + * + * El dispositivo SOLO habla con el backend de CodeHub: es el servidor quien + * resuelve y reenvía el `.eapk` real, así que la URL/repositorio de origen + * nunca viaja al cliente. Aquí no se guarda ni se muestra ese enlace. + */ +object CodeHubExtensions { + + /** Ruta del proxy de descarga que sirve el backend. */ + const val PKG_PATH = "/api/extensions/pkg/" + + private const val TTL_MS = 30 * 60 * 1000L + + val backend: String = BuildConfig.CODEHUB_BACKEND.trimEnd('/') + + /** Lista de extensiones disponibles (el mismo JSON que ya consume Echo). */ + fun catalogUrl(): String = "$backend/api/extensions" + + /** ¿Esta URL la sirve el backend de CodeHub? */ + fun isProxyUrl(url: String): Boolean = url.contains(PKG_PATH) + + @Serializable + data class Entry( + val id: String, + val name: String, + val updateUrl: String, + ) + + @Volatile private var cache: Map? = null + @Volatile private var cacheAt: Long = 0L + + /** Mapa `id -> updateUrl` (proxy), cacheado en memoria. */ + suspend fun catalog(client: OkHttpClient, force: Boolean = false): Map { + val now = System.currentTimeMillis() + val current = cache + if (!force && current != null && now - cacheAt < TTL_MS) return current + val fresh = runCatching { + client.newCall(Request.Builder().url(catalogUrl()).build()) + .await().body.string() + .toData>().getOrThrow() + .associate { it.id to it.updateUrl } + }.getOrNull() ?: return current ?: emptyMap() + cache = fresh + cacheAt = now + return fresh + } + + /** URL proxy del `id`, o `null` si la extensión no está en el catálogo. */ + suspend fun updateUrlFor(id: String, client: OkHttpClient): String? = catalog(client)[id] +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionsViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionsViewModel.kt index 806fbf4d..37fb4adb 100644 --- a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionsViewModel.kt +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionsViewModel.kt @@ -14,6 +14,7 @@ import dev.brahmkshatriya.echo.di.App import dev.brahmkshatriya.echo.extensions.ExtensionLoader import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getOrThrow +import dev.brahmkshatriya.echo.extensions.CodeHubExtensions import dev.brahmkshatriya.echo.extensions.InstallationUtils.installApp import dev.brahmkshatriya.echo.extensions.InstallationUtils.installFile import dev.brahmkshatriya.echo.extensions.InstallationUtils.uninstallApp @@ -212,7 +213,10 @@ class ExtensionsViewModel( show: Boolean = false ): File? { val currentVersion = extension.version - val updateUrl = extension.metadata.updateUrl ?: return null + // Catálogo privado de CodeHub primero (el origen nunca se expone); + // si la extensión no está en él, se mantiene la URL de su manifiesto. + val updateUrl = CodeHubExtensions.updateUrlFor(extension.id, client) + ?: extension.metadata.updateUrl ?: return null val url = runCatching { getUpdateFileUrl(currentVersion, updateUrl, client).getOrThrow() }.recoverCatching { diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddBottomSheet.kt index d4d5a21e..c3f626c1 100644 --- a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddBottomSheet.kt +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddBottomSheet.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.databinding.DialogExtensionAddBinding +import dev.brahmkshatriya.echo.extensions.CodeHubExtensions import dev.brahmkshatriya.echo.extensions.InstallationUtils.openFileSelector import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel @@ -58,6 +59,12 @@ class ExtensionsAddBottomSheet : BottomSheetDialogFragment() { } } + // Catálogo privado de CodeHub: se carga solo al abrir, sin que el + // usuario pegue ningún enlace y sin mostrar nunca la URL de origen. + if (!viewModel.opened && viewModel.addingFlow.value is AddViewModel.AddState.Init) { + viewModel.addFromLinkOrCode(CodeHubExtensions.catalogUrl()) + } + observe(viewModel.addingFlow) { val loading = it != AddViewModel.AddState.Init binding.loading.root.isVisible = loading diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppUpdater.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppUpdater.kt index a9176cd1..f2abe410 100644 --- a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppUpdater.kt +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppUpdater.kt @@ -18,6 +18,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okio.use import java.io.File +import java.net.URLEncoder import java.util.zip.ZipFile object AppUpdater { @@ -174,10 +175,36 @@ object AppUpdater { client: OkHttpClient ) = runIOCatching { if (updateUrl.isEmpty()) return@runIOCatching null - if (updateUrl.startsWith("https://api.github.com/repos/")) { - getGithubUpdateUrl(currentVersion, updateUrl, client) - } else { - throw Exception("Unsupported update url") + when { + updateUrl.startsWith("https://api.github.com/repos/") -> + getGithubUpdateUrl(currentVersion, updateUrl, client) + + // Catálogo privado de CodeHub: el backend resuelve el origen y + // decide si hay actualización. El enlace real nunca llega aquí. + updateUrl.contains("/api/extensions/pkg/") -> + getCodeHubUpdateUrl(currentVersion, updateUrl, client) + + else -> throw Exception("Unsupported update url") + } + } + + private suspend fun getCodeHubUpdateUrl( + currentVersion: String, + updateUrl: String, + client: OkHttpClient + ): String? { + val check = updateUrl + "?check=1&from=" + + URLEncoder.encode(currentVersion, "UTF-8") + val response = client.newCall(Request.Builder().url(check).build()).await() + return try { + when (response.code) { + 200 -> updateUrl + 204 -> null + 404 -> throw Exception("Extensión no encontrada en el catálogo") + else -> throw Exception("CodeHub extensions: HTTP ${response.code}") + } + } finally { + response.close() } } diff --git a/backend/modules/extensions/catalog.js b/backend/modules/extensions/catalog.js new file mode 100644 index 00000000..3c904631 --- /dev/null +++ b/backend/modules/extensions/catalog.js @@ -0,0 +1,217 @@ +'use strict'; +/** + * extensions/catalog.js — Catálogo privado de extensiones de Echo. + * + * Lee la lista del ORIGEN (privado), la normaliza y expone: + * · toPublic(item) → metadatos SIN el enlace real; `updateUrl` apunta al + * proxy de CodeHub. + * · resolveAsset() → resuelve en el servidor el .eapk más reciente del + * origen (GitHub / GitLab / archivo directo). + * + * El dispositivo nunca ve el repo de origen ni el asset real. + */ + +const cfg = require('./config'); + +const GH_RE = /^https:\/\/api\.github\.com\/repos\/([^/]+)\/([^/]+)\//; +const ABI_ORDER = ['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']; + +let _catalog = null; // { at, items: Map } +let _catalogPending = null; +const _assets = new Map(); // id -> { at, asset } + +function isAllowed(id) { + if (cfg.BLOCKLIST.includes(id)) return false; + if (cfg.ALLOWLIST.length && !cfg.ALLOWLIST.includes(id)) return false; + return true; +} + +function outboundHeaders(url) { + const headers = { + 'User-Agent': 'CodeHub-Extensions/1.0 (+https://codehub-98s6.onrender.com)', + Accept: 'application/json', + }; + if (/^https:\/\/api\.github\.com\//.test(url)) { + headers.Accept = 'application/vnd.github+json'; + if (cfg.GITHUB_TOKEN) headers.Authorization = `Bearer ${cfg.GITHUB_TOKEN}`; + } + return headers; +} + +async function fetchWithTimeout(url, acceptBinary, timeoutMs) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeoutMs || cfg.FETCH_TIMEOUT_MS); + try { + return await fetch(url, { + headers: outboundHeaders(url), + signal: ctl.signal, + redirect: 'follow', + }); + } finally { + clearTimeout(timer); + } +} + +async function fetchJson(url) { + const res = await fetchWithTimeout(url, false, cfg.FETCH_TIMEOUT_MS); + if (!res.ok) throw new Error(`origen HTTP ${res.status}`); + return res.json(); +} + +function baseName(url) { + try { + const clean = String(url).split('#')[0].split('?')[0]; + return clean.substring(clean.lastIndexOf('/') + 1) || 'extension.eapk'; + } catch { + return 'extension.eapk'; + } +} + +function buildCatalog() { + return (async () => { + const data = await fetchJson(cfg.SOURCE_URL); + if (!Array.isArray(data)) throw new Error('catálogo con formato inválido'); + const items = new Map(); + for (const raw of data) { + if (!raw || typeof raw !== 'object') continue; + const id = String(raw.id || '').trim(); + const originUpdateUrl = String(raw.updateUrl || '').trim(); + if (!id || !originUpdateUrl) continue; + if (!isAllowed(id)) continue; + items.set(id, { + id, + type: raw.type != null ? String(raw.type) : undefined, + name: raw.name != null ? String(raw.name) : id, + subtitle: raw.subtitle != null ? String(raw.subtitle) : undefined, + iconUrl: raw.iconUrl != null ? String(raw.iconUrl) : undefined, + // Campos privados (NUNCA se serializan al cliente): + originUpdateUrl, + serviceUrl: raw.url != null ? String(raw.url) : undefined, + }); + } + return items; + })(); +} + +async function loadCatalog(force = false) { + if (!force && _catalog && Date.now() - _catalog.at < cfg.CACHE_TTL_MS) return _catalog.items; + if (!_catalogPending) { + _catalogPending = buildCatalog() + .then((items) => { + _catalog = { at: Date.now(), items }; + return items; + }) + .finally(() => { + _catalogPending = null; + }); + } + return _catalogPending; +} + +function toPublic(item) { + const out = { + id: item.id, + name: item.name, + // El cliente reemplaza este updateUrl por el del proxy: el real no viaja. + updateUrl: `${cfg.BACKEND_URL}/api/extensions/pkg/${encodeURIComponent(item.id)}`, + }; + if (item.type != null) out.type = item.type; + if (item.subtitle != null) out.subtitle = item.subtitle; + if (item.iconUrl != null) out.iconUrl = item.iconUrl; + return out; +} + +function pickAsset(assets) { + const apks = (Array.isArray(assets) ? assets : []).filter( + (a) => a && typeof a.name === 'string' && /apk$/i.test(a.name) && a.browser_download_url + ); + if (!apks.length) return null; + for (const abi of ABI_ORDER) { + const hit = apks.find((a) => a.name.includes(abi)); + if (hit) return hit; + } + return apks[0]; +} + +async function resolveGitHub(originUrl) { + const m = GH_RE.exec(originUrl); + if (!m) return null; + const url = `https://api.github.com/repos/${m[1]}/${m[2]}/releases/latest`; + const rel = await fetchJson(url); + const asset = pickAsset(rel && rel.assets); + if (!asset) return null; + return { + url: String(asset.browser_download_url), + version: String((rel && rel.tag_name) || ''), + name: String(asset.name || baseName(asset.browser_download_url)), + }; +} + +function findApkUrlDeep(node, depth) { + const d = depth || 0; + if (node == null || d > 6) return null; + if (typeof node === 'string') return /\.apk(k)?(\?|$)/i.test(node) ? node : null; + if (Array.isArray(node)) { + for (const v of node) { + const hit = findApkUrlDeep(v, d + 1); + if (hit) return hit; + } + return null; + } + if (typeof node === 'object') { + for (const v of Object.values(node)) { + const hit = findApkUrlDeep(v, d + 1); + if (hit) return hit; + } + } + return null; +} + +async function resolveGeneric(originUrl) { + if (/\.apk(k)?(\?|$)/i.test(originUrl)) { + return { url: originUrl, version: '', name: baseName(originUrl) }; + } + const data = await fetchJson(originUrl); + const url = findApkUrlDeep(data, 0); + if (!url) return null; + const version = String((data && (data.tag_name || data.tagName || data.name)) || ''); + return { url, version, name: baseName(url) }; +} + +async function resolveAsset(item, force = false) { + const cached = _assets.get(item.id); + if (!force && cached && Date.now() - cached.at < cfg.ASSET_TTL_MS) return cached.asset; + + const asset = GH_RE.test(item.originUpdateUrl) + ? await resolveGitHub(item.originUpdateUrl) + : await resolveGeneric(item.originUpdateUrl); + + if (asset) _assets.set(item.id, { at: Date.now(), asset }); + return asset; +} + +/** Mismo criterio que Echo: hay actualización si la etiqueta cambió. */ +function hasUpdate(asset, from) { + if (!asset) return false; + if (!from) return true; + return asset.version !== from; +} + +async function fetchBinary(url) { + const res = await fetchWithTimeout(url, true, cfg.FETCH_TIMEOUT_MS * 3); + if (!res.ok) throw new Error(`asset HTTP ${res.status}`); + const declared = Number(res.headers.get('content-length') || 0); + if (declared && declared > cfg.MAX_BYTES) throw new Error('asset demasiado grande'); + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > cfg.MAX_BYTES) throw new Error('asset demasiado grande'); + return buf; +} + +module.exports = { + loadCatalog, + toPublic, + resolveAsset, + hasUpdate, + fetchBinary, + baseName, +}; diff --git a/backend/modules/extensions/config.js b/backend/modules/extensions/config.js new file mode 100644 index 00000000..41e4f7e4 --- /dev/null +++ b/backend/modules/extensions/config.js @@ -0,0 +1,51 @@ +'use strict'; +/** + * extensions/config.js — Config del módulo de extensiones de Echo. + * + * Todo lo que apunta al ORIGEN es privado y vive solo en el servidor: + * el dispositivo nunca recibe la URL real (ni el repo de GitHub ni el + * asset de descarga). Solo habla con CodeHub. + * + * Variables de entorno (todas opcionales): + * ECHO_EXTENSIONS_SOURCE_URL JSON con la lista de extensiones. + * ECHO_EXTENSIONS_ALLOWLIST ids permitidos (coma). Vacío = todos. + * ECHO_EXTENSIONS_BLOCKLIST ids vetados (coma). + * ECHO_EXTENSIONS_CACHE_TTL_MS TTL del catálogo (def. 6h). + * ECHO_EXTENSIONS_ASSET_TTL_MS TTL de la resolución del .eapk (def. 30min). + * ECHO_EXTENSIONS_MAX_BYTES Tamaño máximo a reenviar (def. 80 MB). + * ECHO_EXTENSIONS_FETCH_TIMEOUT_MS Timeout saliente (def. 20s). + * BACKEND_URL URL pública del backend (para armar el proxy). + * GITHUB_TOKEN / GH_TOKEN Opcional, sube el límite de la API de GitHub. + */ + +function num(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function list(value) { + return String(value || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +const cfg = { + SOURCE_URL: + process.env.ECHO_EXTENSIONS_SOURCE_URL || + 'https://raw.githubusercontent.com/itsmechinmoy/echo-extensions/main/echo_extensions.json', + + BACKEND_URL: (process.env.BACKEND_URL || 'https://codehub-98s6.onrender.com').replace(/\/+$/, ''), + + ALLOWLIST: list(process.env.ECHO_EXTENSIONS_ALLOWLIST), + BLOCKLIST: list(process.env.ECHO_EXTENSIONS_BLOCKLIST), + + CACHE_TTL_MS: num(process.env.ECHO_EXTENSIONS_CACHE_TTL_MS, 6 * 60 * 60 * 1000), + ASSET_TTL_MS: num(process.env.ECHO_EXTENSIONS_ASSET_TTL_MS, 30 * 60 * 1000), + MAX_BYTES: num(process.env.ECHO_EXTENSIONS_MAX_BYTES, 80 * 1024 * 1024), + FETCH_TIMEOUT_MS: num(process.env.ECHO_EXTENSIONS_FETCH_TIMEOUT_MS, 20000), + + GITHUB_TOKEN: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '', +}; + +module.exports = cfg; diff --git a/backend/modules/extensions/index.js b/backend/modules/extensions/index.js new file mode 100644 index 00000000..796d018b --- /dev/null +++ b/backend/modules/extensions/index.js @@ -0,0 +1,14 @@ +'use strict'; +/** + * index.js — Barrel export del módulo Extensions. + * Módulo: catálogo privado + proxy de extensiones de Echo · CodeHub + * ───────────────────────────────────────────────────────────────── + * Uso en server.js: + * + * const extensionsRouter = require('./modules/extensions'); + * app.use('/api/extensions', requireAuth, extensionsRouter); + * + * Ver backend/modules/extensions/config.js para las variables de entorno. + */ + +module.exports = require('./routes'); diff --git a/backend/modules/extensions/routes.js b/backend/modules/extensions/routes.js new file mode 100644 index 00000000..bb8869d6 --- /dev/null +++ b/backend/modules/extensions/routes.js @@ -0,0 +1,110 @@ +'use strict'; +/** + * extensions/routes.js — Catálogo privado + proxy de extensiones de Echo. + * + * Montado en `/api/extensions`. El cliente SOLO ve URLs de CodeHub: + * + * GET / Lista para instalar (JSON que consume Echo). + * GET /pkg/:id Descarga el .eapk (proxy del origen). + * GET /pkg/:id?check=1&from= Responden 200 (hay update) o 204 (no hay). + * POST /refresh Invalida la caché del catálogo (ADMIN_KEY). + * + * Seguridad/privacidad: + * · El origen (repo + asset) nunca se envía al dispositivo. + * · El `id` debe existir en el catálogo: no se aceptan URLs del cliente, + * así que no hay SSRF. + */ + +const express = require('express'); +const rateLimit = require('express-rate-limit'); + +const cat = require('./catalog'); + +const router = express.Router(); + +const fail = (res, error, code) => res.status(code || 400).json({ ok: false, error }); + +const listLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: { ok: false, error: 'Demasiadas solicitudes. Espera un momento.' }, +}); + +const pkgLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { ok: false, error: 'Demasiadas descargas. Espera un momento.' }, +}); + +// ── GET / — catálogo público (metadatos, sin origen) ──────────────── +router.get('/', listLimiter, async (req, res) => { + try { + const items = await cat.loadCatalog(); + res.setHeader('Cache-Control', 'public, max-age=600'); + return res.json(Array.from(items.values()).map(cat.toPublic)); + } catch (e) { + return fail(res, 'Catálogo no disponible', 502); + } +}); + +// ── GET /pkg/:id — proxy de descarga / comprobación de update ─────── +router.get('/pkg/:id', pkgLimiter, async (req, res) => { + const id = String(req.params.id || ''); + let item; + try { + const items = await cat.loadCatalog(); + item = items.get(id); + } catch (e) { + return fail(res, 'Catálogo no disponible', 502); + } + if (!item) return fail(res, 'Extensión no encontrada', 404); + + const from = String(req.query.from || ''); + let asset; + try { + asset = await cat.resolveAsset(item); + } catch (e) { + return fail(res, 'No se pudo resolver el paquete de origen', 502); + } + if (!asset || !asset.url) return fail(res, 'Paquete no disponible', 502); + + const available = cat.hasUpdate(asset, from); + if (String(req.query.check) === '1') { + res.status(available ? 200 : 204); + return res.end(); + } + if (!available) { + res.status(204); + return res.end(); + } + + try { + const buf = await cat.fetchBinary(asset.url); + res.setHeader('Content-Type', 'application/vnd.android.package-archive'); + res.setHeader('Content-Length', String(buf.length)); + res.setHeader('Content-Disposition', `attachment; filename="${cat.baseName(asset.name)}"`); + res.setHeader('Cache-Control', 'no-store'); + return res.send(buf); + } catch (e) { + return fail(res, 'No se pudo descargar el paquete', 502); + } +}); + +// ── POST /refresh — invalida la caché del catálogo (admin) ────────── +router.post('/refresh', async (req, res) => { + const validKey = process.env.ADMIN_KEY; + const key = req.headers['x-admin-key']; + if (!validKey || !key || key !== validKey) return fail(res, 'No autorizado', 401); + try { + await cat.loadCatalog(true); + return res.json({ ok: true }); + } catch (e) { + return fail(res, 'No se pudo refrescar el catálogo', 502); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 48363d1a..84022b61 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1952,6 +1952,13 @@ app.use('/api/user', requireAuth, userDataRouter); const rewardsRouter = require('./modules/rewards'); app.use('/api/rewards', requireAuth, rewardsRouter); +// ── Extensions — catálogo privado + proxy de extensiones de Echo ─ +// El dispositivo solo conoce URLs de CodeHub: el backend resuelve el +// .eapk real (GitHub/GitLab) y lo reenvía. El origen nunca se expone. +// Ver backend/modules/extensions/. +const extensionsRouter = require('./modules/extensions'); +app.use('/api/extensions', requireAuth, extensionsRouter); + // ── WIL.E INTELLIGENCE CORE — rutas ────────────────────────── // Memoria, base de conocimiento (RAG) e ingesta privada de entrenamiento. // Seguridad: isAdminReq compara el valor de la key contra ADMIN_KEY (nunca diff --git a/docs/ECHO-EXTENSIONS.md b/docs/ECHO-EXTENSIONS.md new file mode 100644 index 00000000..fc33701c --- /dev/null +++ b/docs/ECHO-EXTENSIONS.md @@ -0,0 +1,79 @@ +# Extensiones de Echo — catálogo privado y proxy + +Reproductor Echo integrado en CodeHub (`apk/android/echo`). Las extensiones se +instalan/actualizan **sin exponer nunca el enlace ni el repositorio de origen**: +el dispositivo solo habla con el backend de CodeHub. + +## Cómo funciona + +``` +App (Echo) Backend CodeHub Origen (privado) + │ │ │ + │ GET /api/extensions │ │ + │──────────────────────────────►│ fetch ──────────►│ + │◄──── [{id,name,updateUrl:…}] │ (caché 6 h) │ + │ updateUrl = proxy │ │ + │ │ │ + │ GET /api/extensions/pkg/:id?check=1&from= │ + │──────────────────────────────►│ resuelve /releases/latest ───────►│ + │◄──── 200 (hay update) / 204 │ (caché 30 min) │ + │ │ │ + │ GET /api/extensions/pkg/:id │ descarga el .eapk ──────────────►│ + │──────────────────────────────►│ lo reenvía al cliente │ + │◄──── .eapk (bytes) │ │ +``` + +- El JSON que consume Echo es el mismo formato de siempre + (`id, name, subtitle, iconUrl, updateUrl`), así que no hace falta tocar el + parser ni la UI de "Añadir extensiones". +- `updateUrl` lo reescribe el backend hacia su propio proxy. La URL real + (p. ej. `api.github.com/repos/…/releases`) nunca sale del servidor. +- El servidor solo acepta `id` presentes en el catálogo → no hay SSRF. + +## Homologación con Echo + +Echo considera que hay actualización si la etiqueta de la última release cambia +(`tag_name != currentVersion`) y elige el asset cuyo nombre termina en `apk` +(prefiriendo el ABI `arm64-v8a`). El backend replica exactamente ese criterio. + +## Endpoints (`backend/modules/extensions`) + +| Método | Ruta | Descripción | +|---|---|---| +| `GET` | `/api/extensions` | Lista para instalar (JSON de Echo, sin origen). | +| `GET` | `/api/extensions/pkg/:id` | Descarga el `.eapk` (proxy del origen). | +| `GET` | `/api/extensions/pkg/:id?check=1&from=` | `200` = hay update, `204` = no hay. | +| `POST` | `/api/extensions/refresh` | Invalida la caché (header `x-admin-key`). | + +## Variables de entorno (todas opcionales) + +| Variable | Defecto | Uso | +|---|---|---| +| `ECHO_EXTENSIONS_SOURCE_URL` | lista pública de Echo | JSON con las extensiones. | +| `ECHO_EXTENSIONS_ALLOWLIST` | *(vacío = todas)* | ids permitidos (coma). | +| `ECHO_EXTENSIONS_BLOCKLIST` | *(vacío)* | ids vetados (coma). | +| `ECHO_EXTENSIONS_CACHE_TTL_MS` | `6h` | TTL del catálogo. | +| `ECHO_EXTENSIONS_ASSET_TTL_MS` | `30min` | TTL de la resolución del `.eapk`. | +| `ECHO_EXTENSIONS_MAX_BYTES` | `80 MB` | Tamaño máximo a reenviar. | +| `ECHO_EXTENSIONS_FETCH_TIMEOUT_MS` | `20s` | Timeout saliente. | +| `BACKEND_URL` | `https://codehub-98s6.onrender.com` | Base pública del proxy. | +| `GITHUB_TOKEN` / `GH_TOKEN` | *(opcional)* | Sube el límite de la API de GitHub. | + +> Para ofrecer **solo** ciertas extensiones usa `ECHO_EXTENSIONS_ALLOWLIST` +> (p. ej. `deezer,spotify,youtube-music`). Sin ella se ofrecen todas las de la +> fuente. + +## Cliente Android + +- `extensions/CodeHubExtensions.kt` — base del backend, URL del catálogo y + caché en memoria `id → updateUrl`. +- `ExtensionsAddBottomSheet` — carga el catálogo solo al abrir el diálogo + (no hay que pegar ningún enlace). +- `AppUpdater.getUpdateFileUrl` — acepta las URLs del proxy además de las de + GitHub. +- `ExtensionsViewModel.getExtensionUpdate` — si el `id` está en el catálogo, + la actualización pasa por el proxy; si no, se mantiene la URL del manifiesto. + +La base del backend se inyecta con `buildConfigField("...", "CODEHUB_BACKEND", …)` +en `apk/android/echo/build.gradle.kts` y se puede sobreescribir con +`-PCODEHUB_BACKEND=https://…`. From 6a18441254a259a06cfe01a986a3a62a766b7b1a Mon Sep 17 00:00:00 2001 From: wilson360-labs Date: Wed, 14 Oct 2026 09:41:52 -0600 Subject: [PATCH 2/2] fix(extensions): resolver .eapk en origenes no-GitHub (GitLab) El regex /\.apk(k)?(\?|$)/ no casaba con .eapk (hay una 'e' entre el punto y 'apk'), y el campo 'name' de la release (p.ej. jellyfin-8de7e92.eapk) se colaba como si fuera URL. Ahora se usa el mismo criterio que Echo (endsWith 'apk') exigiendo http(s) y ignorando query/fragmento. Se anade .github/workflows/extensions-check.yml: node --check + carga/exports del modulo en ramas feat/**, porque deploy.yml solo corre en main/PR. --- .github/workflows/extensions-check.yml | 50 ++++++++++++++++++++++++++ backend/modules/extensions/catalog.js | 10 ++++-- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/extensions-check.yml diff --git a/.github/workflows/extensions-check.yml b/.github/workflows/extensions-check.yml new file mode 100644 index 00000000..86ad551f --- /dev/null +++ b/.github/workflows/extensions-check.yml @@ -0,0 +1,50 @@ +name: Extensions module (rama) + +# Verificación del módulo backend de extensiones de Echo +# (backend/modules/extensions). Node viene preinstalado en los runners de +# GitHub, así que no hace falta setup-node. +# +# Complementa a deploy.yml (que solo corre en main/PR): aquí se valida en +# ramas de trabajo para no tocar main sin comprobar antes la sintaxis. +on: + push: + branches: + - 'feat/**' + - 'echo/**' + - main + paths: + - 'backend/modules/extensions/**' + - '.github/workflows/extensions-check.yml' + pull_request: + paths: + - 'backend/modules/extensions/**' + - '.github/workflows/extensions-check.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + name: Verificar módulo de extensiones (JS) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Sintaxis (node --check) + run: | + node -v + for f in backend/modules/extensions/*.js; do + node --check "$f" && echo "OK $f" + done + + - name: Carga y exports (catalog.js no depende de npm) + run: | + node -e "const c=require('./backend/modules/extensions/catalog.js'); const need=['loadCatalog','toPublic','resolveAsset','hasUpdate','fetchBinary','baseName']; for (const k of need) { if (typeof c[k] !== 'function') throw new Error('falta export: ' + k); } console.log('catalog.js OK:', Object.keys(c).join(', '));" + + - name: Carga y exports (config.js) + run: | + node -e "const c=require('./backend/modules/extensions/config.js'); const need=['SOURCE_URL','BACKEND_URL','CACHE_TTL_MS','ASSET_TTL_MS','MAX_BYTES','FETCH_TIMEOUT_MS']; for (const k of need) { if (c[k] === undefined) throw new Error('falta config: ' + k); } console.log('config.js OK:', c.BACKEND_URL);" diff --git a/backend/modules/extensions/catalog.js b/backend/modules/extensions/catalog.js index 3c904631..54da4255 100644 --- a/backend/modules/extensions/catalog.js +++ b/backend/modules/extensions/catalog.js @@ -147,10 +147,16 @@ async function resolveGitHub(originUrl) { }; } +function isApkUrl(value) { + if (typeof value !== 'string' || !/^https?:\/\//i.test(value)) return false; + const clean = value.split('#')[0].split('?')[0]; + return /apk$/i.test(clean); +} + function findApkUrlDeep(node, depth) { const d = depth || 0; if (node == null || d > 6) return null; - if (typeof node === 'string') return /\.apk(k)?(\?|$)/i.test(node) ? node : null; + if (typeof node === 'string') return isApkUrl(node) ? node : null; if (Array.isArray(node)) { for (const v of node) { const hit = findApkUrlDeep(v, d + 1); @@ -168,7 +174,7 @@ function findApkUrlDeep(node, depth) { } async function resolveGeneric(originUrl) { - if (/\.apk(k)?(\?|$)/i.test(originUrl)) { + if (isApkUrl(originUrl)) { return { url: originUrl, version: '', name: baseName(originUrl) }; } const data = await fetchJson(originUrl);