Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Plataforma para publicar y descubrir ROM hacks de consolas retro (NES, SNES, N64, Game Boy, GBA, DS, 3DS, Switch). Este sitio solo aloja archivos de parche (.ips, .bps, .ups, .xdelta), nunca la ROM completa de un juego. Distribuir una ROM con copyright —modificada o no— es una infracción de derechos de autor. El modelo de parches es el que usan comunidades como RomHacking.net:README 전문 보기 ↗
Plataforma para publicar y descubrir ROM hacks de consolas retro (NES, SNES, N64, Game Boy, GBA, DS, 3DS, Switch).
Este sitio solo aloja archivos de parche (.ips, .bps, .ups, .xdelta),
nunca la ROM completa de un juego. Distribuir una ROM con copyright —modificada
o no— es una infracción de derechos de autor. El modelo de parches es el que
usan comunidades como RomHacking.net:
/patch, que corre 100% en el
navegador (nada se sube al servidor).No subas archivos de ROM completos: serán eliminados.
/files)Además de los parches (ligados a un hack), hay una sección de archivos generales sin restricción de tipo — capturas, guías, savestates, assets, etc. — pensada para que la comunidad comparta lo que necesite sin tener que crear un hack falso solo para subir algo.
Dos bloqueos automáticos en src/lib/fileTypes.ts (por extensión, aplican
sin importar el tipo de archivo que se declare):
.mp4, .mkv, .avi, .webm, ...): para no volar el
almacenamiento gratuito de R2 con archivos pesados..nds, .gba, .sfc, .nsp, .xci, .iso,
...): el único tipo de archivo que representa el riesgo legal real de
esta plataforma, así que se bloquea aquí también aunque la sección sea
"de tipo libre".Todo lo demás se permite sin filtro. La moderación de lo que sí se sube es manual, con dos capas:
/files
aparece para el dueño del archivo y para admins./admin, con el motivo y quién reportó, y pueden eliminar el archivo o
descartar los reportes si no aplica. Un mismo usuario solo puede tener
un reporte activo por archivo (reportar de nuevo actualiza el motivo,
no duplica la fila).No hay moderación automática de contenido explícito (por ejemplo, detección de imágenes para adultos) — es una decisión consciente por ahora: se optó por reportes de comunidad en vez de integrar un servicio de terceros de moderación de imágenes.
Dos capas, en src/lib/fileTypes.ts y src/lib/virustotal.ts:
Extensiones de ejecutables/scripts bloqueadas (.exe, .msi,
.bat, .cmd, .scr, .ps1, .vbs, .js, .jar, .sh, .dll,
.apk, etc.) — igual que el bloqueo de video/ROM, sin excepción y sin
costo de latencia.
Escaneo real con VirusTotal (variable
VIRUSTOTAL_API_KEY, opcional — sin ella, el escaneo simplemente se
salta): antes de aceptar el archivo, se busca su SHA-256 en la base de
VirusTotal (~70 motores antivirus); si es desconocido, se sube para
análisis y se espera hasta ~12s a que termine. Si algún motor lo marca
como malicioso, el archivo se borra de R2 y la subida se rechaza con
400. Si el análisis no termina a tiempo (típico en archivos nuevos y
únicos), el archivo se acepta igual con virusScanStatus: "pending" —
se muestra una insignia "Escaneo pendiente" en /files para que
cualquiera lo note. Esto es defensa en profundidad, no la única capa:
el bloqueo de extensiones y los reportes de comunidad (motivo "Malware
o archivo dañino") siguen aplicando siempre.
Mientras un archivo está en "pending" o "error", la API pública
(/api/v1/files) lo excluye por completo (ni aparece en el listado ni
en el detalle) y GET /api/files/<id>/download responde 404 para
cualquiera que no sea el dueño o un admin/moderador — igual que con los
archivos privados. El sitio web (/files) sí lo sigue mostrando a
todos con la insignia, para que la comunidad pueda ver/moderar mientras
tanto.
Nada vuelve a consultar un "pending" por su cuenta — admins/moderadores
tienen un botón "Reescanear" junto a la insignia (POST /api/admin/files/<id>/rescan) que repite la búsqueda por hash; si sigue
pendiente hay que reintentar más tarde, y si VirusTotal ya lo marcó
malicioso lo borra en el acto.
Límite del nivel gratis de VirusTotal: solo escanea archivos de hasta
32 MB (maxScannableSizeBytes()) — los más grandes se aceptan sin
escanear (virusScanStatus: "skipped").
La subida va directo del navegador a R2 (URL prefirmada vía
POST /api/files/presign), sin pasar por la función serverless de Vercel —
por eso no está limitada a los ~4.5 MB de Vercel Hobby como los parches
(tope configurable con MAX_SHARED_FILE_SIZE_BYTES, 200 MB por defecto).
Esto requiere CORS configurado en el bucket de R2 para PUT/GET/HEAD
desde el dominio del sitio (y localhost:3000 para desarrollo) — si creas
un bucket nuevo, configúralo con PutBucketCorsCommand del SDK de S3 antes
de probar la subida.
Cada archivo se sube como público (cualquiera lo ve en /files y lo
puede descargar) o privado (solo el dueño y los admins lo ven en la
lista y pueden descargarlo — para cualquier otra persona, GET /api/files/<id>/download devuelve 404 en vez de 403, para no revelar ni
la existencia del archivo). Los admins ven todos los archivos, públicos o
privados, para poder moderar.
El bloqueo de extensiones de video/ROM se aplica sin excepción, incluso a administradores — no es una restricción de UX, es la salvaguarda legal de la que depende todo el proyecto (ver "Modelo legal" arriba).
Al crear un hack o etiquetar un archivo con un juego, hay un botón "Buscar
portada" que consulta TheGamesDB (src/lib/thegamesdb.ts)
y deja elegir entre las miniaturas encontradas. Importante:
isAllowedCoverUrl() rechaza
cualquier URL que no apunte a cdn.thegamesdb.net, así que no se puede
usar este campo para inyectar una URL arbitraria vía la API.Game se fija una sola vez (al crear el primer hack para
ese juego); hacks posteriores del mismo juego no la sobreescriben./files puede tener su propia portada independiente, ya
que su gameTitle es texto libre y no está ligado a un Game real./api/games/search-cover requiere sesión (evita gastar la cuota
gratuita de la API con tráfico anónimo) y mantiene el TGDB_API_KEY en
el servidor — nunca se expone al navegador.@prisma/adapter-pg)src/lib/patchers/)Este stack está elegido para poder desplegarse gratis en Vercel: sin disco local persistente ni base de datos con estado en el propio servidor.
pnpm-workspace.yaml para aprobar
scripts de build nativos — no uses npm install, tuvo problemas de
estabilidad durante el desarrollo)Crea un proyecto en neon.tech y copia el
connection string pooled (incluye -pooler en el host).
Crea un bucket en Cloudflare R2 y un API token con permiso de lectura/escritura sobre ese bucket (Dashboard → R2 → Manage API Tokens).
Copia .env.example-como valores en .env (ya incluido) con tus propias
credenciales:
| Variable | Descripción |
|---|---|
DATABASE_URL | Connection string de Postgres (Neon, pooled) — la usa la app en runtime |
DIRECT_DATABASE_URL | Connection string directa (sin -pooler en el host) — solo la usa prisma migrate, porque el pooler de Neon no soporta los advisory locks que necesita |
JWT_SECRET | Secreto para firmar las cookies de sesión (genera uno nuevo con openssl rand -hex 32) |
TOTP_ENCRYPTION_KEY | Clave de 32 bytes en hex para cifrar los secretos TOTP en la base de datos (genera uno nuevo con openssl rand -hex 32) |
R2_ACCOUNT_ID | ID de cuenta de Cloudflare (aparece en la URL del dashboard) |
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY | Credenciales del API token de R2 |
R2_BUCKET_NAME | Nombre del bucket creado |
MAX_PATCH_SIZE_BYTES | Tamaño máximo por archivo de parche (4 MB por defecto, ver nota de Vercel abajo) |
TGDB_API_KEY | API key gratuita de TheGamesDB — usada para buscar portadas de juegos |
Instala dependencias y aplica el esquema:
pnpm install
pnpm exec prisma migrate dev # aplica el esquema a tu base de Neon
pnpm run seed # carga las plataformas (NES, SNES, GBA, DS, ...)
pnpm dev # http://localhost:3000
Regístrate normalmente en la web y luego promuévete a administrador (no hay forma de hacerlo desde la UI, a propósito):
.env tiene las credenciales de producción (la base real). .env.local
(gitignored, no se commitea) tiene las de un branch de Neon aparte
llamado development — mismo esquema, datos separados. Next.js carga
.env.local por encima de .env automáticamente, así que:
pnpm dev y cualquier prueba manual en el navegador ya usan el branch de
desarrollo sin hacer nada extra.pnpm run migrate:dev (= prisma migrate dev) también apunta ahí por
defecto — crear y probar una migración nueva nunca toca producción.pnpm run migrate:prod (PRISMA_TARGET=production prisma migrate deploy) —
requiere el flag explícito a propósito, para que aplicar un cambio de
esquema a la base real sea una decisión consciente, no un accidente de
usar el comando por defecto.npx tsx --env-file=.env.local algo.mts) deberían apuntar a .env.local salvo que la tarea sea
explícitamente sobre datos de producción (ej. make-admin, seed, o
verificar algo que solo existe en la base real).Para crear el branch de desarrollo: Neon dashboard → tu proyecto → pestaña
Branches → Create branch (origen: producción) → copia las cadenas
de conexión pooled/direct de ese branch nuevo a .env.local.
MODERATORUn tercer rol, más limitado que ADMIN, pensado para delegar la
moderación de contenido sin dar acceso a la gestión de usuarios ni a la
lista completa de hacks/archivos:
/moderation (enlace "Moderación" en la nav) — solo la lista de
archivos reportados por la comunidad, con motivo y quién reportó./admin le redirige a / si lo intenta.Solo un ADMIN puede asignar el rol MODERATOR (desde el selector de rol
en la tabla de usuarios de /admin) — no hay forma de auto-promoverse.
@simplewebauthn/server + @simplewebauthn/browser. Login con huella,
Face ID o llave física — sin contraseña ni TOTP, el autenticador ya es en
sí mismo el segundo factor.
/me/security, autenticado): POST /api/auth/passkey/register-options genera el challenge (guardado en una
cookie httpOnly de 5 min) → startRegistration() en el navegador →
POST /api/auth/passkey/register-verify guarda credentialId,
publicKey y counter en Passkey.POST /api/auth/passkey/login-options → startAuthentication() →
POST /api/auth/passkey/login-verify, que identifica al usuario por el
credentialId que regresa el navegador.RP_ID/ORIGIN en src/lib/webauthn.ts cambian entre localhost (dev)
y emulatornds.online (prod, sin www — WebAuthn permite un RP ID que
sea sufijo registrable del origen real).LoginAttempt registra cada intento (password, TOTP, passkey, o login
de la app) con éxito/fallo, IP y user-agent — visible en /me/security
bajo "Actividad reciente de inicio de sesión", para que cada quien note
si alguien intentó entrar a su cuenta./register) tiene un botón "Generar contraseña segura"
(src/lib/passwordGenerator.ts, crypto.getRandomValues, 20
caracteres) que llena el campo y lo muestra en claro para copiarlo.Cualquier usuario puede activar 2FA desde /me/security (compatible con
Google Authenticator, Authy, 1Password, etc.):
otplib
y se cifra con AES-256-GCM (src/lib/totp.ts) antes de guardarse — nunca
queda en texto plano en la base de datos./api/auth/totp/setup → /api/auth/totp/confirm).POST /api/auth/login ya no abre sesión directamente:
devuelve { requiresTotp: true, pendingToken } (token de 5 minutos), y
hay que llamar a /api/auth/totp/verify-login con el código para
completar el inicio de sesión./api/auth/totp/disable) exige contraseña y un
código válido, para que no baste con robar solo la sesión activa.Cada usuario logueado puede elegir un tema visual desde /me (persistido en
User, sincronizado entre dispositivos). Los temas están inspirados en
géneros de juegos de Nintendo (aventura, plataformas, espacial, carreras)
pero usan solo colores e íconos originales dibujados a mano en
src/lib/themes.tsx — ninguna imagen, sprite o logo oficial.
A diferencia de la primera versión (que solo cambiaba el color de los
botones), el tema ahora controla todo el sitio: fondo de página,
superficie de tarjetas/paneles, bordes, texto y el acento de botones/enlaces
destacados — todo vía variables CSS en globals.css ([data-theme="..."]
sobre <html>) y las clases utilitarias bg-page, bg-surface,
border-base, text-base, text-muted, btn-accent, text-accent, etc.
(en vez de clases fijas de Tailwind como bg-neutral-900). Agregar un tema
nuevo es agregar una entrada al arreglo THEMES + su bloque
[data-theme="id"] correspondiente en CSS.
Además de los 5 presets, cualquier usuario puede crear su propio tema
("Personalizado") eligiendo 4 colores (fondo, superficie, acento, texto)
con selectores de color nativos del navegador. Estos valores son arbitrarios
por usuario, así que no tienen un bloque [data-theme] fijo en CSS — se
inyectan como style inline sobre <html> (buildCustomThemeStyle() en
src/lib/themes.tsx), validados en el servidor como hex de 6 dígitos antes
de guardarse (PATCH /api/me/theme).
Mientras se editan los 4 colores, se ven aplicados al instante en toda la
página (applyLivePreview() los escribe directo en <html> vía
element.style.setProperty) — nada se guarda hasta darle a "Guardar tema
personalizado". "Cancelar" (o salir de una vista previa) llama a
clearLivePreview() antes de refrescar: como esos estilos se escriben por
fuera del control de React, un simple re-render no basta para quitarlos si
el estado de reposo no tiene style inline (por ejemplo, cualquier tema
no-personalizado) — React solo limpia las propiedades que él mismo recuerda
haber puesto.
/themes)Cualquier usuario con un tema personalizado activo puede compartirlo con un
nombre (POST /api/community-themes, tabla CommunityTheme). Otros
usuarios navegan /themes, hacen clic en una tarjeta para verla aplicada
de inmediato como vista previa sin guardar (mismo mecanismo de arriba), y
si les gusta le dan "Usar este tema" — eso copia esos 4 colores a su propio
tema personalizado (theme=custom + sus propios customTheme*), totalmente
editable después, desligado del original. El creador (o un admin) puede
borrar el tema de la galería.
relay/Carpeta aparte, proyecto propio (no Next.js, no Vercel): un relay de
PartyKit para que dos jugadores intercambien o
combatan vía link cable emulado (ej. Pokémon), desplegado en
wss://romhack-relay.skyacdx.partykit.dev.
wss://romhack-relay.skyacdx.partykit.dev/parties/main/<código>.relay/party/server.ts, ~20 líneas) no interpreta los
bytes en absoluto: reenvía cada mensaje tal cual al otro conector en la
sala — la semántica del protocolo de link cable la resuelve el núcleo
del emulador de cada lado (mGBA, etc.), igual que ya hacen para
multijugador por TCP/IP.4000.Desplegar cambios: cd relay && npx partykit deploy. Correr local:
cd relay && npx partykit dev.
Vercel Functions son de petición/respuesta corta — no sirven para una conexión WebSocket persistente de baja latencia. PartyKit corre sobre la red edge de Cloudflare (Durable Objects), escala sola según demanda sin tocar código, y como el relay es un módulo autocontenido (no toca usuarios/hacks/guardados de la base de datos principal), migrarlo a otra cosa en el futuro —si hiciera falta— sería reescribir solo esta carpeta, no una migración del resto del sitio.
/messages: solo texto, sin adjuntos — el modelo Message ni siquiera
tiene un campo de archivo, así que no hay forma de reintroducirlo por
accidente. Los links (a una comunidad, a la página, o cualquier http/https)
se detectan y se muestran como enlace clicable, nunca como HTML crudo.
/communities: cualquier usuario crea un grupo (nombre único → slug), se
une o sale libremente; el creador es "owner" y no puede salir (debe
eliminar la comunidad). Sin feed, solo lista de miembros.
/me/friends: solicitudes de amistad simples — POST /api/friends con
{username} envía la solicitud (si el otro ya te la envió, se acepta
automáticamente en vez de duplicarla); POST /api/friends/[id] acepta,
DELETE /api/friends/[id] rechaza/cancela/elimina según el estado. El
botón "Agregar amigo" vive en /u/[username].
Endpoints con Bearer token, no cookies — pensados para que la app nativa
del emulador (multiemu) vincule la cuenta y sincronice partidas guardadas
entre dispositivos:
POST /api/auth/token con { email, password, label? }
(label es el nombre del dispositivo, ej. "Pixel 8 de Juan"). Si la
cuenta no tiene 2FA, responde { token, username } — ese token va en
Authorization: Bearer <token> en cada request siguiente. Si tiene 2FA,
responde { requiresTotp: true, pendingToken }; se completa con
POST /api/auth/token/verify ({ pendingToken, code, label? }) para
obtener el token real.POST /api/saves/presign (Bearer,
{ filename, fileSize, contentType }) da una URL prefirmada de R2 —
PUT los bytes del guardado ahí directo (nunca pasan por nuestro
servidor). Luego POST /api/saves (Bearer,
{ gameKey, slot?, storedName, originalName }) confirma la subida y crea
o sobreescribe el guardado para ese (gameKey, slot) — gameKey lo
define la app (ej. "gba:pokemon-emerald" o un hash de la ROM), slot
es un número opcional (default 0) para varios espacios de guardado por
juego.GET /api/saves (Bearer) devuelve todos
los guardados de la cuenta con su downloadUrl; GET /api/saves/<id>/download (Bearer) da { downloadUrl } (URL firmada de
R2, válida 5 min); DELETE /api/saves/<id> (Bearer) borra uno.Los tokens son de un solo uso por dispositivo y de larga duración (no
expiran solos). El usuario puede ver y revocar cada dispositivo vinculado
desde /me/security → "Dispositivos vinculados" (GET/DELETE /api/me/tokens/<id>, con cookie de sesión normal, no Bearer). Revocar
borra el token de la base de datos — la próxima request de esa app con ese
token da 401.
next.config.ts): Content-Security-Policy (allowlist
explícita para AdSense, EthicalAds, TheGamesDB, R2 y Cloudflare Insights —
cualquier otro origen de script/imagen/conexión se bloquea),
X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff,
Referrer-Policy, Permissions-Policy (sin cámara/micrófono/geolocalización,
el sitio no los usa). El CSP usa 'unsafe-inline' en script-src a
propósito: se intentó el patrón de nonce por request de Next.js (que lo
evitaría), pero Turbopack no timbra sus propios scripts de hidratación con
el nonce — probado con un build de producción real, rompía toda la
interactividad del sitio. La defensa real contra inyección de scripts está
en escapar el contenido de usuario antes de volcarlo en HTML crudo (ver el
siguiente punto).dangerouslySetInnerHTML es el
JSON-LD de /hacks/[slug] — JSON.stringify(jsonLd).replace(/</g, "\\u003c"), para que un title/description con </script><script>...
(controlados por cualquier usuario que publica un hack) no pueda romper el
tag e inyectar JS. Verificado con un payload real.src/lib/rateLimit.ts): usa Upstash Redis
(@upstash/ratelimit + @upstash/redis, ventana deslizante) cuando están
configuradas UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN; si no,
cae a un contador en memoria por instancia (suficiente para dev local, pero
no para un ataque real distribuido entre las múltiples instancias
serverless de Vercel). Para activarlo en producción: en el dashboard de
Vercel, Storage → Marketplace Database Providers → Upstash (o
directamente en upstash.com, plan gratis), crear una base Redis y conectar
el proyecto — Vercel inyecta esas dos variables automáticamente.
DATABASE_URL, JWT_SECRET, R2_*,
MAX_PATCH_SIZE_BYTES).pnpm exec prisma migrate deploy (puedes correrlo
localmente apuntando a la misma DATABASE_URL de producción, o agregarlo
como build command en Vercel).Límite importante: el plan Hobby de Vercel acepta cuerpos de request de
hasta ~4.5 MB en funciones serverless. Por eso MAX_PATCH_SIZE_BYTES está en
4 MB por defecto — la gran mayoría de parches IPS/BPS/UPS pesan mucho menos,
pero un parche xdelta muy grande podría no caber. Si eso es un problema,
considera subir el parche directamente a R2 desde el navegador con una URL
prefirmada de subida en vez de pasar por la función serverless (no
implementado todavía).
Implementado en src/components/AdSlot.tsx, con dos redes en cascada:
Ambas son opcionales de forma independiente vía variables de entorno — si
ninguna está configurada, <AdSlot /> no renderiza nada:
| Variable | Descripción |
|---|---|
NEXT_PUBLIC_ETHICALADS_PUBLISHER_ID | Publisher ID que da EthicalAds al aprobar tu sitio |
NEXT_PUBLIC_ADSENSE_CLIENT_ID | Tu ID de cliente de AdSense (ca-pub-...) |
NEXT_PUBLIC_ADSENSE_SLOT_ID | ID del bloque de anuncio específico (se crea dentro de AdSense, después de la aprobación del sitio) |
El script de carga de AdSense (adsbygoogle.js) se inyecta en el <head>
de todas las páginas desde src/app/layout.tsx en cuanto
NEXT_PUBLIC_ADSENSE_CLIENT_ID está configurado — es el requisito de Google
para verificar el sitio, independiente de si <AdSlot /> termina mostrando
un anuncio de AdSense o no. src/app/ads.txt/route.ts genera el
ads.txt requerido automáticamente a partir del mismo ID.
<AdSlot /> está colocado en dos sitios poco intrusivos: la página de
inicio (debajo de la intro) y el detalle de cada hack (debajo de la
descripción, antes de la lista de versiones).
/about: qué es el sitio, el modelo de solo-parches, y qué más hay
(archivos, comunidades, temas, la app). Contenido estático./contact: formulario público (sin login) → POST /api/contact,
guarda en ContactMessage. No se muestra ningún correo públicamente —
el admin lee los mensajes desde el panel de admin (/admin), que
también permite borrarlos. Rate-limited por IP (5/hora) para frenar spam./app (AppFeedback): requiere
cuenta, igual que el resto del contenido de usuarios del sitio. Cualquiera
puede leerlos; solo el autor o un admin puede borrar uno.AppListing / AppRelease)A diferencia de todo lo demás en este proyecto, esto no es una colección
de contenido de usuarios — hay una sola app (multiemu), con un historial
de versiones. AppListing es la ficha (texto/ícono/capturas, cambia poco);
AppRelease es cada versión publicada, con su propio APK. Contrato
completo en docs/applistingapi.md del repo de la app.
GET /api/v1/app → { listing, latestRelease }
(404 si nadie ha creado la ficha todavía), GET /api/v1/app/releases
(historial paginado), POST /api/v1/app/releases/<id>/downloads (solo
telemetría, 204).PUT /api/app (crea o
actualiza la ficha; la primera vez exige tagline y description),
POST /api/app/releases (nueva versión, 409 si el versionCode ya
existe)./api/saves y /api/themes reservan para su v2 — POST /api/app/assets/presign con slot: "icon" | "screenshot" | "apk:<releaseId>", el cliente hace PUT a la URL firmada, y POST /api/app/assets registra la pieza. Límites: ícono ≤2MB, captura ≤5MB,
APK sin límite propio (usa el mismo tope que /files, 200MB).EmulatorTheme)No confundir con CommunityTheme (el tema de colores de esta web) — este es
el modelo de temas visuales para los controles del emulador de la app
(docs/themes-api.md en el repo de la app tiene el contrato completo).
palette son 6 colores
hex; presets son IDs de forma de botón que la app resuelve del lado del
cliente (el servidor solo guarda el string, nunca sabe qué dibuja cada
uno). El campo assets del JSON de respuesta siempre va con todo null
en v1 — no hay columna para eso todavía a propósito; se agrega en v2 sin
romper el contrato actual.system valida contra los slugs ya existentes de Platform (gb,
gbc, gba, nds, ...) — reusa esa tabla en vez de duplicar un enum.GET /api/v1/themes (lista) y GET /api/v1/themes/<id> (detalle, 404 si no es público)./api/saves):
POST /api/themes (crea, slug elegido por el cliente, 409 si ya
existe), PATCH /api/themes/<id> y DELETE /api/themes/<id> (403 si el
token no es del autor).Endpoints de solo lectura, sin autenticación, pensados para que una app
externa (ej. un emulador) busque hacks y descargue parches directamente —
CORS abierto (Access-Control-Allow-Origin: *), útil tanto desde apps
nativas/React Native (que no aplican CORS) como desde el navegador.
| Endpoint | Descripción |
|---|---|
GET /api/v1 | Info de la API y enlaces a cada endpoint (autodescriptivo) |
GET /api/v1/platforms | Lista de plataformas: { platforms: [{ slug, name }] } |
GET /api/v1/games?platform=<slug>&q=<texto>&limit=&offset= | Juegos, filtrables por plataforma y/o búsqueda de texto en el título |
GET /api/v1/hacks?game=<slug>&platform=<slug>&q=<texto>&limit=&offset= | Hacks (con sus parches y downloadUrl listo para usar), filtrables por juego, plataforma y/o texto en el título |
GET /api/v1/hacks/<slug> | Detalle de un hack específico |
GET /api/patches/<id>/download | Redirige (307) a una URL firmada de R2 válida por 5 minutos — descarga el archivo de parche directo |
GET /api/v1/themes?system=&q=&sort=downloads|newest&limit=&offset= | Temas visuales del emulador (ver más abajo) |
GET /api/v1/themes/<id> | Detalle de un tema, solo si es público |
POST /api/v1/themes/<id>/downloads | Incrementa el contador de descargas en 1 (sin auth, es solo telemetría) → 204 |
games, hacks y themes devuelven paginación: { ..., pagination: { limit, offset, total, hasMore } }.
limit por defecto es 20 (máximo 50); usa offset para pedir la siguiente página.
Las respuestas exitosas llevan Cache-Control: public, s-maxage=60, stale-while-revalidate=300
(cacheables por CDN/cliente ~1 min); los errores van con no-store.
No hay autenticación ni rate limiting en estos endpoints todavía — si el uso crece, considera añadir un límite de requests por IP (ej. con Upstash Redis, que también tiene capa gratuita).
Ejemplo de flujo típico desde un emulador: el usuario elige su plataforma →
GET /api/v1/games?platform=gba → elige su juego → GET /api/v1/hacks?game=<slug>
→ el usuario elige un parche → fetch(patch.downloadUrl) para obtener los
bytes → aplicar el parche (mismo algoritmo IPS/BPS/UPS que hay en
src/lib/patchers/, son ~200 líneas de TS sin dependencias, fácil de portar)
sobre la ROM que el usuario ya tiene cargada, todo en memoria, sin que este
servidor vea la ROM en ningún momento.
Para que Google (y otros buscadores) indexen el sitio y muestren buenos snippets:
generateMetadata en /hacks/[slug] y
/platforms/[slug], metadata estático en el resto): título,
descripción (tomada del texto real del hack, recortada a 160
caracteres), URL canónica, y Open Graph/Twitter con la portada del
juego como imagen si existe.SoftwareApplication, para que Google pueda mostrar resultados
enriquecidos (rich results)./sitemap.xml (src/app/sitemap.ts) generado dinámicamente desde
la base de datos: incluye cada plataforma y cada hack. Como es
dinámico, no hay que regenerarlo a mano al publicar contenido nuevo./robots.txt (src/app/robots.ts) permite indexar todo excepto
/admin, /me, /login, /register y /api/.Esto es todo lo que se puede hacer desde el código. Para que realmente aparezca en resultados de búsqueda, falta un paso que solo tú puedes hacer (requiere verificar que eres dueño del dominio):
https://www.emulatornds.online (verificación por
DNS o subiendo un archivo HTML — Vercel deja hacer ambas).https://www.emulatornds.online/sitemap.xml en la sección
"Sitemaps" — así Google encuentra y rastrea todas las páginas de una
vez, en vez de esperar a encontrarlas solo.No hay forma de "forzar" que algo aparezca como sugerencia de autocompletado del buscador — eso lo decide el buscador según qué tan indexado y buscado esté el sitio con el tiempo; lo de arriba es lo que maximiza esa probabilidad.
prisma/schema.prisma Modelos: User, Platform, Game, Hack, Patch
src/lib/auth.ts Sesión JWT (cookie httpOnly)
src/lib/prisma.ts Cliente Prisma con el driver adapter de Postgres
src/lib/storage.ts Subida a R2 y generación de URLs de descarga firmadas
src/lib/patchers/ Implementación de IPS, BPS, UPS y CRC32 (cliente)
src/app/api/hacks Crear hack + primera versión de parche
src/app/api/patches Agregar nueva versión / descargar un parche
src/app/hacks/new Formulario de publicación (requiere sesión)
src/app/patch Herramienta de parcheo en el navegador
Este proyecto usa Next.js 16, cuya API difiere de versiones anteriores en puntos que vale la pena tener presentes al modificar el código:
params y searchParams son Promise tanto en Server Components como en
Route Handlers (const { slug } = await params).proxy.ts (no se usa en este proyecto).cacheComponents (PPR / use cache) está deshabilitado a propósito: la app
necesita datos dinámicos por request.Prisma 7 ya no incluye un motor de conexión por defecto: hace falta pasar un
adapter explícito. Ver src/lib/prisma.ts, que usa @prisma/adapter-pg
contra el connection string de Neon.
pnpm exec tsx prisma/make-admin.ts [email protected]
Con el rol ADMIN aparece un enlace "Admin" en la barra de navegación,
que lleva a /admin: ahí puedes eliminar cualquier hack (borra también
sus archivos de R2) y gestionar usuarios (promover/degradar roles,
eliminar cuentas junto con sus hacks).