Compare commits

...
Sign in to create a new pull request.

49 commits

Author SHA1 Message Date
s1to
8d838dd8b1 readme: la portada del repositorio, con esquemas y capturas
Al entrar en el repositorio se veia el aviso de rama experimental y, debajo,
el README entero de upstream copiado: 350 lineas contando lo que ya cuenta el
proyecto oficial, y mejor.

Ahora la portada ensena lo que hay aqui: la tabla de que anade esta rama frente
al oficial, Oasis explicado en cuatro dibujos, Karvan, las videollamadas, las
identidades y como empezar desde cero, todo con esquemas propios y capturas
reales.

El aviso de que esto NO es Oasis oficial se mantiene arriba del todo y completo.
Y al final, los enlaces al proyecto de epsylon, que es de donde sale todo.

Las imagenes se enlazan a la wiki en vez de duplicarse en el repositorio, que
es lo que hace tambien upstream con las suyas.

El fichero se sigue leyendo al arrancar el backend (readFileSync sin try/catch),
asi que lo importante es que exista: comprobado que arranca.
2026-08-20 08:32:22 +02:00
s1to
923bb0ef9d blobs: las fotos ya no suben con la ubicacion dentro
stripImageMetadata dependia de sharp, y cuando no esta devolvia la imagen
intacta. En Android sharp no se empaqueta —lleva binarios nativos y el bundle
del movil va sin ninguno— asi que las fotos subian con su EXIF entero: marca,
modelo, numero de serie, fecha y coordenadas GPS. Y sin sharp instalado,
tambien en escritorio.

Ahora hay un limpiador que no depende de nada: recorre el contenedor y tira los
bloques de metadatos sin decodificar ni recomprimir, asi que los pixeles quedan
byte a byte como estaban.

  JPEG   fuera APP1 (Exif y XMP), APP13 (IPTC), APP14, COM y demas.
         Se quedan JFIF y el perfil de color, que afectan a como se ve.
  PNG    fuera tEXt, zTXt, iTXt, eXIf, tIME, dSIG.
  WebP   fuera los bloques EXIF y XMP del contenedor RIFF.

De la orientacion se guarda solo eso: sin sharp no se pueden girar los pixeles,
y tirar la etiqueta dejaria tumbadas las fotos verticales. Se reconstruye un
bloque EXIF de 32 bytes con esa unica etiqueta, que no dice nada de quien hizo
la foto ni donde. Si sharp esta, se sigue prefiriendo su camino, que ademas gira
la imagen de verdad.

Comprobado con una foto que llevaba GPS, marca, modelo, numero de serie y fecha:

  - En el movil y en escritorio, el blob guardado queda con 32 bytes de EXIF
    (solo la orientacion, que sobrevive) y cero rastros de lo demas.
  - Los pixeles son identicos antes y despues, en JPEG, PNG y WebP.
  - El perfil de color ICC se conserva.
  - Sin metadatos, progresivo, GIF, fichero corrupto, fichero vacio y algo que
    no es una imagen: se devuelven tal cual, sin excepciones.
2026-08-19 21:07:02 +02:00
s1to
da2ad8e1c6 android: la pantalla de espera, con tilde y sin asustar
Decia "Preparando la aplicacion" sin tilde y "puede tardar unos minutos", que
para un primer arranque de quince segundos suena a que algo va mal. Ahora dice
lo que hace y cuanto: descomprime el servidor, tarda un minuto, no hay que
hacer nada.
2026-08-19 20:48:38 +02:00
s1to
5c769a5a10 estructura: los ficheros de la rama, donde los pondria el dev
Upstream no usa ni un solo subdirectorio en src/views, src/backend, src/models,
src/client/assets/styles ni translations: todo plano. La rama habia creado dos
(views/fork/ y translations/fork/), que es lo que mas se separaba de su forma de
organizar el codigo. Se deshacen, y los ficheros pasan a llamarse como los suyos:

  views/fork/hive_nav.js          -> views/hive_views.js      (como main_views.js)
  views/fork/identities.js        -> views/identities_view.js (como *_view.js)
  translations/fork/i18n_fork.js  -> translations/i18n_fork.js (junto a i18n.js)
  backend/fork_routes.js          -> backend/karvan_routes.js (familia karvan_*)

Ademas se retira una ruta duplicada: /legacy/export estaba declarada dos veces,
en backend.js (la del dev) y en el fichero de rutas de la rama, con el mismo
cuerpo salvo un && defensivo. Nacio cuando el formulario se paso a GET; al
devolverlo a POST quedo como copia exacta. Con ella se van legacyModel y
onboardingModel, que ya no hacia falta inyectar.

La cabecera del fichero de rutas explica ahora por que sigue existiendo, con los
datos medidos y no de memoria: backend.js concentra 625 rutas y se reescribe en
cada release (+833/-286 en la 0.9.2, +266/-31 en la 0.9.1), asi que una ruta
anadida en medio entra en conflicto cada vez.

Quedan diez ficheros propios, todos siguiendo la convencion de upstream, y
+247 lineas repartidas en 21 ficheros suyos (sin contar OasisMobile.css, que es
el tema de la rama).

Comprobado tras mover: arranque, las siete rutas principales, el selector de
identidades con sus siete entradas y su aviso, crear sala y enviar mensaje en
Karvan, y la exportacion de identidad por la ruta del dev.
2026-08-19 20:38:26 +02:00
s1to
e0ff70c138 android: rotar la pantalla rompia la app
Al girar el movil aparecia ERR_CONNECTION_REFUSED y ahi se quedaba. La lista de
configChanges estaba incompleta —faltaba smallestScreenSize, que es justo lo que
cambia al rotar—, asi que Android destruia y recreaba la Activity: el WebView se
perdia y volvia a cargar la URL, a veces antes de que el backend escuchara.

Con la lista completa, rotar ya no recrea nada y ademas no se pierde lo que
estuvieras escribiendo.

Y se anade una red de seguridad que no habia: onReceivedError vuelve a esperar
al backend y recarga la ultima pagina que si cargo, en vez de dejar el error de
Chromium en pantalla. Cubre tres casos mas, todos comprobados en el emulador:

- Matar el proceso :node con la app abierta (lo que hace Android cuando necesita
  memoria): antes se quedaba muerta, ahora vuelve sola en unos 19 segundos.
- Cambiar de identidad, que reinicia el backend a proposito.
- Arranques lentos en moviles con poca memoria.

Un guard evita que se acumulen hilos esperando al mismo puerto, porque
onReceivedError se dispara una vez por recurso que falle.

Probado ademas: boton atras, modo avion, trim-memory RUNNING_CRITICAL y
am kill con reapertura. Todo eso ya iba bien.
2026-08-19 19:34:06 +02:00
s1to
728f10beb0 i18n: los nombres de las categorias vuelven a ser los de epsylon
El overlay pisaba menuNetwork, menuMedia y fediverse para dejarlos en Network,
Media y Fediverse. Se quitan: la rama no deberia separarse de upstream en algo
tan visible, y ahora movil y escritorio dicen lo mismo (Community, Library,
Multiverse).

Eso obliga a renombrar el filtro de la topbar: se llamaba Community y ahora ese
nombre es el de una categoria, asi que en la misma pantalla habria dos. Pasa a
ser Social, que es del fork y no choca con nada de epsylon.

Y las traducciones dejan de estar a medias: las 56 claves de la rama estan
completas en los 11 idiomas, ninguna cae al ingles. Antes solo el castellano
estaba entero y los otros nueve mostraban el texto en ingles.
2026-08-19 19:24:29 +02:00
s1to
161d636f17 legacy: la contraseña de la copia deja de viajar en la direccion
El formulario de exportacion se habia pasado a GET, asi que la contraseña que
cifra la copia de la identidad acababa en el historial del navegador, en el
registro del servidor y en el Referer de lo siguiente que se cargara. Vuelve a
POST, como en upstream, y la ruta la lee del cuerpo.

Comprobado: la exportacion sigue descargando el fichero, y la direccion con la
contraseña ya no responde.
2026-08-19 11:23:19 +02:00
s1to
e84a9f84da forum: quitar el campo subtopic, que no lo lee nadie
Resto del intento de subtemas propios que se descarto: se copiaba a cada
respuesta pero ninguna vista lo mira. Los subtemas de verdad son los de
upstream, con sus rutas y sus vistas.
2026-08-19 11:21:25 +02:00
s1to
d843d47374 identidades: enlace a exportar e importar
Sacar o meter una identidad ya existente lo hace el modulo legacy, que es quien
sabe cifrar el secret con una contraseña. Se enlaza desde la lista para que
este donde se busca.
2026-08-19 11:18:55 +02:00
s1to
f465fb2288 android: script de publicacion y workflow para la APK
release.sh hace el ciclo entero y siempre igual, se lance a mano o desde un
runner: empaqueta el backend, compila, verifica y publica la release en gitea.

Las verificaciones no son decorativas, son las que han cazado fallos reales en
esta rama: una APK puede compilar, instalar y arrancar sin el backend dentro, o
haber perdido los permisos de camara y quedarse sin videollamadas. Se comprueba
que estan el backend y libnode.so, los dos permisos, y la firma.

El versionCode sale del numero de commits, que sube solo; el versionName, de
package.json, que es de donde lo saca tambien el backend, para que la version
que muestra la aplicacion no pueda discrepar de la de la APK. Los dos se pasan
por entorno a Gradle en vez de editarlos a mano.

La release se crea como borrador a proposito: publicar una APK firmada sin que
nadie la mire no deberia poder pasar por descuido.

El workflow de gitea no se dispara con cada commit (la APK son mas de 100 MB) y
necesita un runner con el SDK de Android; queda documentado en su cabecera.
2026-08-19 11:16:25 +02:00
s1to
a22e4a49d4 identidades: selector en los ajustes, con reinicio en movil
La base de multicuenta ya estaba en el arranque (OASIS_ACCOUNT), pero solo se
podia elegir por linea de comandos. Ahora hay una seccion en los ajustes que
lista las identidades, marca cual esta en uso y permite crear una.

Como funciona:

- accounts.js encuentra las identidades buscando directorios ~/.<algo> con un
  secret dentro, y lee de el su @id. En Android ese HOME es el almacenamiento
  privado de la app, asi que quedan aisladas del resto del sistema.
- El selector anota la elegida en src/configs/active-account; ssb_config.js la
  usa al arrancar si no hay OASIS_ACCOUNT (que sigue mandando).
- Crear una identidad solo anota el nombre: el secret lo genera ssb-config al
  arrancar con esa cuenta.

En movil hay ademas un boton de reinicio, porque aqui cerrar la app no basta:
el backend vive en un servicio en primer plano que sobrevive. La ruta responde
la pagina y luego termina el proceso; el servicio se recrea y node arranca con
la identidad nueva. No se puede levantar el runtime dos veces en el mismo
proceso, de ahi salir en vez de reiniciar por dentro.

Comprobado en el emulador: al reiniciar, el feed pasa a ser el de la otra
identidad y la anterior sigue intacta.

Dos carpetas pueden tener el mismo secret copiado, y publicar desde las dos
parte el registro sin arreglo posible. La lista lo detecta comparando los @id
y lo avisa en rojo en las dos filas.
2026-08-19 11:08:55 +02:00
s1to
37937e8363 assets: recuperar las tres imagenes de UX que faltaban
La pantalla de ajustes y el asistente de bienvenida piden ux-blocks.png,
ux-chats.png y ux-ainav.png para el selector de modo de interfaz. No se
copiaron al integrar 0.9.5, asi que el selector salia con tres imagenes rotas.

De paso se retira media-favorites.js con su JSON: es de una version anterior
de la rama y ya no lo requiere nadie, upstream lo resuelve con
content_favorites.js.
2026-08-19 10:35:26 +02:00
s1to
48cafbbe59 android: selector de ficheros, espera del backend y re-extraccion
Cuatro arreglos del envoltorio, todos vistos al probarlo en el emulador:

- onShowFileChooser: sin el, el navegador interno no abre nada al pulsar
  "Choose File", asi que no se podia subir ninguna imagen. El APK oficial si
  lo implementa; era una regresion frente a el.

- La marca de re-extraccion del backend era el versionName. Al reconstruir la
  APK sin subir version, la app se quedaba con el backend anterior y ningun
  cambio se aplicaba. Pasa a ser lastUpdateTime del paquete, que cambia en
  cada instalacion. (openFd sobre el asset no vale: va comprimido dentro del
  APK y no admite descriptor.)

- La espera del backend sondeaba con HEAD, que el backend rechaza con 400.
  Ahora abre un socket TCP contra 127.0.0.1:3000, que es lo unico que hay que
  saber, y espera hasta cinco minutos en vez de uno: descomprimir 231 MB y
  levantar SSB tarda mas en un movil lento. Mientras, pinta una pantalla de
  estado con los segundos transcurridos, en vez de dejar el WebView en blanco.

- targetUrl/onNewIntent para poder abrir una ruta concreta desde un intent.

network_security_config.xml tenia base-config detras de domain-config, que el
esquema no admite. Corregido el orden y restaurada la referencia en el
manifiesto, que se habia sustituido por usesCleartextTraffic=true al depurar:
eso abria texto en claro hacia cualquier host, no solo hacia loopback.
2026-08-19 10:30:37 +02:00
s1to
5bfbe2d6c8 modules: karvan en su sitio alfabetico
La lista de modulos va ordenada por nombre; karvan estaba detras de polls,
que es de donde se copio el patron. Pasa a estar entre jobs y larp.
2026-08-19 10:30:37 +02:00
s1to
cb892579f5 karvan: el panel de llamada no tenia estilos
Un comentario sin cerrar en karvan.css se comia la llave de la regla que
neutraliza el div{background:#222;padding:20px} global, y con ella todo el
bloque de la videollamada. El resultado eran dos cajas vacias enormes y unos
botones cuadrados en vez del panel.

Ademas:

- La hoja del modulo se cargaba antes que el tema, asi que el tema ganaba por
  cascada. Pasa a cargarse despues, que es lo que decia su propio comentario.
- Las opciones de duracion (30m / 2h / 8h) se partian letra a letra en
  escritorio; les faltaba white-space: nowrap.
- Clear-SNH usa !important en casi todo, asi que ninguna hoja posterior puede
  ganarle. Se resuelve como lo resuelve Oasis: es el tema el que pinta el
  modulo. Sin ese bloque las burbujas salian negras sobre blanco y el texto
  secundario en amarillo palido, ilegible.

Comprobado con los cuatro temas en escritorio y en el emulador.
2026-08-19 10:30:16 +02:00
s1to
2203b54917 chats: la respuesta citada se guardaba pero no se pintaba
buildMessage no copiaba replyTo del contenido del mensaje, asi que la vista
nunca encontraba a quien se respondia y la burbuja salia sin cita. El campo
estaba puesto por error en buildChat, donde no significa nada: un chat no
responde a ningun mensaje.

Se ve al responder a un mensaje y recargar el hilo.
2026-08-19 10:30:00 +02:00
SITO
0bf4a8fcb9 fix: la app no arrancaba en Android por una expresion regular de 0.9.5
Diagnosticado en el emulador. El backend moria al cargar backend.js:

  SyntaxError: Invalid regular expression: /^#([\p{L}\p{N}_-]+)/:
  Invalid property name in character class

libnode.so de nodejs-mobile viene sin ICU completo, asi que las propiedades Unicode
en expresiones regulares no existen y el fichero ni siquiera compila. La rama ya lo
habia resuelto con un rango explicito; al integrar 0.9.5 se reintrodujo la forma de
upstream y con ella el fallo. Afectaba a backend.js y a data_model.js.

Tras el arreglo el emulador arranca: 'Backend started on port 3000', 991 modulos,
285 ms de warmup.

Y se devuelven al overlay los nombres de tres categorias. Upstream los renombro en
0.9.5 (Network -> Community, Media -> Library, Fediverse -> Multiverse) y al
restaurar los ficheros de traduccion se colaron los suyos, cambiando las etiquetas
de los hexagonos. Lo destapo una captura de pantalla del usuario, no el codigo.
2026-08-19 08:24:12 +02:00
SITO
f354069af7 ssb: base de multicuenta y arreglo de las rutas fijas a ~/.ssb
ssb_config pasa a derivar la cuenta de OASIS_ACCOUNT. Como ssb-config construye la
ruta desde el appname y gui.js saca el socket de config.path, cambiar ese literal
arrastra de forma coherente el almacen, la base de datos, los blobs, el secret y el
socket. Sin la variable se usa 'ssb', asi que las instalaciones existentes siguen en
~/.ssb y no notan nada.

El nombre se valida contra [a-zA-Z0-9][a-zA-Z0-9_-]{0,31} y cae a 'ssb' si no encaja.
No es de adorno: acaba en rutas de fichero y hay operaciones destructivas detras.
Comprobado que OASIS_ACCOUNT=../../etc no sale de sitio.

statePath separa tambien por cuenta cuando se usa OASIS_STATE_DIR. Sin eso, dos
identidades compartirian el estado que 0.9.5 empezo a guardar ahi, que es justo el
camino a partir el registro.

Y el arreglo que importa: doce sitios construian la ruta como os.homedir() + '/.ssb'
en vez de usar la de la cuenta. El peor era panicmode_model, que con varias
identidades habria borrado la equivocada; tambien exportmode y legacy_model, que
habrian exportado o restaurado la identidad de otra cuenta. Los de lectura
—main_models, stats_model y backend.js con gossip.json y ebt— habrian mirado siempre
la cuenta por defecto.

Falta la parte visible: el selector de identidad y el reinicio del proceso al
cambiar. Esta base es la que hacia falta para que eso no sea peligroso.

Verificado: con OASIS_ACCOUNT sin definir la ruta sigue siendo ~/.ssb; con 'trabajo'
pasa a ~/.trabajo; con un valor con .. o espacios se rechaza. Diez rutas OK con la
app en marcha, incluidas /peers y /stats, que son las que leen esos ficheros.
2026-08-19 00:44:23 +02:00
SITO
f80b2e04f0 docs: aviso de rama experimental en el README y version al dia
El README abria con 'Oasis Mobile 0.9.1' y describia mejoras sobre 0.9.0, sin decir
en ningun sitio que esto no es el Oasis oficial. Ahora lo primero que se lee es que
es una rama experimental sin afiliacion, con enlace al repositorio de epsylon, la
indicacion de que los fallos se reportan aqui y el enlace a la wiki.

El package.json de la raiz seguia en 0.9.1 mientras el de src/server ya estaba en
0.9.5. Alineados.

Nota: backend.js sirve este README en la interfaz, asi que el aviso se ve tambien
desde dentro de la aplicacion.
2026-08-19 00:41:25 +02:00
SITO
f3330e3d0e views: la topbar de movil solo en movil
renderMobileTopbar era la unica de las tres piezas de interfaz de la rama sin gate,
asi que en escritorio se dibujaba encima de la cabecera de upstream. Los hexagonos y
la barra inferior si lo tenian.

Detectado al arrancar el arbol sin OASIS_MOBILE para preparar la version de
escritorio.
2026-08-19 00:35:49 +02:00
SITO
736b528b99 karvan: darle sitio en el escritorio siguiendo el patron del dev
Karvan solo existia en la rejilla de hexagonos, que es de movil. En escritorio no
habia forma de llegar salvo escribiendo la URL.

Se anade renderKarvanLink en main_views, calcado de renderPollsLink —misma forma,
mismo checkMod por getConfig().modules, mismo navLink— y se invoca en el grupo
social junto a renderChatsLink. Entra tambien en modules_view y en la lista de
/modules de backend.js, para poder apagarlo como cualquier otro modulo, con sus
etiquetas en el overlay.

Los estilos del modulo pasan a src/client/assets/styles/karvan.css. Estaban dentro
de OasisMobile.css, que en escritorio no se carga, asi que las salas se habrian
visto sin formato. Va como hoja propia siguiendo el patron de highlight.css, que
tampoco pertenece a ningun tema: asi el modulo se ve igual con cualquier tema y en
las dos plataformas.

Verificado: karvan.css sirve 200 y se carga en las dos plantillas, el enlace aparece
en el menu lateral, y en movil la rejilla de hexagonos sigue igual.
2026-08-19 00:30:55 +02:00
SITO
841acf2a7a karvan: quitar el STUN de Google y servir la configuracion ICE del propio nodo
El cliente tenia stun.l.google.com fijo en el codigo. Un servidor STUN aprende la
IP publica y el momento de cada consulta, asi que tal cual estaba cada llamada le
avisaba a Google de que ese usuario esta llamando. En un proyecto con esta postura
sobre privacidad no se sostiene.

Por defecto ya no hay ningun servidor ICE: solo candidatos host, que funcionan en
la misma red y con NAT amable. Es mas honesto degradar a nada que degradar a un
tercero.

Se anade turnCredentials.js, siguiendo el estilo de los helpers de src/backend/:
genera credenciales efimeras con el mecanismo REST de coturn (use-auth-secret),
username = caducidad:identificador y credential = HMAC-SHA1 en base64. El feed id
hace de identificador, que es lo que distingue a quien llama en Oasis. Como coturn
valida el HMAC, las credenciales se emiten sin hablar con el servidor y caducan
solas.

La ruta GET /karvan/ice las sirve solo a loopback —la credencial va firmada con el
secreto del nodo y no debe salir de la maquina— y el cliente la pide ANTES de crear
ninguna RTCPeerConnection, porque la configuracion ICE de una conexion ya creada no
se puede cambiar de forma fiable. Con dos segundos de espera maxima y respaldo a
candidatos host.

La configuracion vive en oasis-config.json bajo rtc, vacia de fabrica, y admite tres
formas: STUN propio, TURN con secreto compartido, o credenciales estaticas. Con
relayOnly se fuerza iceTransportPolicy relay para que ningun par vea la IP del otro.

Verificado: sin configuracion devuelve iceServers vacio; con secreto emite credencial
reproducible y con caducidad; cero referencias a Google en el codigo.
2026-08-19 00:16:50 +02:00
SITO
c3a1c841e6 fix: cerrar la integracion 0.9.5 — recursos que faltaban y regresiones
Una revision a fondo del arbol contra 0.9.5 saco varios fallos, unos heredados y
otros que introdujo la propia integracion. Verificados uno a uno antes de tocar.

Recursos referenciados que no existian (los introduje al adoptar vistas de 0.9.5
que los usan, mientras la rama los habia borrado):
- Los cuatro temas *-SNH.css. settings_view ofrece Dark, Clear, Matrix y Purple,
  pero solo estaba OasisMobile: elegir cualquiera daba 404 y la app se quedaba sin
  estilos. Ahora los cuatro responden 200.
- pdf.min.mjs, pdf.worker.min.mjs y pdf-viewer.js, referenciados desde seis vistas.

Regresiones revertidas a 0.9.5 verbatim en catorce ficheros. Las de fondo:
- larp_model perdio getGoverningPeriodId, que backend.js llama: el anuncio de
  gobierno no salia nunca, con el TypeError tragado por un catch.
- courts_view y search_view tenian selected: false, que hyperscript SI serializa
  como atributo y HTML interpreta como seleccionado. La forma de upstream, el
  spread condicional, es la correcta.
- pm_model perdio includeDeleted, projects_model la validacion de deadline pasado,
  jobs_model el desempate por timestamp, middleware el flag de debug HTTP.

forum_view pasa a 0.9.5 conservando el filtro hot. Esto arregla un fallo activo: la
vista de la rama declaraba un cuarto parametro topic y backend.js llama con cinco
argumentos, asi que al abrir una respuesta el %clave del hilo se colaba como tema y
el hilo salia VACIO. Upstream pasa los mismos cinco argumentos a una firma de tres y
JS los ignora, que es lo correcto.

chats_view pasa a 0.9.5 y se implementa replyTo de verdad. Estaba muerto de punta a
punta: backend.js no leia ctx.query.replyTo ni pasaba el cuarto argumento a
sendMessage, asi que ningun mensaje llegaba a tener replyTo y la cita no se
renderizaba jamas. Ahora la cadena esta completa —modelo, GET, POST y vista— con
indice msgById armado antes de mezclar con las encuestas, y sin gate de movil:
responder a un mensaje no es una funcion de movil.

aria-label: encontrada la causa. No es que hyperaxe lo ignore, es que hyperscript
solo asigna como propiedad y html-element solo serializa atributos de su tabla, en
la que aria-* no esta; data-* si tiene rama propia, por eso las tablas responsive
funcionaban. Se arregla con el escape hatch attrs:{}, sin tocar node_modules.

prepare.sh copia README.md y falla si no esta: backend.js lo lee con readFileSync
sin try/catch, de modo que sin el fichero el backend muere con ENOENT al arrancar.

Verificado con la app en marcha: 17 rutas OK, los cuatro recursos que faltaban
sirviendo 200, aria-label emitido en el HTML, e interfaz intacta (10 categorias,
50 modulos, 7 accesos rapidos, 0 etiquetas vacias).
2026-08-18 23:48:14 +02:00
SITO
7459644a73 android: fijar HOME y reutilizar el node_modules podado del APK base
main.js resuelve el directorio de datos con process.env.HOME || resolve(__dirname,
'..','..'). En Android HOME suele llegar como "/", que no es escribible, asi que el
servicio lo fija explicitamente con Os.setenv al almacenamiento privado de la app:
ahi cuelga ~/.ssb. Tambien fija TMPDIR.

prepare.sh deja de pedir un npm install: saca el node_modules del APK base, que
viene podado —137 MB frente a los 1,4 GB de una instalacion de escritorio— y
sustituye solo el codigo. Es seguro porque las 98 dependencias son identicas entre
0.9.1 y 0.9.5: ninguna nueva, ninguna quitada, ninguna con version distinta.
El zip se comprime; sin comprimir el backend son 145 MB y la APK se iria por encima
de 200.

APK generada y verificada: 78 MB, net.laenre.oasis, versionName 0.9.5, targetSdk 35,
con libnode.so y el backend de 32 MB dentro.
2026-08-18 23:35:19 +02:00
SITO
32f6d2269c android: wrapper propio, con los permisos que faltaban para las videollamadas
Hasta ahora la APK salia re-firmando la de epsylon, y su classes.dex no implementa
WebChromeClient.onPermissionRequest. Sin ese metodo el WebView deniega por defecto
todo getUserMedia, asi que las videollamadas de Karvan no podian funcionar por mucho
que se parchease el manifest: la lista de permisos es estatica y se fija al compilar.

El wrapper cierra las dos puertas. Declara CAMERA, RECORD_AUDIO y
MODIFY_AUDIO_SETTINGS, pero no se conceden al instalar: son permisos peligrosos y se
piden en tiempo de ejecucion la primera vez que se pulsa Llamar. Si nadie llama, no
se pide nada. uses-feature required=false evita excluir dispositivos sin camara.

Reutiliza las tres librerias nativas tal cual: libnode.so es nodejs-mobile v18.20.4
oficial y libnative-lib.so son 6,8 KB con un unico simbolo JNI. De ahi que el
namespace sea com.solarnethub.oasis —JNI resuelve por nombre— mientras el
applicationId es net.laenre.oasis, que permite instalarla junto a la oficial. No
hace falta NDK ni compilar Node.

De paso corrige cosas del APK actual: targetSdk 35, trafico en claro permitido solo
hacia loopback en vez de usesCleartextTraffic global, allowBackup a false para que
adb backup no saque el .ssb, y el backend en un servicio en primer plano en el
proceso :node, que ademas es lo que permitira reiniciarlo limpio para el cambio de
identidad. El APK actual declara FOREGROUND_SERVICE pero no registra ningun servicio,
de modo que el backend muere al pasar a segundo plano.

Los binarios no se versionan: scripts/prepare.sh extrae las librerias y empaqueta el
backend. La keystore se pasa por entorno.

Verificado: ./gradlew assembleDebug construye una APK de 52 MB con las tres librerias,
targetSdk 35 y los permisos declarados; su DEX contiene onPermissionRequest,
PermissionRequest, CAMERA y RECORD_AUDIO, donde el APK actual tiene cero de los cuatro.
Falta probarla en un dispositivo real.
2026-08-18 23:13:03 +02:00
SITO
ace175cca3 karvan: refrescar la sala sin JavaScript
Sin JS el chat ya funcionaba —el formulario publica y el mensaje sale en el HTML—
pero no llegaban los mensajes de los demas hasta recargar a mano.

La pagina de sala anade <noscript><meta http-equiv=refresh content=10>: solo actua
cuando no hay JavaScript, asi que con JS el data-channel sigue mandando y no hay
recargas encima del chat en vivo. Es el mismo recurso que usa upstream en
indexing_view.

Comprobado ademas sobre el modelo, con TTL acortado: la sala se autodestruye al
vencer y no deja mensajes, el anillo respeta el tope de 250 conservando los mas
recientes, se rechaza la senalizacion de mas de 16 KiB, el tope de 100 salas se
aplica, y postear en una sala inexistente devuelve null en vez de reventar.

El identificador de sala son 72 bits de entropia: no es adivinable, y es la unica
credencial de acceso — quien tiene el enlace, entra. Es el modelo previsto, pero
conviene tenerlo escrito.
2026-08-18 23:04:41 +02:00
SITO
ad2d5c8f62 nav: dar entrada en la interfaz a los modulos nuevos del dev
polls, blogs y data tenian modelo, vista y rutas desde la integracion de 0.9.5,
pero no aparecian en ningun sitio: habia que llegar por URL.

Entran en los hexagonos por categoria — blogs junto a menciones y publicaciones,
encuestas junto a votaciones, coincidencias en herramientas — y en la lista de
modulos fijables en la barra inferior. La rejilla se autogestiona, asi que no hace
falta tocar CSS.

Sus etiquetas ya existian en el i18n de upstream (pollsTitle, blogTitle, dataTitle),
de modo que salen traducidas sin anadir claves al overlay.

Verificado: 10 categorias y 50 modulos (antes 47), los tres enlaces presentes en la
home, 0 etiquetas vacias y las cuatro rutas respondiendo 200.
2026-08-18 23:01:38 +02:00
SITO
7b90fa8211 backend: backend.js a 0.9.5, con lo que faltaba para los modulos nuevos
backend.js pasa a la version de 0.9.5 y baja de 1549 a 16 lineas de divergencia.
Sin esto los modulos que el dev ha anadido estaban portados pero inaccesibles: no
tenian rutas. Ahora /polls, /blogs y /mentions responden.

Reinyectado de la rama: el enganche de fork_routes, los tres gates de movil (no
abrir navegador al arrancar, no matar el sbot al borrar la cadena), el selector de
chats de la barra lateral movil, y los globals de la interfaz — el filtro
Personal/Community, la visibilidad de los hexagonos y el avatar de la topbar.
Esos globals no aparecian al clasificar el diff y su perdida dejaba la home sin
hexagonos; detectado al comparar el HTML servido.

bottomBarPickerView (la pantalla de personalizar la barra) vivia en main_views y se
perdia al adoptar 0.9.5. Pasa a fork/hive_nav.js, junto al resto de la interfaz de
la rama, y main_views la reexporta en una linea.

housing se porta pero apagado (housingMod off). Mantenerlo fuera obligaba a editar
83 referencias de backend.js en cada release; asi el fichero queda igual que
upstream y el modulo no aparece en la interfaz. Se portan tambien contentPdf,
housing_model y housing_view para cerrar los requires.

Verificado con la app en marcha: 29 rutas OK, hexagonos 10/47 con los filtros
Personal y Community dando 5 y 5, topbar con avatar, 7 accesos rapidos, orden de
hojas correcto, y Karvan creando sala y sirviendo el panel de videollamada.
2026-08-18 22:57:33 +02:00
SITO
7eeac8b004 upstream: integrar 0.9.5 en style.css y las vistas grandes
style.css pasa a 0.9.5: lo unico que la rama le habia hecho era borrar reglas de
housing, y su estilo propio vive en OasisMobile.css, que carga despues y gana igual.

Se adopta 0.9.5 en tags_view (anade busqueda y filtros mine/recent), vote_view,
market_view y activity_view, donde la rama solo arrastraba la version anterior:
renderContentActions de dos parametros, comentarios de mercado propios y un import
muerto de renderHiveNav que no se llegaba a llamar.

Quedan a proposito en la version de la rama forum_view y chats_view: llevan
funcionalidad movil propia (la tira de temas del foro y la interfaz de responder
mensajes, que acompana al replyTo del modelo) entrelazada con estructuras que 0.9.5
ha reescrito. Integrarlas pide rehacer esa funcionalidad sobre la forma nueva, y no
es un cambio que convenga colar entre otros veinte.

Verificado con la app en marcha: 21 rutas OK, 0 claves i18n rotas, 0 requires rotos,
interfaz intacta y orden de hojas correcto (style.css, mobile.css, OasisMobile.css).
2026-08-18 22:49:21 +02:00
SITO
373fba5d96 views: main_views a 0.9.5 conservando la interfaz de la rama
main_views.js pasa a la version de 0.9.5 y se le reinyectan los seis puntos propios:
el enganche a fork/hive_nav, la topbar y los hexagonos en la cabecera, la barra
inferior antes del pie, el orden invertido de hojas de estilo en movil (en los dos
templates), el campo para copiar el Oasis ID y el chip de dispositivo.

Esto era necesario, no cosmetico: el renderContentActions de la rama tenia dos
parametros y el de 0.9.5 tiene tres. Las vistas ya integradas lo llaman con el
tercero (favoritos, difusion, reportar) y la rama lo ignoraba en silencio, de modo
que esos botones no llegaban a dibujarse.

Cuidado con el orden de hojas: 0.9.5 carga el tema antes de mobile.css, lo que en
movil dejaba a mobile.css ganando por cascada. Corregido en los dos templates.

bookmark_view pasa a 0.9.5 con el renderPMButton de la rama reinyectado. El
renderFavoriteToggle propio desaparece porque 0.9.5 integra los favoritos dentro de
renderContentActions, que cubre lo mismo.

Verificado con la app en marcha: 15 rutas OK, orden de hojas correcto en el HTML
servido (style.css, mobile.css, OasisMobile.css), 10 categorias y 47 modulos en los
hexagonos, topbar con Personal/Community y avatar, 7 accesos rapidos, campo de Oasis
ID presente y 0 etiquetas vacias.
2026-08-18 22:45:35 +02:00
SITO
c662e37668 upstream: integrar 0.9.5 en modelos y vistas medianas
chats_model: se adopta 0.9.5 y se reinyecta el replyTo de la rama (responder a
mensajes). El fix del salt en joinByInvite ya venia en upstream, y el limite de
mensajes pasa a ser configurable (60/h) en vez del parche que lo saltaba en movil.

config-manager: version de 0.9.5 con karvanMod, los seis modulos de public y toda
la normalizacion de bottomBarPins reinyectados; housingMod fuera. Entran blogsMod
y pollsMod del dev.

Se adopta 0.9.5 en votes_model (ya trae el margen de 2 minutos de la rama y anade
codigos de error), blobHandler (superconjunto compatible: mantiene handleBlobUpload
y anade subida multiple y deteccion de OGG), market_model (misma refactorizacion
mas opiniones y compradores), agenda_model y agenda_view (el codigo de 0.9.5 ya
degrada solo sin housingModel), opinions_view, cv_view y trending_view.

peers_view y parliament_view se quedan como estan: sus celdas llevan data-label,
que es lo que OasisMobile.css usa para convertir las tablas en tarjetas en movil.

Verificado con la app en marcha: 20 rutas OK, interfaz intacta (10 categorias, 47
modulos, 7 accesos rapidos, 0 etiquetas vacias) y Karvan completo de punta a punta
— crear sala, enviar y leer mensajes, mailbox de senalizacion WebRTC con miembros,
y la pagina de sala sirviendo karvan.js con el panel de videollamada.
2026-08-18 22:38:09 +02:00
SITO
82b6c3ae05 upstream: integrar 0.9.5 en 34 ficheros mas y portar los modulos nuevos
Segunda tanda. Resueltas por merge de tres vias 15 divergencias que no solapaban,
y aplicada la version de 0.9.5 en 19 vistas y modelos donde la rama solo arrastraba
codigo antiguo (firmas viejas de renderProductForm, renderJobForm, listTags sin
search, y el import sin renderSpreadEditWarning). Se preserva la eliminacion de
housing donde la rama lo habia descartado: modules_view, blockchain_view,
search_model y tags_model.

Modulos nuevos del dev portados: blog, data, gallery, mentions, polls, workflows,
comments, recurrence y media_gallery, mas contentPdf, pdfDocument y content_favorites.

main_views recupera renderEngagement, spreadsFor y renderSpreadEditWarning, que la
rama habia eliminado y que las vistas de 0.9.5 necesitan. Sin ellas polls_view
lanzaba TypeError al editar.

El overlay de i18n pasa de 15 a 41 claves (46 en castellano): incorpora las 9 que
upstream tenia en 0.9.1 y elimino en 0.9.5 mientras nuestro codigo las sigue usando
(menuBlogs, spreadHint, chatShareUrl, forumFilterHot, publishBlog...) y las 17 de
Karvan, que nunca existieron y tiraban de texto de reserva en ingles; ahora estan
traducidas al castellano.

Verificado con la app arrancada: declara 0.9.5, 0 claves i18n sin resolver (antes
26), 26 rutas responden 200/302, y la interfaz de la rama sale intacta: 10
categorias y 47 modulos en los hexagonos, topbar con Personal/Community y avatar,
7 accesos rapidos en la barra inferior y 0 etiquetas vacias (antes 1, la de Blogs).
2026-08-18 22:03:41 +02:00
SITO
f964e61a1c upstream: integrar 0.9.5 en lo que no diverge (57 ficheros) + dependencias nuevas
Primera tanda de la integracion 0.9.1 -> 0.9.5. Se actualizan los 57 ficheros que
upstream cambio y la rama no tocaba, entre ellos los 11 de traduccion: eso ya solo
es posible gracias al overlay de i18n, antes eran 11 conflictos garantizados.

Se portan ademas las dependencias que 0.9.5 introduce y de las que dependen los
ficheros actualizados: pdfDocument, content_favorites, recurrence, media_gallery,
comments_view, gallery_view y polls (modelo, limites y vista).

Quedan 48 ficheros con divergencia real por resolver a mano (backend.js,
main_views.js, OasisMobile.css y 45 vistas y modelos).

Verificado: 0 errores de sintaxis en src/, 0 requires relativos rotos, la app
arranca declarando 0.9.5 y las 47 rutas principales responden 200/302.
2026-08-18 21:42:59 +02:00
SITO
b3986b12a0 backend: llevar tambien bottombar y legacy/export al router del fork
Quedaban tres rutas propias sueltas dentro de la cadena de upstream:
/settings/bottombar (GET y POST, la personalizacion de la barra inferior) y
/legacy/export en GET, que upstream solo tiene en POST.

Con esto backend.js queda sin ninguna ruta de la rama: 8899 lineas frente a las
9067 de partida, y el unico rastro son las 4 lineas del require de fork_routes
en el array de middleware.

Verificado: el router monta las 11 rutas y backend.js no conserva referencias
a identificadores movidos.
2026-08-18 21:34:09 +02:00
SITO
e5e8e53374 backend: mover las rutas de Karvan a un router propio
backend.js concentra las 577 rutas de upstream en una sola cadena fluida y se
mueve +905/-298 lineas por release. Las 8 rutas de Karvan, su modelo, el import
de vistas y las tres funciones de relay cross-device vivian intercaladas ahi,
asi que cada integracion las ponia en conflicto.

Pasan a src/backend/fork_routes.js, un router propio que se inserta en la cadena
de middleware justo antes del router de upstream; lo que no casa cae a next().
Las dependencias (cooler, pull, pmModel, checkMod, getViewerId) se inyectan en
vez de reconstruirse, para no abrir un segundo cliente muxrpc contra el sbot.

backend.js pasa de 9067 a 8932 lineas. Quedan 2 lineas de enganche en lugar de 137.

Verificado: el router monta las 8 rutas (/karvan, /karvan/create, /karvan/:id,
/karvan/:id/invite, /karvan/:id/msg, /karvan/:id/msgs y /karvan/:id/signal en GET
y POST) y no quedan referencias huerfanas en backend.js.
2026-08-18 21:32:06 +02:00
SITO
9321718d90 i18n: devolver los 11 ficheros de idioma a la version de upstream
Con el overlay en su sitio, la rama ya no necesita editarlos. Los 11
oasis_*.js quedan identicos a upstream 0.9.1, asi que al integrar una version
nueva se copian tal cual en lugar de resolver conflictos sobre ~13.000 lineas.

El overlay recoge ademas los 6 textos del grupo spread en castellano que la
rama corregia dentro de oasis_es.js.

Efecto lateral asumido: vuelven las 86 claves de modulos que la rama no usa.
Son texto muerto, no cambian comportamiento.

Verificado: las claves propias resuelven en los 11 idiomas y la interfaz
renderiza identica (419 / 799 / 7799 bytes en topbar, barra inferior y hexagonos).
2026-08-18 21:18:06 +02:00
SITO
3fe71a0ec2 views: mover topbar, barra inferior y hexagonos fuera de main_views
main_views.js es el fichero de upstream con mas movimiento despues de backend.js
(+256/-502 lineas de 0.9.1 a 0.9.5). La interfaz propia de la rama vivia dentro,
asi que cada integracion de una version nueva la ponia en riesgo.

renderMobileTopbar, renderBottomBar, renderHiveNav y PINNABLE_MODULES pasan a
src/views/fork/hive_nav.js, una factory que recibe i18n por referencia (main_views
lo muta sin reasignarlo). En main_views quedan 8 lineas de enganche en lugar de 168.

Verificado que el HTML renderizado es identico byte a byte antes y despues, en las
tres funciones y con los filtros personal, community y sin filtro.
2026-08-18 21:15:23 +02:00
SITO
61273dfeec i18n: mover las claves propias a un overlay fuera de los ficheros de upstream
Las 15 claves anadidas por la rama (barra inferior bb*, menu Personal/Community,
karvanTitle, peerLastChange, filter) vivian editadas dentro de los 11
src/client/assets/translations/oasis_*.js. Upstream reescribe esos ficheros en
cada release (de 0.9.1 a 0.9.5 elimino 849 claves), asi que cada version las
ponia en conflicto.

Ahora estan en translations/fork/i18n_fork.js y se fusionan desde i18n.js. Los
11 ficheros de idioma pueden reemplazarse por los de upstream sin perder nada.

Verificado cargando i18n.js con los oasis_*.js de 0.9.5 sin modificar: las 15
claves resuelven en los 11 idiomas y las nuevas de upstream siguen disponibles.
2026-08-18 21:12:05 +02:00
s1to
6020c7f8df [0.9.1] FORK_IA_UX: Fase 2 cross-device — relay Karvan messages over SSB private msgs
Makes a Karvan room work between two different phones once they're connected to a pub:
- karvan_model: rooms track remoteFeeds (SSB ids of other participants); addRemoteFeed/
  getRemoteFeeds/anyRemoteRooms (feed-id validated, capped at MAX_MEMBERS).
- backend: inviting registers the invitee's feed; joining by link registers the inviter's
  feed (read from the invite PM). Posting a message publishes a private 'karvan-relay' to
  those feeds; an efficient LIVE log-stream subscriber (createLogStream old:false live:true)
  ingests incoming relays and injects them into the local mirror room (dedup by mid,
  skips own → no loop). WebRTC SIGNAL frames are NOT relayed (too many/slow for the log).
- Chosen over a muxrpc plugin because a classic pub only replicates the LOG (store-and-forward);
  live muxrpc wouldn't reach the other phone. No SSB-startup changes → no boot risk.
Verified: boot OK with the live stream, invite/adopt register feeds, message posts + relay
publishes, server stays up, 16/16 tests. Cross-device delivery needs the user's 2-phone+pub test.
Note: text chat only — video still needs the wrapper camera permission (Fase 3).
2026-08-07 21:06:13 +02:00
s1to
2c2c8f241d [0.9.1] FORK_IA_UX: review pass — remove dead code + Karvan security hardening
Dead code (from the abandoned thumb-zone/Explore-sheet nav experiment):
- OasisMobile.css: removed ~90 lines of orphaned CSS (.oasis-bottombar-fork/.bb-fab-*,
  .hive-sheet*/.fork-sheet*/.hsq-*, .omt-spacer, .hive-sheet-seg/.hs-seg, dead
  .oasis-bottombar-fixed) — no element emits any of them.
- main_views.js: removed renderHiveSheet() (never called; sole emitter of that CSS).
- karvan_view.js: dropped dead export karvanShortId + unused karvanView param.
- Added a style for .karvan-msg-live (client emitted it with no rule).

Karvan security (from the review):
- GET /karvan/:id only adopts a mirror room on a real navigation (sec-fetch-dest
  document / Accept text/html), not on <img>/subresources → fixes a CSRF that could
  spam/evict the user's ephemeral rooms.
- Cap room.members at 50 (was unbounded; each poll echoed it back).
- Reject signal payloads >16KB (SDP/ICE are tiny) — anti memory-DoS.
- karvan.js: guard malformed {kind:desc} signals so one bad signal can't abort a poll batch.
Tests: 15/15 (added members-cap + oversized-payload). Verified: CSRF fix (nav=200,
subresource=302), pages unchanged after CSS removal, boot clean.
2026-08-07 20:42:53 +02:00
s1to
af160dbd26 [0.9.1] FORK_IA_UX: Fase 2 (invite) — invite a contact to a Karvan room by feed id over SSB
Reuses Oasis's own private-message invite pattern (like the industry module:
pmModel.sendMessage([feed], 'KARVAN_INVITE', '... -> /karvan/<id>')), so the invite
lands in the contact's inbox with a link — no new SSB code, no touching the SSB
startup. Joining a room by link adopts a local mirror (karvanModel.adoptRoom, the
id is the capability). Real-time cross-device signaling (media/text sync between
the two mirrors) is the remaining Fase 2 piece (muxrpc ephemeral), deferred.
Verified single-node: invite publishes to the inbox, validation, adopt, form; 13/13
model tests (added adoptRoom).
2026-08-07 20:27:45 +02:00
s1to
813bf16958 [0.9.1] FORK_IA_UX: tests for the Karvan ephemeral-rooms model (12 cases, no framework)
Oasis ships no test runner, so this is a dependency-free node script (node
test/karvan_model.test.js). Covers rooms, ephemeral messages, the 250-msg ring
buffer, truncation/validation, self-destruct by idle+absolute TTL, idle reset on
activity, MAX_ROOMS eviction, and the WebRTC signaling mailbox. Lives in test/
(outside src/), so it is not bundled into the APK.
2026-08-07 20:01:42 +02:00
s1to
e2598ab747 [0.9.1] FORK_IA_UX v7: Karvan calls Fase 1 — video/mic UI + media over the existing WebRTC
Adds a call panel to the Karvan room (local video + remote gallery + call/mic/cam/hang-up
buttons) and grafts getUserMedia + addTrack + ontrack onto the peer connections we already
use for the text data-channel (perfect negotiation handles the renegotiation). No backend,
no new !important. Verified: getUserMedia path attaches audio+video tracks to the local
video and activates the panel (chromium fake device). getUserMedia will fail gracefully on
the current wrapper (no camera/mic permission) with a clear message — the chat keeps working.
Fases 2 (SSB cross-device signaling) and 3 (wrapper permissions) pending.
2026-08-07 19:44:37 +02:00
s1to
423e973f02 [0.9.1] FORK_IA_UX v6: show the hive only on the home, keep content pages clean
The hexagon hive was rendered in the header on EVERY page (~210px), pushing real
content far down on Peers/Market/Inbox/etc. Now a middleware flag
(__OASIS_SHOW_HIVE__ = path is /activity or /) gates renderHiveNav so the hive is
the home hub only; other pages get just the fixed topbar and their content up top.
Personal/Community and the bottombar still navigate everywhere.
2026-08-07 19:04:25 +02:00
s1to
634625d134 [0.9.1] FORK_IA_UX v5: drop 29 !important by replacing the CSS (no visual change)
Per feedback (avoid !important, substitute the CSS). Verified with computed-style
diffs on the real forum/chat DOM — layout is byte-identical.
- OasisMobile.css: 45 -> 23 !important. Removed the ones that only fought epsylon's
  NARROW rules; win by load-order/specificity instead (e.g. .forum-score-box
  .forum-score-form, .main-column .new-message-form). Kept the truly load-bearing
  ones (inline-style overrides [style*=...], round buttons vs the broad
  button{min-height:44px!important}, and comment-body-row vs mobile.css:164).
- mobile.css (FORK ONLY, user-authorized): removed !important from 7 narrow
  forum/chat rules (.forum-comment margin/padding, .comment-body-row flex-dir,
  .comment-vote/text-col width, .forum-score-* flex, .comment-textarea width) so
  our theme wins without !important. Not touched on UX_OASIS.
2026-08-06 17:27:43 +02:00
s1to
b31b8cbfbd [0.9.1] FORK_IA_UX v4: restore the customizable bottom bar (pins + edit pencil + fixed Peers/Invites), drop the + FAB
Per feedback: bring the bottom bar back to the design that was liked several
versions ago — user-pinned modules, an edit pencil to /settings/bottombar, and a
fixed Peers/Invites pair on the right. The central + FAB is removed. The hive
returns to the header (top) so Personal/Community filter the visible hexagons; the
Explore bottom-sheet is retired. Karvan module kept.
2026-08-06 16:55:40 +02:00
s1to
81be3e6c21 [0.9.1] FORK_IA_UX v3: restore working Personal/Community in the top bar
The top bar was left empty (logo + avatar only) after moving the filter to the
Explore sheet — it looked unbalanced/broken. Since the sheet no longer blocks the
top (visibility:hidden), Personal/Community are brought back to the top bar where
they fill the space and now work; the duplicate segmented control is removed from
the Explore sheet.
2026-08-06 16:47:40 +02:00
s1to
e938c8bb42 [0.9.1] FORK_IA_UX v2: fix top-bar (visibility sheet) + Karvan module (ephemeral messages + WebRTC)
Navigation fixes:
- The bottom "Explore" sheet no longer blocks the top bar: closed state is
  visibility:hidden (out of hit-testing), so the top identity buttons and page
  content always receive taps (fixes "top buttons don't work").
- Top bar is now identity only (logo, avatar). The Personal/Community filter
  moved into the Explore sheet, next to the hive, as a segmented control.

New module "Karvan" (self-contained; inspired by karvan-protocol ephemeral rooms):
- Ephemeral/temporary chat rooms held only in RAM, self-destructing on idle
  (30 min) / absolute (2 h) TTL — nothing is written to disk.
- Server relay chat (RAM + polling) as the reliable path, plus a WebRTC
  data-channel layer (perfect negotiation, HTTP signaling mailbox) for instant
  P2P delivery; degrades to the relay if WebRTC is unavailable. Data-channel
  only (no camera/mic) so no wrapper permission changes are needed.
- Files: models/karvan_model.js, views/karvan_view.js, client/public/js/karvan.js;
  routes /karvan* + karvanMod config + nav entry (network) + karvanTitle i18n (11 langs).

Additive and gated by OASIS_MOBILE / karvanMod; Linux and the UX_OASIS branch untouched.
2026-08-06 16:38:36 +02:00
s1to
745c340917 [0.9.1] FORK_IA_UX: thumb-zone navigation — hive bottom-sheet + publish FAB + reordered bar
Mobile-usability experiment grounded in real studies (Hoober 2013,
Bergstrom-Lehtovirta CHI 2011, Parhi 2006 target size, NN/g 2016 visible nav):
- The hive (categories + modules) opens from the bottom bar as a thumb-reachable
  bottom-sheet (checkbox-hack, no JS), freeing the top for content.
- Bottom bar reordered: Explore, PM, (+) publish FAB, Search, Inbox; the central
  FAB is the distinct, elevated primary action.
- Network/status quick row (Peers, Graphos, Inbox, Settings) inside the sheet.
- Tap targets >=48px. Additive and gated by OASIS_MOBILE (Linux untouched).
2026-08-06 12:00:18 +02:00
186 changed files with 20495 additions and 15735 deletions

53
.gitea/workflows/apk.yml Normal file
View file

@ -0,0 +1,53 @@
# Construye la APK y la deja publicada como borrador en las releases.
#
# NO se dispara sola con cada commit: la APK son mas de 100 MB y el backend tarda
# en empaquetarse. Se lanza a mano desde la interfaz de gitea, o al crear un tag.
#
# El runner necesita: JDK 17 con compilador, el SDK de Android (plataforma 35 y
# build-tools), node, zip y unzip. Un runner pelado NO vale.
#
# Secretos que hay que dar de alta en el repositorio:
# OASIS_KEYSTORE_B64 el almacen de claves en base64
# OASIS_KEYSTORE_PASS su contraseña
# OASIS_KEY_ALIAS el alias (por defecto "oasis")
#
# El token para publicar lo pone gitea solo en GITEA_TOKEN.
name: APK
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # release.sh cuenta commits para el versionCode
- name: Restaurar el almacen de claves
run: |
echo "${{ secrets.OASIS_KEYSTORE_B64 }}" | base64 -d > /tmp/oasis.jks
chmod 600 /tmp/oasis.jks
- name: Construir, verificar y publicar
env:
JAVA_HOME: /usr/lib/jvm/temurin-17-jdk-amd64
ANDROID_HOME: /opt/android-sdk
OASIS_KEYSTORE: /tmp/oasis.jks
OASIS_KEYSTORE_PASS: ${{ secrets.OASIS_KEYSTORE_PASS }}
OASIS_KEY_ALIAS: ${{ secrets.OASIS_KEY_ALIAS }}
OASIS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
OASIS_GITEA_URL: ${{ github.server_url }}
OASIS_GITEA_REPO: ${{ github.repository }}
run: |
cd android
./scripts/release.sh --release --publish
- name: Borrar el almacen de claves
if: always()
run: rm -f /tmp/oasis.jks

410
README.md
View file

@ -1,336 +1,184 @@
# Oasis Mobile 0.9.1 # OASIS_MOBILE — rama de desarrollo experimental
Mejoras de **navegación móvil** para Android (WebView + nodejs-mobile) sobre Oasis 0.9.0: > ## ⚠ ESTO NO ES OASIS OFICIAL
**topbar** (logo→inicio, avatar→perfil), **navegación por hexágonos** y **barra inferior** >
personalizable. Debajo, el README original de Oasis. > Esta es una **rama de desarrollo experimental** mantenida por el hacklab, **no
> afiliada** al proyecto original.
>
> ### El repositorio oficial de Oasis es el de epsylon: https://github.com/epsylon/oasis
>
> Si quieres **usar Oasis de verdad, instala el oficial**. Lo que hay aquí puede
> romperse, cambiar de forma incompatible o desaparecer sin aviso. No está auditado y
> no tiene garantía de ninguna clase.
>
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.**
Versión base: **Oasis 0.9.5** · Rama: `PRUEBAS` ·
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX)
![Las novedades](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/00-novedades.png)
--- ---
# Oasis ## Lo que añade
Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application
that helps you follow interesting content and discover new ones.
![SNH](https://solarnethub.com/git/snh-oasis-logo3.jpg "SolarNET.HuB")
Oasis redefines what it means to be connected in the modern world, giving people
the ability to control their online presence and interactions without the need for centralized institutions.
----------
## Frontend:
Main features of the Oasis interface are:
+ Data manipulation is not permitted due to the use of BLOCKCHAIN technology.
+ No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
+ Use your favorite web browser to read and write messages to the people you care about.
+ Strong cryptography in every single point of the network.
+ You are the center of your own distributed network. Online or offline, it works anywhere that you are.
+ Initial identities are randomnly generated (no username or password required).
+ No personal profile generated (no questions about gender, age, location, etc …).
+ Automatic exif stripping (such as GPS coordinates) on images for better privacy.
+ No email or associated mobile phone required.
+ Automatic updates with new functionalities.
![SNH](https://solarnethub.com/git/snh-oasis-settings.png "SolarNET.HuB")
But it has others features that are also really interesting, for example:
+ Support for multiple languages.
![SNH](https://solarnethub.com/git/snh-oasis-languages.png "SolarNET.HuB")
+ Modularity to set your own environment.
![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
+ Support for multiple themes (including Mobile Theme).
![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB")
![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB")
![SNH](https://solarnethub.com/git/snh-matrix-theme.png "SolarNET.HuB")
And some other nice modules, like for example:
+ A complex Reddit-styled forum system.
![SNH](https://solarnethub.com/git/snh-forum.png "SolarNET.HuB")
+ Or a collaborative -offline- maps system.
![SNH](https://solarnethub.com/git/snh-maps.jpeg "SolarNET.HuB")
And much more, that we invite you to discover by yourself ;-)
![SNH](https://solarnethub.com/git/snh-games.jpeg "SolarNET.HuB")
----------
## Modules:
Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
+ Agenda: Module to manage all your assigned items.
+ AI: Module to talk with a LLM called '42'.
+ AINav: Module for natural-language queries about the network's content.
+ Audios: Module to discover and manage audios.
+ Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
+ BlockExplorer: Module to navigate the blockchain.
+ Bookmarks: Module to discover and manage bookmarks.
+ Calendars: Module to discover and manage calendars.
+ Chats: Module to discover and manage encrypted chats.
+ Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).
+ Courts: Module to resolve conflicts and emit veredicts.
+ Documents: Module to discover and manage documents.
+ Events: Module to discover and manage events.
+ Favorites: Module to manage your favorite content.
+ Fediverse: Manage your other fediverse accounts, including sending and receiving content.
+ Feed: Module to discover and share short-texts (feeds).
+ Forums: Module to discover and manage forums.
+ Games: Module to play and share your scores in various mini-games.
+ Governance: Module to discover and manage votes.
+ Graphos: Module to explore the network as an interactive map of peers.
+ Images: Module to discover and manage images.
+ Invites: Module to manage and apply invite codes.
+ Jobs: Module to discover and manage jobs.
+ Legacy: Module to manage your secret (private key) quickly and securely.
+ Latest: Module to receive the most recent posts and discussions.
+ L.A.R.P.: Module for a live-action role-playing layer with 9 houses.
+ Logs: Module to record (via AI assistant) your experiences.
+ Maps: Module to manage and share offline maps.
+ Market: Module to exchange goods or services.
+ Melody: Module to generate and share the "sound" of your blockchain.
+ Multiverse: Module to receive content from other federated peers.
+ Opinions: Module to discover and vote on opinions.
+ Pads: Module to manage collaborative encrypted text editors.
+ Parliament: Module to elect governments and vote on laws.
+ Pixelia: Module to draw on a collaborative grid.
+ Projects: Module to explore, crowd-funding and manage projects.
+ Popular: Module to receive posts that are trending, most viewed, or most commented on.
+ Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.
+ Shops: Module to manage and discover shops.
+ Summaries: Module to receive summaries of long discussions or posts.
+ Tags: Module to discover and explore taxonomy patterns (tags).
+ Tasks: Module to discover and manage tasks.
+ Threads: Module to receive conversations grouped by topic or question.
+ Topics: Module to receive discussion categories based on shared interests.
+ Torrents: Module to explore and manage torrents.
+ Transfers: Module to discover and manage smart-contracts (transfers).
+ Trending: Module to explore the most popular content.
+ Tribes: Module to explore or create tribes (groups).
+ Videos: Module to discover and manage videos.
+ Wallet: Module to manage your digital assets (ECOin).
Both the codebase and the inhabitants can generate new modules. | | Oasis oficial | Esta rama |
|---|---|---|
| **Salas efímeras** | — | Karvan: mensajes que solo viven en memoria y se autodestruyen |
| **Videollamadas** | — | Audio y vídeo por WebRTC, sin servidores de terceros |
| **Interfaz de móvil** | El menú de escritorio en una pantalla pequeña | Panal de hexágonos, filtros y barra de accesos rápidos |
| **Varias identidades** | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave |
| **Respuesta citada** | — | Responder a un mensaje concreto dentro de un chat |
| **Envoltorio Android** | APK sin permisos de cámara ni micrófono | Proyecto Gradle propio: permisos, subida de ficheros, servicio que sobrevive |
| **Metadatos de las fotos** | Se quitan solo si está `sharp`; en Android **nunca** | Se quitan siempre: GPS, marca, modelo, número de serie |
| **Servidor STUN** | Uno de Google, por defecto | Ninguno. TURN propio si lo configuras |
---------- Los nombres de las categorías, los módulos y el resto de la aplicación son los de
epsylon, sin tocar.
## C-AI (collective artificial intelligence) ---
Oasis contains its own AI model called "42". ## Qué es Oasis, en cuatro dibujos
The main idea behind this implementation is to enable distributed learning generated through the collective action of many individuals, with the goal of redistributing the necessary processing load, as well as the ecological footprint and corporate bias. Una red social **sin servidor**: publicas en tu propio equipo y los demás se traen una
copia. No hay cuenta, no hay contraseña y nadie puede borrarte.
![SNH](https://solarnethub.com/git/oasis-ai-example2.png "SolarNET.HuB") ![Qué es Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/01-red.png)
Our AI is trained with content from the OASIS network and its purpose is to take action and obtain answers to individual, but also global, problems. Los mensajes viajan **de vecino en vecino**, así que llegan a gente con la que nunca te
has conectado. Por eso funciona incluso sin internet, entre dos móviles en la misma wifi.
+ https://wiki.solarnethub.com/socialnet/ai ![Cómo llega un mensaje](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/02-gossip.png)
---------- Un **pub** es solo un nodo que no se apaga nunca. No es un servidor con tus datos: no
guarda tu identidad, no decide qué ves y no puede borrarte.
## Parliament ![Qué es un pub](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/04-pub.png)
Oasis contains its own Parliament (Government system). Todo lo que publicas se encadena y va **firmado con tu clave**. Nadie puede colar un
mensaje en tu nombre ni cambiar lo que dijiste.
![SNH](https://solarnethub.com/git/oasis-parliament.png "SolarNET.HuB") ![Tu registro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/14-registro.png)
## Courts
Oasis contains its own Courts (Justice system). > La explicación completa —los saltos, las tribus, el LARP, los módulos— está en
> **[Entender Oasis en diez minutos](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender)**.
![SNH](https://solarnethub.com/git/oasis-courts.png "SolarNET.HuB") ---
## ECOin
Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace. ## Karvan: salas que se borran solas
![SNH](https://solarnethub.com/git/oasis-tomatoes-example.png "SolarNET.HuB") ![Cómo funciona Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/11-karvan.png)
You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions. Lo que se dice dentro **no toca el disco**, no entra en tu registro y no se replica a
nadie. Dos relojes corren a la vez —inactividad y tiempo absoluto— y la sala muere con
el que llegue antes.
+ https://ecoin.03c8.net <p align="center">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/10-karvan-salas.png" width="270">
## Banking <img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/11-karvan-sala.png" width="270">
</p>
Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust. ---
![SNH](https://solarnethub.com/git/oasis-banking.png "SolarNET.HuB") ## Videollamadas sin terceros
----------
## Carbon Footprinting ![Cómo funciona una llamada](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/12-llamadas.png)
Oasis contains its own carbon footprint meter. El servidor solo sirve para que os encontréis: **la voz y el vídeo van directos** entre
los participantes, cifrados de extremo a extremo. Sin STUN de Google.
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting.png "SolarNET.HuB") <p align="center">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/12-permiso-camara.png" width="270">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/15-llamada-controles.png" width="270">
</p>
All transmissions, stored files, and interactions are measured to understand the environmental impact of the network. Esto **no es posible con la APK oficial**: no declara los permisos de cámara y su
navegador interno los deniega. Por eso esta rama trae su propio envoltorio Android.
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting2.png "SolarNET.HuB") ---
And also of each inhabitant. ## La interfaz
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting3.png "SolarNET.HuB") ![La interfaz por dentro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/18-interfaz.png)
---------- <p align="center">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/01-portada.png" width="240">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/04-hexagono-network.png" width="240">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/09-barra-con-karvan.png" width="240">
</p>
## L.A.R.P. Casi cincuenta módulos no caben en la pantalla de un teléfono: se reparten en un panal
de categorías, dos filtros arriba y una barra de cuatro atajos que eliges tú.
Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses. ---
![SNH](https://solarnethub.com/git/oasis-larp-schema.jpg "SolarNET.HuB") ## Empezar desde cero
The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics. ![De cero a estar dentro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/05-empezar.png)
+ https://wiki.solarnethub.com/socialnet/roleplaying#how_to_play | | |
|---|---|
Check "The Houses" to review which one fit better with your ambitions: | **[oasis-project.pub](https://oasis-project.pub)** | Directorio de pubs, con invitaciones y estado de cada uno |
| **[0asis.net](https://0asis.net)** | El proyecto explicado desde el principio |
| **[wiki.solarnethub.com](https://wiki.solarnethub.com/socialnet/overview)** | La documentación oficial, módulo por módulo |
+ https://wiki.solarnethub.com/socialnet/roleplaying#the_houses ---
---------- ## Instalar
## Fediverse La APK **no está en ninguna tienda**: se descarga de las *releases* de este repositorio
y se instala a mano, con orígenes desconocidos activado. Se instala **junto al Oasis
oficial** sin pisarlo —el identificador es `net.laenre.oasis`—, así que puedes tener
los dos y comparar.
Oasis bridges to the **Fediverse**. You can connect your fediverse accounts (ex: **Mastodon**) and use them for content from inside Oasis: read your home timeline, publish (text, images and video), reply, boost and favourite — without storing any third-party content in your SSB log (the feed is fetched live and shown ephemerally; only your credentials are kept locally). Necesita unos **350 MB libres**. El primer arranque tarda entre 15 y 60 segundos porque
descomprime el servidor.
![SNH](https://solarnethub.com/git/oasis-fediverse.png "SolarNET.HuB") > **Verifica la huella de la firma** antes de instalar una APK bajada de un servidor
> propio: `apksigner verify --print-certs`
Connect your account from **Settings → Fediverse**, then open it from the **Fediverse** menu. Each network lives in its own space. ### Compilar
+ Mastodon connect guide: [docs/FEDIVERSE/MASTODON/connect.md](docs/FEDIVERSE/MASTODON/connect.md) ```sh
cd android
./scripts/prepare.sh ../../oasis_mobile # librerías nativas y backend, la primera vez
./scripts/release.sh # compila, verifica y calcula la huella
```
---------- ![Qué hay dentro de la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/16-arquitectura-movil.png)
## Invite codes (for PUBs and TRIBES):
Oasis is a TRUSTNET. This means you need an invitation code to enter the PUBs (managed by inhabitants or hacklabs). ---
Similarly, TRIBES (groups in Oasis) require an entry code. ## Documentación
![SNH](https://solarnethub.com/git/snh-oasis-invites.png "SolarNET.HuB") Todo lo de arriba, en detalle, está en la
**[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki)**:
While you can use it and connect to any nodes you want, it's a good idea to get an entry code to connect with the community.
So you'll need to know someone, or participate in a collective action that distributes invitation codes, to see everything. | | |
|---|---|
| [Entender Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender) | La red, el gossip, los saltos, los pubs, las tribus y el LARP |
| [Empezar desde cero](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Empezar) | Invitación, seguir gente, abrir tu blog o tu tienda |
| [Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Karvan) | Salas efímeras, paso a paso |
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Cómo se llama y cómo montar un TURN |
| [La interfaz](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Interfaz) | Botón por botón |
| [Identidades](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Identidades) | Varias cuentas en el mismo móvil |
| [Compilar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Compilar) | Y cómo se publica una versión |
| [Estructura del código](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Estructura) | Dónde vive cada fichero y por qué ahí |
| [Si algo va mal](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Problemas) | Los fallos que han pasado de verdad |
| [Qué NO tiene](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Limitaciones) | Lo que falta respecto al oficial, sin adornos |
+ https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants ---
----------
## Architecture: ## El proyecto original
Oasis uses a gossip protocol or epidemic protocol which is a procedure or process of computer peer-to peer communication Esta rama existe **gracias a** y **por encima de** el trabajo de epsylon. El código, la
that is based on the way epidemics spread. arquitectura, los módulos y la red son suyos.
![SNH](https://solarnethub.com/git/snh-meshnet.png "SolarNET.HuB") - **Código:** https://github.com/epsylon/oasis
- **Proyecto:** https://solarnethub.com
- **Wiki:** https://wiki.solarnethub.com/socialnet/overview
This means that information is able to distribute across multiple machines, without requiring direct connections between them. ## Licencia
![SNH](https://solarnethub.com/git/gossip-graph1.png "SolarNET.HuB") AGPL-3.0, la misma que el proyecto original.
Even though Alice and Dan lack a direct connection, they can still exchange feeds:
![SNH](https://solarnethub.com/git/gossip-graph2.png "SolarNET.HuB")
This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB
to reach Alice, and visa-versa.
----------
## Backend:
Oasis is based on a mesh network and self-hosted social media ecosystem called Secure Scuttlebutt (SSB).
SSB uses a blockchain like append-only data structure and a fully decentralized P2P network. There are no servers or authorities
of any kind. Like a crypto transaction, SSB posts are censorship-resistant and are replicated to the entire network.
![SNH](https://solarnethub.com/git/ssb-participants-perspective.png "SolarNET.HuB")
In SSB each user hosts their own content and the content of the peers they follow, which provides fault tolerance and
eventual consistency.
----------
## Installing:
Follow ['INSTALL.md'](docs/install/install.md) to build and install it on your device.
----------
## Setup & Deploy:
Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal) to learn how to choose your language, set a theme & configure your avatar.
----------
## SNH-Hub (for HackLabs):
The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
![SNH](https://solarnethub.com/git/snh-pub-feed.png "SolarNET.HuB")
Just visit: https://pub.solarnethub.com/
![SNH](https://solarnethub.com/git/snh-pub-laplaza.png "SolarNET.HuB")
And also you can visit periodically the public statistic of the SNH-PUB:
![SNH](https://solarnethub.com/git/snh-pub-stats.png "SolarNET.HuB")
See stats: https://laplaza.solarnethub.com/
----------
## Roadmap:
Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
----------
## Translations:
Oasis supports multiple languages. One way to contribute is to translate the interface into your language so other people in your region can use it more intuitively.
+ https://wiki.solarnethub.com/socialnet/snh#choose_language
----------
## Development:
Oasis is completely coded in: node.js, HTML5 + CSS.
Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
----------
## Links:
+ SNH Website: https://solarnethub.com
+ Kräkens.Lab: https://krakenslab.com
+ Documentation: https://wiki.solarnethub.com
+ Research: https://wiki.solarnethub.com/docs/research
+ Code of Conduct: https://wiki.solarnethub.com/docs/code_of_conduct
+ The KIT: https://wiki.solarnethub.com/kit/overview
+ Ecosystem: https://wiki.solarnethub.com/socialnet/ecosystem
+ Project Network: https://wiki.solarnethub.com/socialnet/snh#the_project_network
+ Oasis: https://wiki.solarnethub.com/socialnet/overview
+ ECOin: https://wiki.solarnethub.com/ecoin/overview
+ Role-playing (L.A.R.P): https://wiki.solarnethub.com/socialnet/roleplaying
+ Warehouse: https://wiki.solarnethub.com/stock/submit_request
+ THS: https://thehackerstyle.com
+ PeerTube: https://video.hardlimit.com/c/thehackerstyle/videos
+ Youtube: https://www.youtube.com/@thehackerstyle
+ Twitch: https://twitch.tv/thehackerstyle

7
android/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# artefactos y binarios grandes: los prepara scripts/prepare.sh
app/src/main/jniLibs/
app/src/main/assets/nodejs-project.zip
.gradle/
build/
app/build/
local.properties

90
android/README.md Normal file
View file

@ -0,0 +1,90 @@
# Wrapper Android propio
Proyecto Gradle para construir la APK de la rama sin depender del APK oficial como
molde.
## Por que existe
El APK que se venia usando es el de epsylon re-firmado, y su `classes.dex` **no
implementa `WebChromeClient.onPermissionRequest`**. Sin ese metodo el WebView de
Android deniega por defecto toda peticion de `getUserMedia()`, de modo que las
videollamadas de Karvan no pueden funcionar por mucho que se parchee el manifest:
la lista de permisos es estatica y hay que declararla al compilar.
Este wrapper resuelve las dos puertas: declara los permisos y los concede al
contenido web solo tras el consentimiento del usuario.
## Permisos: declarados, no concedidos
`CAMERA`, `RECORD_AUDIO` y `MODIFY_AUDIO_SETTINGS` figuran en el manifest porque
Android exige que la lista sea fija. Pero son permisos peligrosos: **se piden en
tiempo de ejecucion la primera vez que se pulsa Llamar**, no al instalar. Si nadie
llama, no se pide nada y la app no accede a la camara ni al microfono.
`uses-feature ... required="false"` evita excluir de la tienda a dispositivos sin
camara o sin microfono.
## Que reutiliza y que es nuevo
Las tres librerias nativas se toman tal cual del APK existente:
- `libnode.so` — nodejs-mobile **v18.20.4** oficial (OpenSSL 3.0.13+quic), un
artefacto publico; no hay que compilar Node.
- `libnative-lib.so` — 6,8 KB, un solo simbolo JNI:
`Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments`.
- `libc++_shared.so`
Por eso el `namespace` del modulo es `com.solarnethub.oasis`: JNI resuelve por
nombre y la clase puente tiene que vivir en ese paquete. El `applicationId` si es
propio (`net.laenre.oasis`), asi que la app se instala junto a la oficial sin
pisarla.
Si algun dia se quiere soltar el `.so` de epsylon, reimplementar esos 6,8 KB son
unas 40 lineas de C++ contra las cabeceras de nodejs-mobile, y ahi si haria falta
el NDK.
## Mejoras sobre el APK actual
- `targetSdk 35` (el actual se quedo en uno ya caducado para la tienda).
- Trafico en claro permitido **solo hacia loopback** via
`network_security_config`, en vez de `usesCleartextTraffic="true"` global.
- `allowBackup="false"`: `adb backup` deja de poder extraer `~/.ssb`.
- El backend corre en un servicio en primer plano, en el proceso `:node`, asi que
sobrevive en segundo plano — imprescindible para recibir llamadas. El APK actual
declara `FOREGROUND_SERVICE` pero no registra ningun servicio.
- Al descomprimir el backend se descartan las entradas con rutas sospechosas
(zip slip).
## Construir
```sh
# 1. dependencias del backend (una vez)
(cd ../src/server && npm install)
# 2. librerias nativas + empaquetado del backend
./scripts/prepare.sh ../../oasis_mobile # o la ruta de un APK
# 3. compilar
./gradlew assembleDebug # o assembleRelease
```
Para firmar la release, por entorno (la keystore nunca al repositorio):
```sh
export OASIS_KEYSTORE=/ruta/oasis-alfa-key.jks
export OASIS_KEYSTORE_PASS=...
export OASIS_KEY_ALIAS=oasis
./gradlew assembleRelease
```
## Requisitos
JDK 17 o superior **con compilador**. El OpenJDK del sistema puede ser solo
runtime; `gradle.properties` apunta al JDK que trae Android Studio. Cambia esa
linea si compilas en otra maquina.
## Estado
Compila y genera APK con los permisos y el `onPermissionRequest` en su sitio.
**Falta probarlo en un dispositivo real**: que arranque el backend, que la
interfaz cargue y que `getUserMedia` resuelva al pulsar Llamar.

View file

@ -0,0 +1,61 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
// El .so nativo exporta Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments,
// asi que la clase puente tiene que vivir en ese paquete para que el simbolo resuelva.
// El applicationId si es propio: la app se instala junto a la oficial sin pisarla.
namespace = "com.solarnethub.oasis"
compileSdk = 35
defaultConfig {
applicationId = "net.laenre.oasis"
minSdk = 24
targetSdk = 35
// el pipeline los pasa por entorno; a mano valen los de aqui
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
versionName = System.getenv("OASIS_VERSION_NAME") ?: "0.9.5"
ndk { abiFilters += listOf("arm64-v8a") }
}
signingConfigs {
create("release") {
val ks = System.getenv("OASIS_KEYSTORE")
if (ks != null) {
storeFile = file(ks)
storePassword = System.getenv("OASIS_KEYSTORE_PASS")
keyAlias = System.getenv("OASIS_KEY_ALIAS") ?: "oasis"
keyPassword = System.getenv("OASIS_KEY_PASS") ?: System.getenv("OASIS_KEYSTORE_PASS")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
if (System.getenv("OASIS_KEYSTORE") != null) signingConfig = signingConfigs.getByName("release")
}
}
buildFeatures { buildConfig = true }
packaging {
jniLibs {
// libnode.so son 49 MB: sin comprimir se carga por mmap y arranca mas rapido
useLegacyPackaging = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
}

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Camara y microfono para las videollamadas de Karvan.
Se declaran aqui porque Android exige que la lista sea estatica, pero NO se
conceden al instalar: son permisos peligrosos y se piden en tiempo de ejecucion
la primera vez que se pulsa Llamar. Si nadie llama, no se pide nada y la app
nunca accede a la camara ni al microfono. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- required=false para no excluir de la tienda a dispositivos sin camara o sin microfono -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="false"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Oasis">
<!-- El backend Node vive en su propio proceso: asi sobrevive en segundo plano
y se puede reiniciar limpio (nodejs-mobile solo permite arrancar node una
vez por proceso). -->
<service
android:name="net.laenre.oasis.NodeService"
android:process=":node"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- La lista de configChanges tiene que ser completa: si falta una sola (y la
que faltaba era smallestScreenSize), rotar destruye y recrea la Activity,
el WebView se pierde y vuelve a cargar la URL, a veces antes de que el
backend escuche. -->
<activity
android:name="net.laenre.oasis.MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|keyboard|navigation|uiMode|fontScale|layoutDirection"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,22 @@
package com.solarnethub.oasis
/**
* Puente con el runtime de Node.
*
* El nombre de la clase y del metodo no son libres: libnative-lib.so exporta
* Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments, y JNI resuelve
* por nombre. De ahi que el namespace del modulo sea com.solarnethub.oasis aunque
* el applicationId sea net.laenre.oasis.
*
* No es una Activity pese al nombre; solo declara el metodo nativo.
*/
object OasisActivity {
init {
System.loadLibrary("node")
System.loadLibrary("native-lib")
}
/** Arranca node en el hilo actual. No retorna hasta que el runtime termina. */
@JvmStatic
external fun startNodeWithArguments(arguments: Array<String>): Int
}

View file

@ -0,0 +1,243 @@
package net.laenre.oasis
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.solarnethub.oasis.BuildConfig
class MainActivity : AppCompatActivity() {
private lateinit var web: WebView
private var pending: PermissionRequest? = null
private var fileCallback: ValueCallback<Array<Uri>>? = null
private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
// Si se cancela hay que devolver null igualmente: si no, el <input> queda
// bloqueado y no vuelve a abrir el selector nunca mas.
fileCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res.resultCode, res.data))
fileCallback = null
}
private val askAndroid = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
val req = pending
pending = null
if (req == null) return@registerForActivityResult
// Solo se concede al contenido web lo que el sistema nos ha concedido a nosotros.
val allowed = req.resources.filter { res ->
when (res) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> granted[Manifest.permission.CAMERA] == true
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> granted[Manifest.permission.RECORD_AUDIO] == true
else -> false
}
}.toTypedArray()
if (allowed.isEmpty()) req.deny() else req.grant(allowed)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= 33 && !has("android.permission.POST_NOTIFICATIONS")) {
askAndroid.launch(arrayOf("android.permission.POST_NOTIFICATIONS"))
}
startService(Intent(this, NodeService::class.java))
web = WebView(this)
setContentView(web)
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
web.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false // permite reproducir el audio remoto de la llamada
allowFileAccess = false
allowContentAccess = false
}
// La navegacion se queda dentro del backend local; lo de fuera va al navegador.
web.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(v: WebView?, r: WebResourceRequest?): Boolean {
val u = r?.url ?: return false
if (isLocal(u)) return false
startActivity(Intent(Intent.ACTION_VIEW, u))
return true
}
/**
* Red de seguridad: si una pagina del backend no carga, se vuelve a esperar
* y se reintenta, en vez de dejar el ERR_CONNECTION_REFUSED de Chromium en
* pantalla. Pasa cuando el backend todavia no escucha (arranque lento), y
* tambien cuando se ha reiniciado al cambiar de identidad, o porque Android
* mato el proceso del servicio estando en segundo plano.
*/
override fun onPageFinished(v: WebView?, url: String?) {
// se recuerda para poder volver aqui si luego hay que reintentar
if (url != null && url.startsWith("http://127.0.0.1:3000")) lastGoodUrl = url
}
override fun onReceivedError(v: WebView?, req: WebResourceRequest?, err: WebResourceError?) {
if (req?.isForMainFrame != true) return // un css o una imagen no cuentan
val u = req.url ?: return
if (!isLocal(u)) return // paginas de fuera, ni tocarlas
Log.w("OasisNode", "la pagina no ha cargado (${'$'}u); reintentando")
loaded = false
waitAndLoad()
}
}
// Sin este WebChromeClient.onPermissionRequest, el WebView DENIEGA por defecto
// toda peticion de getUserMedia y no hay videollamada posible, aunque los
// permisos esten declarados en el manifest. Es la puerta que faltaba.
web.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
view: WebView?,
callback: ValueCallback<Array<Uri>>?,
params: FileChooserParams?
): Boolean {
fileCallback?.onReceiveValue(null) // descartar uno anterior sin resolver
fileCallback = callback
return try {
pickFiles.launch(params?.createIntent())
true
} catch (e: Exception) {
fileCallback = null
false
}
}
override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread {
if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread }
val needed = mutableListOf<String>()
for (res in request.resources) {
when (res) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE ->
if (!has(Manifest.permission.CAMERA)) needed += Manifest.permission.CAMERA
PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
if (!has(Manifest.permission.RECORD_AUDIO)) needed += Manifest.permission.RECORD_AUDIO
}
}
if (needed.isEmpty()) {
request.grant(request.resources)
} else {
// Aqui es donde el usuario ve el dialogo del sistema: al pulsar Llamar,
// no al instalar la app.
pending = request
askAndroid.launch(needed.toTypedArray())
}
}
}
}
waitAndLoad()
}
private fun has(p: String) =
ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
private fun isLocal(u: Uri?): Boolean {
val h = u?.host ?: return false
return (h == "127.0.0.1" || h == "localhost") && u.port == 3000
}
private fun showStatus(attempt: Int) {
val s = attempt / 2
web.loadDataWithBaseURL(null, """
<html><head><meta name='viewport' content='width=device-width,initial-scale=1'>
<style>body{background:#121212;color:#FFB400;font-family:sans-serif;display:flex;
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
div{padding:24px}p{opacity:.7;font-size:14px;line-height:1.5}</style></head>
<body><div><h2>Oasis</h2>
<p>Preparando la aplicaci&oacute;n&hellip;<br>El primer arranque descomprime el
servidor y puede tardar un minuto. No hace falta hacer nada.</p><p>${s}s</p></div></body></html>
""".trimIndent(), "text/html", "utf-8", null)
}
/**
* Carga la interfaz reintentando hasta que el backend responda.
*
* Se hace con el propio WebView en vez de sondear con HttpURLConnection: es el
* canal que de verdad va a servir la app, y evita depender de que la peticion de
* comprobacion pase los mismos filtros (el backend, por ejemplo, responde 400 a HEAD).
*/
private var loadAttempt = 0
private var loaded = false
@Volatile private var waiting = false // hay un hilo esperando al backend
private var lastGoodUrl: String? = null // ultima pagina que cargo bien
/**
* Espera a que el backend acepte conexiones y entonces carga la interfaz.
*
* La comprobacion es un socket TCP, no una peticion HTTP: no depende del metodo
* (el backend responde 400 a HEAD), ni de redirecciones (/ redirige a /activity),
* ni de la politica de trafico en claro. Si el puerto acepta, esta listo.
*
* La pantalla de estado se pinta ANTES de empezar a esperar, nunca entre medias:
* cargarla mientras el WebView trae la pagina buena cancelaria esa carga.
*/
/** Ruta a abrir: la del intent si viene con una, o la raiz. */
private fun targetUrl(): String {
val d = intent?.data
if (d != null && isLocal(d)) return d.toString()
val path = intent?.getStringExtra("path")
if (!path.isNullOrBlank() && path.startsWith("/")) return "http://127.0.0.1:3000${'$'}path"
return "http://127.0.0.1:3000/"
}
override fun onNewIntent(newIntent: Intent) {
super.onNewIntent(newIntent)
intent = newIntent
if (loaded) web.loadUrl(targetUrl())
}
private fun waitAndLoad() {
// onReceivedError puede dispararse varias veces seguidas (la pagina y sus
// recursos); sin esto se acumularian hilos esperando al mismo puerto
if (waiting) return
waiting = true
showStatus(0)
Log.i("OasisNode", "esperando al backend en 127.0.0.1:3000")
Thread {
var i = 0
while (i < 600 && !loaded) { // hasta 5 minutos
val ok = try {
java.net.Socket().use { it.connect(java.net.InetSocketAddress("127.0.0.1", 3000), 1000); true }
} catch (e: Exception) {
if (i % 10 == 0) Log.w("OasisNode", "socket #" + i + ": " + e.javaClass.simpleName + ": " + e.message)
false
}
if (ok) {
Handler(Looper.getMainLooper()).post {
loaded = true
waiting = false
Log.i("OasisNode", "backend listo tras " + (i / 2) + "s, cargando interfaz")
web.loadUrl(lastGoodUrl ?: targetUrl())
}
return@Thread
}
if (i % 8 == 0) Handler(Looper.getMainLooper()).post { if (!loaded) showStatus(i) }
Thread.sleep(500); i++
}
waiting = false
}.start()
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (web.canGoBack()) web.goBack() else super.onBackPressed()
}
}

View file

@ -0,0 +1,117 @@
package net.laenre.oasis
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.system.Os
import android.util.Log
import com.solarnethub.oasis.OasisActivity
import com.solarnethub.oasis.R
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
/**
* Servicio en primer plano que sostiene el backend Node.
*
* Vive en el proceso :node por dos razones: nodejs-mobile solo permite arrancar el
* runtime una vez por proceso cambiar de identidad exige matar el proceso, no vale
* reiniciar dentro, y asi el backend sobrevive cuando la app pasa a segundo plano,
* que es imprescindible para recibir llamadas.
*/
class NodeService : Service() {
companion object {
private const val TAG = "OasisNode"
private const val CHANNEL = "oasis-node"
private const val NOTIF_ID = 1
@Volatile private var started = false
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
startForeground(NOTIF_ID, buildNotification())
if (started) return
started = true
Thread({ runNode() }, "node-main").start()
}
private fun buildNotification(): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL, getString(R.string.node_channel), NotificationManager.IMPORTANCE_LOW)
ch.setShowBadge(false)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(ch)
}
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL)
else @Suppress("DEPRECATION") Notification.Builder(this)
return b.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.node_running))
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setOngoing(true)
.build()
}
private fun runNode() {
try {
val root = File(filesDir, "nodejs-project")
extractProjectIfNeeded(root)
val main = File(root, "main.js")
if (!main.exists()) { Log.e(TAG, "no encuentro main.js en ${root.absolutePath}"); return }
// main.js hace process.env.HOME || resolve(__dirname,'..','..'). En Android HOME
// suele venir como "/" —no escribible—, asi que se fija explicitamente al
// almacenamiento privado de la app: ahi cuelga ~/.ssb.
val home = filesDir.parentFile ?: filesDir
val tmp = File(home, "tmp").apply { mkdirs() }
Os.setenv("HOME", home.absolutePath, true)
Os.setenv("TMPDIR", tmp.absolutePath, true)
Log.i(TAG, "HOME=${home.absolutePath}")
val rc = OasisActivity.startNodeWithArguments(arrayOf("node", main.absolutePath))
Log.w(TAG, "el runtime de node ha terminado con codigo $rc")
} catch (e: Throwable) {
Log.e(TAG, "fallo arrancando node", e)
}
}
/** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */
private fun extractProjectIfNeeded(root: File) {
val stamp = File(root, ".version")
// Marca por tamano y fecha del asset, no por versionName: al reconstruir la APK
// sin subir la version, con versionName la app seguiria con el backend anterior.
// lastUpdateTime cambia en cada instalacion, tambien reinstalando la misma version.
// (openFd no vale: el asset va comprimido dentro del APK y no admite descriptor.)
val current = packageManager.getPackageInfo(packageName, 0).lastUpdateTime.toString()
if (stamp.exists() && stamp.readText() == current) return
if (root.exists()) root.deleteRecursively()
root.mkdirs()
assets.open("nodejs-project.zip").use { input ->
ZipInputStream(input).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val out = File(root, entry.name.removePrefix("nodejs-project/"))
// no confiar en las rutas del zip: evita escribir fuera del directorio (zip slip)
if (!out.canonicalPath.startsWith(root.canonicalPath + File.separator) &&
out.canonicalPath != root.canonicalPath) {
Log.w(TAG, "entrada de zip descartada por ruta sospechosa: ${entry.name}")
} else if (entry.isDirectory) {
out.mkdirs()
} else {
out.parentFile?.mkdirs()
FileOutputStream(out).use { fos -> zip.copyTo(fos) }
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
stamp.writeText(current)
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hexagono, el mismo motivo que la rejilla de navegacion de la interfaz -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path
android:pathData="M54,28 L74,40 L74,64 L54,76 L34,64 L34,40 Z"
android:strokeColor="#FFB400" android:strokeWidth="5" android:fillColor="#00000000"/>
<path
android:pathData="M54,44 L63,49 L63,59 L54,64 L45,59 L45,49 Z"
android:fillColor="#FFB400"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Oasis</string>
<string name="node_channel">Servicio de Oasis</string>
<string name="node_running">Oasis esta activo</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Oasis" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">#121212</item>
<item name="android:statusBarColor">#121212</item>
</style>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- El backend corre en 127.0.0.1:3000 sin TLS, asi que hay que permitir texto en
claro, pero SOLO hacia loopback. El resto de la red queda en cifrado obligatorio.
El orden importa: el esquema de Android exige base-config antes que domain-config. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

4
android/build.gradle.kts Normal file
View file

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View file

@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
# El OpenJDK del sistema es solo runtime (sin javac). Se usa el JDK que trae
# Android Studio. Si compilas en otra maquina, cambia esta ruta o instala un JDK 17+.
org.gradle.java.home=/opt/android-studio/jbr

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
android/gradlew vendored Executable file
View file

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

80
android/scripts/prepare.sh Executable file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Prepara lo que el proyecto Android no lleva en git:
# - las librerias nativas (libnode.so son 49 MB; no tiene sentido versionarlas)
# - assets/nodejs-project.zip con el backend Node y sus dependencias
#
# Uso: ./scripts/prepare.sh [ruta-a-un-apk-o-directorio-con-lib/arm64-v8a]
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
JNI="$HERE/app/src/main/jniLibs/arm64-v8a"
ASSETS="$HERE/app/src/main/assets"
SRC="${1:-}"
mkdir -p "$JNI" "$ASSETS"
# --- 1) librerias nativas -----------------------------------------------------
need_libs=(libnode.so libnative-lib.so libc++_shared.so)
missing=0
for l in "${need_libs[@]}"; do [ -f "$JNI/$l" ] || missing=1; done
if [ "$missing" = "1" ]; then
if [ -z "$SRC" ]; then
echo "Faltan las librerias nativas en $JNI"
echo "Pasa un APK o un directorio que tenga lib/arm64-v8a/, por ejemplo:"
echo " ./scripts/prepare.sh ../../oasis_mobile"
exit 1
fi
if [ -d "$SRC/lib/arm64-v8a" ]; then
cp "$SRC/lib/arm64-v8a"/*.so "$JNI/"
elif [ -f "$SRC" ]; then
tmp="$(mktemp -d)"; unzip -qo "$SRC" 'lib/arm64-v8a/*' -d "$tmp"
cp "$tmp/lib/arm64-v8a"/*.so "$JNI/"; rm -rf "$tmp"
else
echo "No encuentro librerias en $SRC"; exit 1
fi
echo "librerias nativas copiadas:"; ls -la "$JNI" | tail -n +2 | awk '{print " "$NF" ("$5" bytes)"}'
fi
# --- 2) backend + dependencias ---------------------------------------------
# El node_modules del movil esta podado: son ~137 MB frente a los 1,4 GB de una
# instalacion de escritorio. En vez de reinstalarlo, se reutiliza el del APK base:
# comprobado que las 98 dependencias son identicas entre 0.9.1 y 0.9.5.
BASE_ZIP=""
if [ -n "$SRC" ] && [ -f "$SRC" ]; then
tmpz="$(mktemp -d)"; unzip -qo "$SRC" 'assets/nodejs-project.zip' -d "$tmpz" 2>/dev/null || true
[ -f "$tmpz/assets/nodejs-project.zip" ] && BASE_ZIP="$tmpz/assets/nodejs-project.zip"
fi
work="$(mktemp -d)"
if [ -n "$BASE_ZIP" ]; then
( cd "$work" && unzip -q "$BASE_ZIP" )
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
rm -rf "$work/nodejs-project/src"
elif [ -d "$REPO/src/server/node_modules" ]; then
mkdir -p "$work/nodejs-project"
cp -r "$REPO/src/server/node_modules" "$work/_nm"
else
echo "No hay node_modules. Pasa un APK base del que sacarlo, o instalalas:"
echo " (cd $REPO/src/server && npm install --omit=dev)"
exit 1
fi
mkdir -p "$work/nodejs-project"
cp -r "$REPO/src" "$work/nodejs-project/"
# backend.js lee README.md con readFileSync SIN try/catch (linea ~1690):
# si falta, el backend muere con ENOENT al arrancar.
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/" 2>/dev/null || true
test -f "$work/nodejs-project/README.md" || { echo "FALTA README.md: el backend no arrancaria"; exit 1; }
rm -rf "$work/nodejs-project/src/server/node_modules"
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
# --- 3) empaquetar ------------------------------------------------------------
# Con compresion: sin ella el backend son ~145 MB y la APK se iria por encima de 200.
rm -f "$ASSETS/nodejs-project.zip"
( cd "$work" && zip -q -r "$ASSETS/nodejs-project.zip" nodejs-project )
rm -rf "$work" "${tmpz:-/nonexistent}"
echo "backend empaquetado: $(du -h "$ASSETS/nodejs-project.zip" | cut -f1) en assets/nodejs-project.zip"
echo
echo "Listo. Ahora: ./gradlew assembleDebug (o assembleRelease para firmar)"

151
android/scripts/release.sh Executable file
View file

@ -0,0 +1,151 @@
#!/usr/bin/env bash
#
# Construye la APK, la verifica y (si se le pasa un token) publica la release en
# gitea. Pensado para lanzarlo a mano o desde un runner; hace lo mismo en los dos
# sitios, que es lo que evita el clasico "en mi maquina si sale".
#
# Uso:
# ./scripts/release.sh # compila debug y verifica
# ./scripts/release.sh --release # compila release firmada
# ./scripts/release.sh --release --publish
#
# Variables:
# OASIS_KEYSTORE, OASIS_KEYSTORE_PASS, OASIS_KEY_ALIAS firma (obligatorias con --release)
# OASIS_GITEA_TOKEN publicar (obligatoria con --publish)
# OASIS_APK_BASE APK del que sacar node_modules la primera vez
# OASIS_GITEA_URL por defecto https://gitea.laenre.net
# OASIS_GITEA_REPO por defecto hacklab/OASIS_MOBILE
# ANDROID_HOME por defecto ~/Android/Sdk
# JAVA_HOME un JDK 17 o superior CON compilador
#
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
cd "$HERE"
MODE=debug
PUBLISH=0
for arg in "$@"; do
case "$arg" in
--release) MODE=release ;;
--publish) PUBLISH=1 ;;
*) echo "opcion desconocida: $arg"; exit 2 ;;
esac
done
GITEA_URL="${OASIS_GITEA_URL:-https://gitea.laenre.net}"
GITEA_REPO="${OASIS_GITEA_REPO:-hacklab/OASIS_MOBILE}"
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
say() { printf '\n== %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
# --- 1) version -------------------------------------------------------------
# El nombre sale de package.json, que es de donde lo saca tambien el backend, para
# que la version que muestra la aplicacion y la de la APK no puedan discrepar.
VERSION_NAME="${OASIS_VERSION_NAME:-$(node -p "require('$REPO/package.json').version" 2>/dev/null || echo 0.0.0)}"
# El codigo tiene que subir en cada publicacion o Android rechaza la instalacion:
# el numero de commits sirve y no hay que llevarlo a mano.
VERSION_CODE="${OASIS_VERSION_CODE:-$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 1)}"
TAG="v${VERSION_NAME}-${VERSION_CODE}"
export OASIS_VERSION_NAME="$VERSION_NAME" OASIS_VERSION_CODE="$VERSION_CODE"
say "version ${VERSION_NAME} (codigo ${VERSION_CODE}), modo ${MODE}"
# --- 2) comprobaciones previas ---------------------------------------------
[ -n "${JAVA_HOME:-}" ] || die "define JAVA_HOME apuntando a un JDK con compilador"
[ -x "${JAVA_HOME}/bin/javac" ] || die "en ${JAVA_HOME} no hay javac: es un runtime, no un JDK"
[ -d "$SDK" ] || die "no encuentro el SDK de Android en $SDK (define ANDROID_HOME)"
if [ "$MODE" = release ]; then
[ -n "${OASIS_KEYSTORE:-}" ] || die "--release necesita OASIS_KEYSTORE"
[ -f "$OASIS_KEYSTORE" ] || die "no existe el almacen de claves: $OASIS_KEYSTORE"
[ -n "${OASIS_KEYSTORE_PASS:-}" ] || die "--release necesita OASIS_KEYSTORE_PASS"
fi
if [ "$PUBLISH" = 1 ]; then
[ -n "${OASIS_GITEA_TOKEN:-}" ] || die "--publish necesita OASIS_GITEA_TOKEN"
[ "$MODE" = release ] || die "no se publica una APK de depuracion"
fi
# --- 3) backend empaquetado -------------------------------------------------
# prepare.sh es idempotente pero tarda; se rehace siempre porque el backend cambia
# en cada commit y una APK con el backend viejo es peor que no tener APK.
# La primera vez hace falta un APK base del que sacar node_modules: el del movil
# esta podado y sin binarios nativos, y un npm install normal no lo reproduce.
say "empaquetando el backend"
./scripts/prepare.sh ${OASIS_APK_BASE:+"$OASIS_APK_BASE"} \
|| die "prepare.sh ha fallado (define OASIS_APK_BASE con un APK del que sacar node_modules)"
ZIP=app/src/main/assets/nodejs-project.zip
[ -f "$ZIP" ] || die "falta $ZIP"
ZIP_MB=$(( $(stat -c%s "$ZIP") / 1024 / 1024 ))
[ "$ZIP_MB" -ge 20 ] || die "el backend empaquetado son $ZIP_MB MB: se ha quedado a medias"
say "backend: ${ZIP_MB} MB"
# --- 4) compilar ------------------------------------------------------------
say "compilando"
if [ "$MODE" = release ]; then
./gradlew --quiet assembleRelease
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
else
./gradlew --quiet assembleDebug
APK=$(ls app/build/outputs/apk/debug/*.apk | head -1)
fi
[ -f "$APK" ] || die "no se ha generado ninguna APK"
say "APK: $APK ($(( $(stat -c%s "$APK") / 1024 / 1024 )) MB)"
# --- 5) verificar -----------------------------------------------------------
# Estas tres comprobaciones son las que han cazado fallos de verdad: una APK que
# compila e instala puede no llevar el backend dentro, o haber perdido los permisos.
BT=$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1)
[ -n "$BT" ] || die "no encuentro build-tools en $SDK"
say "verificando"
# ojo con pipefail: grep -q cierra la tuberia y el productor muere con SIGPIPE,
# asi que se vuelca a fichero antes de mirar
LISTING=$(mktemp); PERMS=$(mktemp)
trap 'rm -f "$LISTING" "$PERMS"' EXIT
unzip -l "$APK" > "$LISTING"
"$BT/aapt2" dump permissions "$APK" > "$PERMS" 2>/dev/null || true
grep -q "assets/nodejs-project.zip" "$LISTING" || die "la APK no lleva el backend dentro"
grep -q "android.permission.CAMERA" "$PERMS" || die "la APK no declara CAMERA: no podria hacer videollamadas"
grep -q "android.permission.RECORD_AUDIO" "$PERMS" || die "la APK no declara RECORD_AUDIO"
grep -q "libnode.so" "$LISTING" || die "la APK no lleva el runtime de node"
if [ "$MODE" = release ]; then
"$BT/apksigner" verify --print-certs "$APK" > /tmp/oasis-signer.txt 2>&1 \
|| die "la APK no esta firmada correctamente"
FINGERPRINT=$(grep -m1 "SHA-256 digest" /tmp/oasis-signer.txt | awk '{print $NF}')
say "huella del certificado: $FINGERPRINT"
fi
SHA=$(sha256sum "$APK" | cut -d' ' -f1)
say "sha256 de la APK: $SHA"
# --- 6) publicar ------------------------------------------------------------
if [ "$PUBLISH" != 1 ]; then
say "listo (sin publicar)"
exit 0
fi
say "publicando $TAG en $GITEA_REPO"
API="$GITEA_URL/api/v1/repos/$GITEA_REPO"
AUTH="Authorization: token $OASIS_GITEA_TOKEN"
NOTES=$(printf 'Rama experimental del hacklab sobre el Oasis de epsylon.\n\nNO es Oasis oficial: https://github.com/epsylon/oasis\n\nVersion base: %s (codigo %s)\n\nsha256 de la APK: %s\nHuella del certificado: %s\n\nComprueba la huella antes de instalar:\n\n apksigner verify --print-certs oasis.apk\n' \
"$VERSION_NAME" "$VERSION_CODE" "$SHA" "${FINGERPRINT:-sin firmar}")
RELEASE_ID=$(curl -sf -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
-d "$(node -e '
const [tag, name, body] = process.argv.slice(1);
process.stdout.write(JSON.stringify({ tag_name: tag, name, body, draft: true, prerelease: true }));
' "$TAG" "Oasis Mobile $TAG" "$NOTES")" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null) \
|| die "no se ha podido crear la release (token sin permiso, o el tag ya existe)"
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-$TAG.apk" \
-H "$AUTH" -F "attachment=@$APK" >/dev/null \
|| die "la release se ha creado pero no se ha podido subir la APK"
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
say "revisala y quita el borrador a mano; no se publica sola a proposito"

View file

@ -0,0 +1,9 @@
pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() }
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = "OasisMobile"
include(":app")

View file

@ -1,6 +1,6 @@
{ {
"name": "@krakenslab/oasis", "name": "@krakenslab/oasis",
"version": "0.9.1", "version": "0.9.5",
"description": "Oasis - Social Networking Utopia", "description": "Oasis - Social Networking Utopia",
"repository": { "repository": {
"type": "git", "type": "git",
@ -156,4 +156,4 @@
"node": "^10.0.0 || >=12.0.0" "node": "^10.0.0 || >=12.0.0"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

74
src/backend/accounts.js Normal file
View file

@ -0,0 +1,74 @@
/**
* Identidades locales.
*
* Cada identidad vive en su propio directorio ~/.<cuenta>, con su secret, su base
* de datos y sus blobs. Cual esta activa lo decide ssb_config.js al arrancar:
* primero OASIS_ACCOUNT, y si no, el fichero que escribe setActive().
*
* Aqui no se cambia de identidad en caliente: medio Oasis captura la referencia al
* sbot al cargarse, asi que cambiarla con el proceso vivo dejaria modulos hablando
* con la identidad anterior. El cambio se aplica al reiniciar.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// mismo criterio que ssb_config.js: este nombre acaba en rutas de fichero
const ACCOUNT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/;
const ACTIVE_FILE = path.join(__dirname, '..', 'configs', 'active-account');
const isValid = (name) => typeof name === 'string' && ACCOUNT_RE.test(name);
/** Cuenta anotada como activa, o null si no hay ninguna anotada. */
const readActive = () => {
try {
const raw = fs.readFileSync(ACTIVE_FILE, 'utf8').trim();
return isValid(raw) ? raw : null;
} catch (_) { return null; }
};
/** Anota cual sera la cuenta activa en el proximo arranque. */
const setActive = (name) => {
if (!isValid(name)) throw new Error('nombre de cuenta no valido');
fs.mkdirSync(path.dirname(ACTIVE_FILE), { recursive: true });
fs.writeFileSync(ACTIVE_FILE, name, { mode: 0o600 });
return name;
};
/** Directorio de una cuenta. */
const dirOf = (name) => path.join(os.homedir(), `.${name}`);
/**
* Identidades que existen en el disco: directorios ~/.<algo> con un secret dentro.
* El secret es lo que hace que un directorio sea una identidad y no otra cosa.
*/
const list = () => {
let entries = [];
try {
entries = fs.readdirSync(os.homedir(), { withFileTypes: true });
} catch (_) { return []; }
return entries
.filter((e) => e.isDirectory() && e.name.startsWith('.'))
.map((e) => e.name.slice(1))
.filter(isValid)
.filter((name) => {
try { return fs.statSync(path.join(dirOf(name), 'secret')).isFile(); }
catch (_) { return false; }
})
.sort();
};
/** Identidad publica (@...) de una cuenta, leyendo su secret. Null si no se puede. */
const feedIdOf = (name) => {
if (!isValid(name)) return null;
try {
const raw = fs.readFileSync(path.join(dirOf(name), 'secret'), 'utf8');
const clean = raw.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
const id = JSON.parse(clean).id;
return typeof id === 'string' ? id : null;
} catch (_) { return null; }
};
module.exports = { isValid, list, readActive, setActive, feedIdOf, dirOf, ACTIVE_FILE };

File diff suppressed because it is too large Load diff

View file

@ -11,15 +11,166 @@ try {
} catch (e) { } catch (e) {
} }
const stripImageMetadata = async (buffer) => { /**
if (typeof sharp !== "function") return buffer; * Quitar metadatos de una imagen SIN sharp.
*
* sharp no se empaqueta en Android (lleva binarios nativos y el bundle del movil
* va sin ninguno), asi que alli esta funcion devolvia la imagen intacta y las
* fotos subian con su EXIF entero, ubicacion GPS incluida.
*
* Esto recorre el contenedor y tira los bloques de metadatos, sin decodificar ni
* recomprimir la imagen: los pixeles se quedan byte a byte como estaban.
*
* De la orientacion se guarda solo eso, la orientacion. Sin sharp no se pueden
* girar los pixeles, y tirar la etiqueta dejaria las fotos verticales tumbadas;
* asi que se reconstruye un bloque EXIF minimo con esa unica etiqueta, que no
* dice nada de quien hizo la foto ni donde.
*/
// JPEG: cada segmento es FF <marca> <longitud:2> <datos>. Se tiran los que llevan
// metadatos y se dejan JFIF (APP0) y el perfil de color (APP2), que afectan a como
// se ve la imagen.
const JPEG_DROP = new Set([
0xE1, // APP1 Exif y XMP
0xE5, // APP5
0xE6, // APP6
0xEC, // APP12 Ducky/Picture Info
0xED, // APP13 IPTC / Photoshop
0xEE, // APP14 Adobe
0xFE // COM comentario
]);
const jpegOrientation = (seg) => {
// seg empieza en "Exif\0\0"; detras va una cabecera TIFF con la IFD0
if (seg.length < 14 || seg.toString("latin1", 0, 6) !== "Exif\0\0") return 0;
const tiff = seg.subarray(6);
const le = tiff.toString("latin1", 0, 2) === "II";
const u16 = (o) => (le ? tiff.readUInt16LE(o) : tiff.readUInt16BE(o));
const u32 = (o) => (le ? tiff.readUInt32LE(o) : tiff.readUInt32BE(o));
if (u16(2) !== 0x002A) return 0;
const ifd0 = u32(4);
if (ifd0 + 2 > tiff.length) return 0;
const n = u16(ifd0);
for (let i = 0; i < n; i++) {
const e = ifd0 + 2 + i * 12;
if (e + 12 > tiff.length) break;
if (u16(e) === 0x0112) { // Orientation
const v = u16(e + 8);
return v >= 1 && v <= 8 ? v : 0;
}
}
return 0;
};
/** Bloque APP1 de 32 bytes con la orientacion y nada mas. */
const exifOnlyOrientation = (orientation) => {
const b = Buffer.alloc(32);
b.write("Exif\0\0", 0, "latin1");
b.write("II", 6, "latin1");
b.writeUInt16LE(0x002A, 8);
b.writeUInt32LE(8, 10); // la IFD0 empieza justo detras de la cabecera TIFF
b.writeUInt16LE(1, 14); // una sola entrada
b.writeUInt16LE(0x0112, 16); // Orientation
b.writeUInt16LE(3, 18); // SHORT
b.writeUInt32LE(1, 20); // un valor
b.writeUInt16LE(orientation, 24);
b.writeUInt32LE(0, 28); // no hay mas IFDs
return b;
};
const stripJpeg = (buf) => {
if (buf.length < 4 || buf[0] !== 0xFF || buf[1] !== 0xD8) return null;
const out = [buf.subarray(0, 2)];
let orientation = 0;
let i = 2;
while (i + 4 <= buf.length) {
if (buf[i] !== 0xFF) return null; // no es un JPEG sano: no tocarlo
const marker = buf[i + 1];
if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; }
if (marker === 0xDA) { out.push(buf.subarray(i)); break; } // datos comprimidos: hasta el final
const len = buf.readUInt16BE(i + 2);
if (len < 2 || i + 2 + len > buf.length) return null;
const seg = buf.subarray(i + 4, i + 2 + len);
if (marker === 0xE1 && !orientation) orientation = jpegOrientation(seg);
if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len));
i += 2 + len;
}
if (orientation > 1) {
const exif = exifOnlyOrientation(orientation);
const head = Buffer.alloc(4);
head.writeUInt16BE(0xFFE1, 0);
head.writeUInt16BE(exif.length + 2, 2);
out.splice(1, 0, head, exif);
}
return Buffer.concat(out);
};
// PNG: cadena de bloques <longitud:4> <tipo:4> <datos> <crc:4>.
const PNG_DROP = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME", "dSIG"]);
const stripPng = (buf) => {
const SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
if (buf.length < 8 || !buf.subarray(0, 8).equals(SIG)) return null;
const out = [buf.subarray(0, 8)];
let i = 8;
while (i + 8 <= buf.length) {
const len = buf.readUInt32BE(i);
const type = buf.toString("latin1", i + 4, i + 8);
const end = i + 12 + len;
if (end > buf.length) return null;
if (!PNG_DROP.has(type)) out.push(buf.subarray(i, end));
i = end;
if (type === "IEND") break;
}
return Buffer.concat(out);
};
// WebP: contenedor RIFF con bloques <tipo:4> <longitud:4> <datos> (+1 de relleno
// si la longitud es impar).
const stripWebp = (buf) => {
if (buf.length < 12) return null;
if (buf.toString("latin1", 0, 4) !== "RIFF" || buf.toString("latin1", 8, 12) !== "WEBP") return null;
const out = [];
let i = 12;
while (i + 8 <= buf.length) {
const type = buf.toString("latin1", i, i + 4);
const len = buf.readUInt32LE(i + 4);
const end = i + 8 + len + (len % 2);
if (end > buf.length) return null;
if (type !== "EXIF" && type !== "XMP ") out.push(buf.subarray(i, end));
i = end;
}
const body = Buffer.concat(out);
const head = Buffer.alloc(12);
head.write("RIFF", 0, "latin1");
head.writeUInt32LE(body.length + 4, 4);
head.write("WEBP", 8, "latin1");
return Buffer.concat([head, body]);
};
/** Limpia segun el formato; si algo no cuadra, devuelve la imagen tal cual. */
const stripMetadataPure = (buffer) => {
try { try {
return await sharp(buffer).rotate().toBuffer(); const out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer);
return out && out.length ? out : buffer;
} catch { } catch {
return buffer; return buffer;
} }
}; };
const stripImageMetadata = async (buffer) => {
// Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
// orientacion girando los pixeles de verdad.
if (typeof sharp === "function") {
try {
return await sharp(buffer).rotate().toBuffer();
} catch {
return stripMetadataPure(buffer);
}
}
return stripMetadataPure(buffer);
};
const PDF_METADATA_KEYS = [ const PDF_METADATA_KEYS = [
'/Title', '/Author', '/Subject', '/Keywords', '/Title', '/Author', '/Subject', '/Keywords',
'/Creator', '/Producer', '/CreationDate', '/ModDate' '/Creator', '/Producer', '/CreationDate', '/ModDate'
@ -58,25 +209,30 @@ class FileTooLargeError extends Error {
} }
} }
const handleBlobUpload = async function (ctx, fileFieldName) { const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac'];
if (!ctx.request.files || !ctx.request.files[fileFieldName]) { const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex'];
return null;
}
const blobUpload = ctx.request.files[fileFieldName]; const oggMime = (buffer) => {
if (!blobUpload) return null; const head = buffer.slice(0, 65536);
for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg';
for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg';
return 'video/ogg';
};
const storeUploadedFile = async function (blobUpload) {
if (!blobUpload || !blobUpload.filepath) return null;
let data = await promisesFs.readFile(blobUpload.filepath); let data = await promisesFs.readFile(blobUpload.filepath);
if (data.length === 0) return null; if (data.length === 0) return null;
if (data.length > MAX_BLOB_SIZE) { if (data.length > MAX_BLOB_SIZE) {
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length); throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length);
} }
const EXTENSION_MIME_MAP = { const EXTENSION_MIME_MAP = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg', '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus', '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
@ -93,9 +249,12 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
blob.mime = null; blob.mime = null;
} }
if (!blob.mime && blob.name) { if (blob.mime === 'application/ogg') blob.mime = oggMime(data);
const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']);
if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) {
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase(); const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream'; blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream';
} }
if (!blob.mime) { if (!blob.mime) {
@ -126,6 +285,25 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
return `\n[${blob.name}](${blob.id})`; return `\n[${blob.name}](${blob.id})`;
}; };
const handleBlobUpload = async function (ctx, fileFieldName) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
const entry = ctx.request.files[fileFieldName];
const first = Array.isArray(entry) ? entry[0] : entry;
return storeUploadedFile(first);
};
const handleBlobUploads = async function (ctx, fileFieldName, max = 8) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return [];
const entry = ctx.request.files[fileFieldName];
const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max));
const out = [];
for (const file of files) {
const stored = await storeUploadedFile(file);
if (stored) out.push(stored);
}
return out;
};
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) { function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let done = false; let done = false;
@ -229,6 +407,8 @@ const serveBlob = async function (ctx) {
} }
} }
if (mime === 'application/ogg') mime = oggMime(buffer);
const isSvg = mime === 'image/svg+xml'; const isSvg = mime === 'image/svg+xml';
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : ''; const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
const safeRaw = String(raw).replace(/["\r\n\\]/g, ''); const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
@ -275,5 +455,5 @@ const serveBlob = async function (ctx) {
} }
}; };
module.exports = { handleBlobUpload, serveBlob, FileTooLargeError }; module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, oggMime, FileTooLargeError };

280
src/backend/contentPdf.js Normal file
View file

@ -0,0 +1,280 @@
const { buildDocumentPdf } = require('./pdfDocument');
const fmtDate = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
};
const fmtDay = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10);
};
const asList = v => (Array.isArray(v) ? v : []);
const txt = v => (v == null ? '' : String(v));
const humanLabel = (key) => String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, c => c.toUpperCase());
const pushMeta = (out, item) => {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'METADATA' });
if (item.id) out.push({ kind: 'kv', label: 'Content ID', value: item.id });
const author = item.author || item.organizer || item.createdBy || item.from || '';
if (author) out.push({ kind: 'kv', label: 'Author', value: author });
if (item.createdAt) out.push({ kind: 'kv', label: 'Created At', value: fmtDate(item.createdAt) });
if (item.updatedAt) out.push({ kind: 'kv', label: 'Updated At', value: fmtDate(item.updatedAt) });
};
const pushOpinions = (out, item) => {
const op = item.opinions && typeof item.opinions === 'object' ? item.opinions : {};
const entries = Object.entries(op).filter(([, n]) => Number(n) > 0);
if (!entries.length) return;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'OPINIONS' });
for (const [cat, n] of entries) out.push({ kind: 'kv', label: humanLabel(cat), value: String(n) });
};
const pushTags = (out, item) => {
const tags = asList(item.tags).filter(Boolean);
if (tags.length) out.push({ kind: 'kv', label: 'Tags', value: tags.join(', ') });
};
const reportSections = (report) => {
const out = [];
out.push({ kind: 'title', text: `Report: ${txt(report.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CLASSIFICATION' });
out.push({ kind: 'kv', label: 'Category', value: txt(report.category).toUpperCase() });
out.push({ kind: 'kv', label: 'Severity', value: txt(report.severity).toUpperCase() });
out.push({ kind: 'kv', label: 'Status', value: txt(report.status).toUpperCase() });
pushTags(out, report);
if (txt(report.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: report.description });
}
const tpl = report.template && typeof report.template === 'object' ? report.template : {};
const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim());
if (tplEntries.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DETAILS' });
for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v });
}
const confirmations = asList(report.confirmations);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CONFIRMATIONS' });
out.push({ kind: 'kv', label: 'Confirmed', value: String(confirmations.length) });
for (const c of confirmations) out.push({ kind: 'kv', label: 'Confirmed by', value: c });
pushOpinions(out, report);
pushMeta(out, report);
return out;
};
const voteSections = (vote) => {
const out = [];
out.push({ kind: 'title', text: `Votation: ${txt(vote.question) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'TERMS' });
out.push({ kind: 'kv', label: 'Status', value: txt(vote.status).toUpperCase() });
if (vote.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(vote.deadline) });
pushTags(out, vote);
const votes = vote.votes && typeof vote.votes === 'object' ? vote.votes : {};
const total = Number(vote.totalVotes) || asList(vote.voters).length;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'RESULTS' });
out.push({ kind: 'kv', label: 'Total votes', value: String(total) });
for (const [choice, n] of Object.entries(votes)) {
const count = Number(n) || 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
out.push({ kind: 'kv', label: choice, value: `${count} (${pct}%)` });
}
pushOpinions(out, vote);
pushMeta(out, vote);
return out;
};
const eventSections = (event) => {
const out = [];
out.push({ kind: 'title', text: `Event: ${txt(event.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Date', value: fmtDate(event.date) });
out.push({ kind: 'kv', label: 'Status', value: txt(event.status).toUpperCase() });
if (event.location) out.push({ kind: 'kv', label: 'Location', value: event.location });
if (event.mapUrl) out.push({ kind: 'kv', label: 'Map', value: event.mapUrl });
if (Number(event.price) > 0) out.push({ kind: 'kv', label: 'Price', value: `${Number(event.price)} ECO` });
out.push({ kind: 'kv', label: 'Privacy', value: txt(event.isPublic).toUpperCase() });
if (event.url) out.push({ kind: 'kv', label: 'Url', value: event.url });
pushTags(out, event);
if (txt(event.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: event.description });
}
const attendees = asList(event.attendees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ATTENDEES' });
out.push({ kind: 'kv', label: 'Total', value: String(attendees.length) });
for (const a of attendees) out.push({ kind: 'kv', label: 'Attendee', value: a });
pushOpinions(out, event);
pushMeta(out, event);
return out;
};
const taskSections = (task) => {
const out = [];
out.push({ kind: 'title', text: `Task: ${txt(task.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Status', value: txt(task.status).toUpperCase() });
out.push({ kind: 'kv', label: 'Priority', value: txt(task.priority).toUpperCase() });
if (task.startTime) out.push({ kind: 'kv', label: 'Starts', value: fmtDate(task.startTime) });
if (task.endTime) out.push({ kind: 'kv', label: 'Ends', value: fmtDate(task.endTime) });
if (task.location) out.push({ kind: 'kv', label: 'Location', value: task.location });
out.push({ kind: 'kv', label: 'Privacy', value: txt(task.isPublic).toUpperCase() });
pushTags(out, task);
if (txt(task.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: task.description });
}
const assignees = asList(task.assignees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ASSIGNEES' });
out.push({ kind: 'kv', label: 'Total', value: String(assignees.length) });
for (const a of assignees) out.push({ kind: 'kv', label: 'Assignee', value: a });
pushOpinions(out, task);
pushMeta(out, task);
return out;
};
const calendarSections = (calendar, extra = {}) => {
const dates = asList(extra.dates);
const notesByDate = extra.notesByDate && typeof extra.notesByDate === 'object' ? extra.notesByDate : {};
const out = [];
out.push({ kind: 'title', text: `Calendar: ${txt(calendar.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SUMMARY' });
out.push({ kind: 'kv', label: 'Status', value: calendar.isClosed ? 'CLOSED' : txt(calendar.status).toUpperCase() });
if (calendar.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(calendar.deadline) });
if (calendar.mapUrl) out.push({ kind: 'kv', label: 'Map', value: calendar.mapUrl });
out.push({ kind: 'kv', label: 'Participants', value: String(asList(calendar.participants).length) });
out.push({ kind: 'kv', label: 'Dates', value: String(dates.length) });
const noteTotal = Object.values(notesByDate).reduce((n, arr) => n + asList(arr).length, 0);
out.push({ kind: 'kv', label: 'Notes', value: String(noteTotal) });
pushTags(out, calendar);
const participants = asList(calendar.participants);
if (participants.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PARTICIPANTS' });
for (const p of participants) out.push({ kind: 'kv', label: 'Participant', value: p });
}
if (dates.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DATES' });
for (const d of dates) {
const day = fmtDay(d && d.date) || txt(d && d.date);
out.push({ kind: 'kv', label: day, value: txt(d && d.label) });
for (const note of asList(notesByDate[d && d.key])) {
const noteText = txt(note && note.text);
if (noteText) out.push({ kind: 'text', text: ` - ${noteText}` });
}
}
}
pushMeta(out, calendar);
return out;
};
const cvSections = (cv) => {
const out = [];
out.push({ kind: 'title', text: `Curriculum: ${txt(cv.name) || txt(cv.author) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PROFILE' });
if (cv.location) out.push({ kind: 'kv', label: 'Location', value: cv.location });
if (cv.status) out.push({ kind: 'kv', label: 'Status', value: txt(cv.status).toUpperCase() });
if (cv.preferences) out.push({ kind: 'kv', label: 'Preferences', value: txt(cv.preferences).toUpperCase() });
if (cv.languages) out.push({ kind: 'kv', label: 'Languages', value: cv.languages });
if (txt(cv.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: cv.description });
}
const blocks = [
['PERSONAL', cv.personalExperiences, cv.personalSkills],
['EDUCATION', cv.educationExperiences, cv.educationalSkills],
['PROFESSIONAL', cv.professionalExperiences, cv.professionalSkills],
['OASIS', cv.oasisExperiences, cv.oasisSkills]
];
for (const [heading, experiences, skills] of blocks) {
const skillList = asList(skills).filter(Boolean);
const body = txt(experiences).trim();
if (!body && !skillList.length) continue;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: heading });
if (body) out.push({ kind: 'text', text: body });
if (skillList.length) out.push({ kind: 'kv', label: 'Skills', value: skillList.join(', ') });
}
pushMeta(out, cv);
return out;
};
const BUILDERS = {
reports: { title: 'OASIS - Report', sections: reportSections, name: item => item.title },
votes: { title: 'OASIS - Votation', sections: voteSections, name: item => item.question },
events: { title: 'OASIS - Event', sections: eventSections, name: item => item.title },
tasks: { title: 'OASIS - Task', sections: taskSections, name: item => item.title },
calendars: { title: 'OASIS - Calendar', sections: calendarSections, name: item => item.title },
cv: { title: 'OASIS - Curriculum', sections: cvSections, name: item => item.name || item.author }
};
const isSupported = kind => Object.prototype.hasOwnProperty.call(BUILDERS, kind);
const pdfFilename = (kind, item) => {
const b = BUILDERS[kind];
const raw = b ? txt(b.name(item || {})) : '';
const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
return `oasis-${kind}-${slug || 'document'}.pdf`;
};
const buildContentPdf = (kind, item, extra = {}, viewerId = null) => {
const b = BUILDERS[kind];
if (!b) throw new Error('Unsupported pdf kind');
return buildDocumentPdf({
title: b.title,
issuedToLabel: 'Issued to',
issuedTo: viewerId || null,
sections: b.sections(item || {}, extra)
});
};
module.exports = { buildContentPdf, pdfFilename, isSupported };

View file

@ -1,22 +1,40 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const FILE = path.join(__dirname, "../configs/media-favorites.json"); const TEMPLATE = path.join(__dirname, "../configs/content_favorites.json");
const storePath = () => {
try {
return require("../server/ssb_config").statePath("content_favorites.json") || TEMPLATE;
} catch (_) { return TEMPLATE; }
};
const DEFAULT = { const DEFAULT = {
audios: [], audios: [],
blogs: [],
bookmarks: [], bookmarks: [],
calendars: [], calendars: [],
chats: [], chats: [],
documents: [], documents: [],
events: [],
forum: [],
housing: [],
images: [], images: [],
jobs: [],
logs: [],
maps: [], maps: [],
pads: [], pads: [],
polls: [],
projects: [],
reports: [],
shops: [], shops: [],
market: [], market: [],
shopProducts: [], shopProducts: [],
tasks: [],
torrents: [], torrents: [],
videos: [] transfers: [],
videos: [],
votes: []
}; };
const safeArr = (v) => (Array.isArray(v) ? v : []); const safeArr = (v) => (Array.isArray(v) ? v : []);
@ -39,32 +57,32 @@ const normalize = (raw) => {
const ensureFile = async () => { const ensureFile = async () => {
try { try {
await fs.promises.access(FILE); await fs.promises.access(storePath());
} catch (e) { } catch (e) {
const dir = path.dirname(FILE); const dir = path.dirname(storePath());
await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8"); await fs.promises.writeFile(storePath(), JSON.stringify(DEFAULT, null, 2), "utf8");
} }
}; };
const readAll = async () => { const readAll = async () => {
await ensureFile(); await ensureFile();
try { try {
const txt = await fs.promises.readFile(FILE, "utf8"); const txt = await fs.promises.readFile(storePath(), "utf8");
return normalize(JSON.parse(txt || "{}")); return normalize(JSON.parse(txt || "{}"));
} catch (e) { } catch (e) {
const fixed = normalize(DEFAULT); const fixed = normalize(DEFAULT);
await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8"); await fs.promises.writeFile(storePath(), JSON.stringify(fixed, null, 2), "utf8");
return fixed; return fixed;
} }
}; };
const writeAll = async (data) => { const writeAll = async (data) => {
const dir = path.dirname(FILE); const dir = path.dirname(storePath());
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`); const tmp = path.join(dir, `.content_favorites.${process.pid}.${Date.now()}.tmp`);
const txt = JSON.stringify(normalize(data), null, 2); const txt = JSON.stringify(normalize(data), null, 2);
await fs.promises.writeFile(tmp, txt, "utf8"); await fs.promises.writeFile(tmp, txt, "utf8");
await fs.promises.rename(tmp, FILE); await fs.promises.rename(tmp, storePath());
}; };
const assertKind = (kind) => { const assertKind = (kind) => {
@ -88,6 +106,15 @@ exports.getFavoriteSet = async (kind) => {
return new Set(safeArr(data[k]).map(String)); return new Set(safeArr(data[k]).map(String));
}; };
exports.getFavoriteIndex = async () => {
const data = await readAll();
const index = new Map();
for (const kind of Object.keys(DEFAULT)) {
for (const id of safeArr(data[kind])) index.set(String(id), kind);
}
return index;
};
exports.addFavorite = async (kind, id) => exports.addFavorite = async (kind, id) =>
withLock(async () => { withLock(async () => {
const k = assertKind(kind); const k = assertKind(kind);
@ -123,4 +150,4 @@ exports.removeFromFavorites = async (...args) => {
const id = idFromArgs(args); const id = idFromArgs(args);
return exports.removeFavorite(kind, id); return exports.removeFavorite(kind, id);
}; };
exports.storePath = storePath;

View file

@ -0,0 +1,237 @@
"use strict";
// Rutas del modulo Karvan (salas efimeras en RAM, mensajes temporales y
// senalizacion WebRTC), mas las dos pantallas que la rama anade a los ajustes.
//
// Upstream pone todas sus rutas en backend.js: 625 en una sola cadena fluida.
// Estas viven aparte porque ese fichero se reescribe entero en cada release
// (+833/-286 en la 0.9.2, +266/-31 en la 0.9.1), asi que una ruta anadida en
// medio entra en conflicto cada vez. Se montan en un router propio insertado
// antes del de upstream; lo que no casa cae a next() y sigue su curso.
//
// El nombre sigue a la familia del modulo: karvan_model.js, karvan_view.js,
// karvan.js, karvan.css.
//
// Las dependencias se inyectan en lugar de reconstruirse: cooler abre la conexion
// al sbot y duplicarla abriria un segundo cliente muxrpc.
const koaRouter = require("../server/node_modules/@koa/router");
const { koaBody } = require("../server/node_modules/koa-body");
const { stripDangerousTags } = require("./sanitizeHtml");
const { buildIceServers } = require("./turnCredentials");
const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC)
const { karvanView, karvanRoomView } = require("../views/karvan_view");
module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId,
getConfig, saveConfig, isLoopbackRequest, sendErrorPage }) => {
// FORK_IA_UX Fase 2 (cross-device): relay de MENSAJES de Karvan por privados SSB. Funciona vía un pub
// (el privado se replica al otro móvil); los SIGNAL de WebRTC NO se relayan (serían demasiados/lentos por el log).
// No toca el arranque del SSB. Ingesta por stream EN VIVO (no polling) → eficiente.
const karvanSeenRelay = new Set(); // mids ya inyectados (evita duplicados/bucles)
let karvanRelaySub = false; // ¿suscriptor del log en vivo montado?
async function karvanPublishRelay(roomId, mid, from, text) {
try {
const feeds = karvanModel.getRemoteFeeds(roomId);
if (!feeds.length) return;
const ssbClient = await cooler.open();
const recps = [...new Set([ssbClient.id, ...feeds])].slice(0, 8);
await new Promise((res, rej) => ssbClient.private.publish(
{ type: 'karvan-relay', roomId, mid: String(mid || '').slice(0, 60), from: String(from || '?').slice(0, 80), text: String(text || '').slice(0, 2000), private: true },
recps, (e, m) => e ? rej(e) : res(m)));
} catch (e) {}
}
async function karvanStartRelay() {
if (karvanRelaySub) return;
karvanRelaySub = true;
try {
const ssbClient = await cooler.open();
const me = ssbClient.id;
pull(
ssbClient.createLogStream({ old: false, live: true }),
pull.drain((m) => {
try {
if (!m || !m.value) return;
let dec; try { dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.timestamp }); } catch (_) { return; }
const c = dec && dec.value && dec.value.content;
if (!c || c.type !== 'karvan-relay' || !c.roomId || !c.mid) return;
if (dec.value.author === me) return; // no reinyectar lo propio (evita bucle A→B→A)
if (karvanSeenRelay.has(c.mid)) return;
if (!karvanModel.getRoom(c.roomId)) return; // solo si tenemos esa sala localmente
karvanSeenRelay.add(c.mid);
if (karvanSeenRelay.size > 5000) karvanSeenRelay.clear();
karvanModel.postMessage(c.roomId, { from: c.from, text: c.text, mid: c.mid });
} catch (e) {}
})
);
} catch (e) { karvanRelaySub = false; }
}
// ¿quién me invitó a esta sala? (para registrar su feed en el espejo local) — reutiliza el lector de privados
async function karvanInviteAuthor(roomId) {
try {
const msgs = await pmModel.listAllPrivate();
for (const m of (msgs || [])) {
const c = m && m.value && m.value.content;
if (c && c.subject === 'KARVAN_INVITE' && typeof c.text === 'string' && c.text.indexOf('/karvan/' + roomId) !== -1) return m.value.author;
}
} catch (e) {}
return null;
}
const forkRouter = new koaRouter();
forkRouter
// Configuracion ICE para las llamadas. Solo loopback: la credencial TURN va
// firmada con el secreto del nodo y no debe salir de la maquina.
.get('/karvan/ice', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = { error: 'forbidden' }; return; }
let who = ''; try { who = getViewerId() || ''; } catch (e) {}
let rtc = null; try { rtc = (getConfig() || {}).rtc || null; } catch (e) {}
ctx.body = buildIceServers(rtc, who);
})
// ===== FORK_IA_UX: módulo KARVAN (mensajes temporales RAM + WebRTC data-channel) =====
.get('/karvan', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
ctx.body = karvanView(karvanModel.listRooms(), { selfId });
})
.post('/karvan/create', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const room = karvanModel.createRoom({
title: stripDangerousTags(String(b.title || '').trim()).slice(0, 80),
ttlMinutes: b.ttl,
});
ctx.redirect('/karvan/' + room.id);
})
.get('/karvan/:id', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
let room = karvanModel.getRoom(ctx.params.id);
// unirse por enlace/invitación crea un espejo local, pero SOLO en una navegación real (documento):
// un <img>/subrecurso de una web maliciosa NO debe poder crear/expulsar salas por GET (CSRF). El navegador
// controla sec-fetch-dest/accept, así que son señales fiables.
let justAdopted = false;
if (!room) {
const isNav = ctx.get('sec-fetch-dest') === 'document' || (ctx.get('accept') || '').includes('text/html');
if (isNav) { room = karvanModel.adoptRoom({ id: ctx.params.id }); justAdopted = !!room; }
}
if (!room) { ctx.redirect('/karvan'); return; }
if (justAdopted) { // al unirte por enlace, registra el feed de quien te invitó → relay cross-device
const author = await karvanInviteAuthor(ctx.params.id);
if (author) { karvanModel.addRemoteFeed(ctx.params.id, author); karvanStartRelay(); }
}
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msgs = karvanModel.listMessages(room.id, 0) || [];
ctx.body = karvanRoomView(room, msgs, { selfId, invite: ctx.query.invite || '' });
})
.post('/karvan/:id/invite', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const id = ctx.params.id;
const room = karvanModel.getRoom(id);
if (!room) { ctx.redirect('/karvan'); return; }
const to = String((ctx.request.body || {}).to || '').trim();
if (!/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(to)) { ctx.redirect('/karvan/' + id + '?invite=bad'); return; }
try {
// reutiliza el patrón de invitación por mensaje privado de Oasis (como el módulo industry):
// le llega al INBOX del contacto con un enlace a la sala. Sin código SSB nuevo.
await pmModel.sendMessage([to], 'KARVAN_INVITE',
'You have been invited to an ephemeral Karvan room' + (room.title ? ' ("' + room.title + '")' : '') + ' -> /karvan/' + id);
karvanModel.addRemoteFeed(id, to); karvanStartRelay(); // relay cross-device hacia el invitado
ctx.redirect('/karvan/' + id + '?invite=ok');
} catch (e) { ctx.redirect('/karvan/' + id + '?invite=fail'); }
})
.post('/karvan/:id/msg', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msg = karvanModel.postMessage(ctx.params.id, {
from: String(b.from || selfId || '?').slice(0, 80),
text: stripDangerousTags(String(b.text || '')).slice(0, 2000),
mid: b.mid,
});
if (!msg) { ctx.status = 404; ctx.body = { error: 'no room' }; return; }
karvanPublishRelay(ctx.params.id, msg.mid || (msg.from + '-' + msg.ts + '-' + msg.seq), msg.from, msg.text); // → feeds remotos (si hay)
const wantsJson = (ctx.get('accept') || '').indexOf('application/json') !== -1
|| (ctx.get('content-type') || '').indexOf('application/json') !== -1;
if (wantsJson) ctx.body = { ok: true, seq: msg.seq };
else ctx.redirect('/karvan/' + ctx.params.id);
})
.get('/karvan/:id/msgs', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const msgs = karvanModel.listMessages(ctx.params.id, ctx.query.since || 0);
ctx.body = { messages: msgs || [] };
})
.post('/karvan/:id/signal', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
karvanModel.postSignal(ctx.params.id, { from: b.from, to: b.to, payload: b.payload });
ctx.body = { ok: true };
})
.get('/karvan/:id/signal', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const r = karvanModel.getSignals(ctx.params.id, { forId: ctx.query.for || '', since: ctx.query.since || 0 });
ctx.body = r || { signals: [], members: [] };
})
.get("/settings/bottombar", async (ctx) => {
ctx.body = require("../views/main_views").bottomBarPickerView();
})
.post("/settings/bottombar", koaBody(), async (ctx) => {
const cfg = getConfig();
const MAX = 4;
const body = ctx.request.body || {};
const action = String(body.action || '').trim();
const key = String(body.key || '').trim();
const catalog = require("../views/main_views").PINNABLE_MODULES || [];
let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : [];
if (action === 'add') {
if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key);
} else if (action === 'remove') {
pins = pins.filter(k => k !== key);
} else if (action === 'clear') {
pins = [];
}
cfg.bottomBarPins = pins.slice(0, MAX);
saveConfig(cfg);
ctx.redirect('/settings/bottombar');
})
.post("/settings/identity/use", koaBody(), async (ctx) => {
// solo anota cual sera la del proximo arranque: cambiarla en caliente dejaria
// media aplicacion hablando con el sbot anterior
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name) || !accounts.list().includes(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/create", koaBody(), async (ctx) => {
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
// no se genera el secret aqui: lo crea ssb-config al arrancar con esa cuenta,
// que es quien sabe como hacerlo. Basta con anotarla y reiniciar.
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/restart", koaBody(), async (ctx) => {
// Solo en movil: alli el backend vive en un servicio en primer plano que
// sobrevive a cerrar la app, asi que la unica forma de arrancar con otra
// identidad es terminar el proceso. nodejs-mobile no permite volver a
// levantar el runtime dentro del mismo proceso: hay que salir y que la app
// lo relance al abrirse.
if (process.env.OASIS_MOBILE !== '1') { ctx.status = 404; ctx.body = ''; return; }
ctx.body = require("../views/main_views").template(
require('../views/main_views').i18n.identitiesTitle,
require("../server/node_modules/hyperaxe").section(
require("../server/node_modules/hyperaxe").p(require('../views/main_views').i18n.identitiesRestarting)
)
);
// se responde primero: si se sale antes, el navegador se queda sin pagina
setTimeout(() => process.exit(0), 500);
});
return forkRouter.routes();
};

View file

@ -3,7 +3,7 @@ const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg'); const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); const { escapePdf } = require('./pdfDocument');
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g; const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
@ -94,8 +94,8 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
const catalogId = addObj(null); const catalogId = addObj(null);
const pagesId = addObj(null); const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>'); const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>'); const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null; let logoXObjId = null;
if (logoBuf && logoDims) { if (logoBuf && logoDims) {
@ -156,7 +156,7 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`); parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n'); const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`; const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream); const cid = addObj(stream);
contentIds.push(cid); contentIds.push(cid);
const pid = addObj(null); const pid = addObj(null);

199
src/backend/pdfDocument.js Normal file
View file

@ -0,0 +1,199 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const WIN_ANSI_HIGH = {
'\u20AC': '\x80', '\u201A': '\x82', '\u0192': '\x83', '\u201E': '\x84',
'\u2026': '\x85', '\u2020': '\x86', '\u2021': '\x87', '\u02C6': '\x88',
'\u2030': '\x89', '\u0160': '\x8A', '\u2039': '\x8B', '\u0152': '\x8C',
'\u017D': '\x8E', '\u2018': '\x91', '\u2019': '\x92', '\u201C': '\x93',
'\u201D': '\x94', '\u2022': '\x95', '\u2013': '\x96', '\u2014': '\x97',
'\u02DC': '\x98', '\u2122': '\x99', '\u0161': '\x9A', '\u203A': '\x9B',
'\u0153': '\x9C', '\u017E': '\x9E', '\u0178': '\x9F'
};
const escapePdf = s => String(s == null ? '' : s)
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\u0152\u0153\u0160\u0161\u0178\u017D\u017E\u0192\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC\u2122]/g,
(c) => WIN_ANSI_HIGH[c] || '?')
.replace(/[^\x20-\xFF\x80-\x9F]/g, '?')
.replace(/\\/g, '\\\\')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt == null ? '' : txt).split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
const flattenSections = (sections) => {
const lines = [];
for (const s of Array.isArray(sections) ? sections : []) {
if (!s) continue;
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value == null ? '' : s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else if (s.kind === 'text') {
for (const w of wrap(s.text, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push({ kind: s.kind, text: s.text });
}
}
return lines;
};
function buildDocumentPdf({ title, issuedToLabel, issuedTo, sections } = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 24;
const bodyBottom = footerH + 10;
const lineH = 14;
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const lines = flattenSections(sections);
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
const pages = [];
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
if (!pages.length) pages.push([]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf(title || 'OASIS')}) Tj\nET`);
if (issuedTo) {
const prefix = `${issuedToLabel || 'Issued to'}: `;
const prefixW = prefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(issuedTo))}) Tj\nET`);
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
for (const ln of pg) {
if (ln.kind === 'title') {
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'subtitle') {
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'section') {
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
} else if (ln.kind === 'kv') {
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildDocumentPdf, escapePdf, wrap };

View file

@ -1,3 +1,7 @@
const fs = require('fs');
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe); const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => { const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
@ -6,4 +10,41 @@ const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship
return isMutual(relationship); return isMutual(relationship);
}; };
module.exports = { isRecipientAllowed, isMutual }; const announceSeenPath = () => {
try { return require('../server/ssb_config').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
};
const readAnnounceSeen = () => {
const file = announceSeenPath();
if (!file) return {};
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) { return {}; }
};
const writeAnnounceSeen = (state) => {
const file = announceSeenPath();
if (!file) return false;
try { fs.writeFileSync(file, JSON.stringify(state || {})); return true; } catch (_) { return false; }
};
const everAnnounced = (announced, subject) => {
for (const entry of (announced || [])) {
if (String(entry).startsWith(`${subject}|`)) return true;
}
return false;
};
const decideAnnouncement = ({ subject, ref, announced = new Set(), seen = {} } = {}) => {
if (!subject || !ref) return { send: false, remember: false };
const key = String(ref);
if (announced.has(`${subject}|${key}`)) return { send: false, remember: false };
if (seen[subject] === key) return { send: false, remember: false };
if (!everAnnounced(announced, subject) && seen[subject] === undefined) {
return { send: false, remember: true };
}
return { send: true, remember: true };
};
module.exports = { isRecipientAllowed, isMutual, announceSeenPath, readAnnounceSeen, writeAnnounceSeen, decideAnnouncement };

View file

@ -3,7 +3,7 @@ const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg'); const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); const { escapePdf } = require('./pdfDocument');
const wrap = (txt, max = 82) => { const wrap = (txt, max = 82) => {
const out = []; const out = [];
@ -131,8 +131,8 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
const catalogId = addObj(null); const catalogId = addObj(null);
const pagesId = addObj(null); const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>'); const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>'); const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null; let logoXObjId = null;
if (logoBuf && logoDims) { if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB'; const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
@ -185,7 +185,7 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`); parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n'); const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`; const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream); const cid = addObj(stream);
contentIds.push(cid); contentIds.push(cid);
const pid = addObj(null); const pid = addObj(null);

View file

@ -0,0 +1,50 @@
"use strict";
// Credenciales efimeras para un TURN propio (coturn con use-auth-secret).
//
// Es el mecanismo REST de coturn: el servidor no guarda usuarios, valida que
// credential == base64(HMAC-SHA1(static-auth-secret, username))
// y que el timestamp del username no haya caducado. Asi las credenciales se
// generan sin hablar con coturn y caducan solas.
//
// Portado del turn.js de karvan-web, cambiando sessionId por el feed id: lo que
// identifica a quien llama en Oasis es su feed, no una sesion anonima.
const crypto = require("crypto");
const DEFAULT_TTL = 3600;
// username = <caduca en unix> ":" <identificador>
function mintTurnCredentials({ urls, secret, ttlSec = DEFAULT_TTL }, who, now = Date.now()) {
if (!secret || !Array.isArray(urls) || !urls.length) return null;
const expiry = Math.floor(now / 1000) + ttlSec;
const username = expiry + ":" + String(who || "anon").slice(0, 64);
const credential = crypto.createHmac("sha1", secret).update(username).digest("base64");
return { urls, username, credential, ttl: ttlSec, expiry };
}
// Lee la configuracion de rtc y devuelve lo que el navegador espera en iceServers.
// Formas admitidas en oasis-config.json:
// "rtc": { "stun": ["stun:mi.pub:3478"] }
// "rtc": { "turn": { "urls": [...], "secret": "...", "ttlSec": 3600 }, "relayOnly": true }
// "rtc": { "turn": { "urls": [...], "username": "...", "credential": "..." } } (estatico)
function buildIceServers(rtc, who, now = Date.now()) {
const out = [];
if (!rtc || typeof rtc !== "object") return { iceServers: out, relayOnly: false };
if (Array.isArray(rtc.stun)) {
for (const u of rtc.stun) if (typeof u === "string" && u) out.push({ urls: u });
}
const t = rtc.turn;
if (t && Array.isArray(t.urls) && t.urls.length) {
if (t.secret) {
const c = mintTurnCredentials(t, who, now);
if (c) out.push({ urls: c.urls, username: c.username, credential: c.credential });
} else if (t.username && t.credential) {
out.push({ urls: t.urls, username: t.username, credential: t.credential });
}
}
return { iceServers: out, relayOnly: !!rtc.relayOnly && out.some(s => s.credential) };
}
module.exports = { mintTurnCredentials, buildIceServers };

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,103 @@
/* Estilos del modulo Karvan (salas efimeras y llamadas).
*
* Viven aparte del tema para que el modulo se vea igual en cualquier tema y
* tambien en escritorio, donde OasisMobile.css no se carga. Mismo patron que
* highlight.css, que tampoco pertenece a ningun tema concreto.
*/
/* --- Reply a mensaje en chats (server-side ?replyTo=, sin JS) --- */
.chat-message-meta .chat-reply-btn { margin-left: auto; padding: 0 4px; color: #FFD700; text-decoration: none; font-size: 15px; opacity: .85; }
.chat-message-self .chat-message-meta .chat-reply-btn { margin-left: 8px; }
.chat-quote {
display: block; border-left: 3px solid #FFD700; padding: 3px 8px; margin: 0 0 5px;
background: rgba(255,215,0,.08); border-radius: 6px; font-size: 12px; line-height: 1.3;
}
.chat-quote-author, .chat-quote-author a { color: #FFD700; font-weight: 700; margin-right: 6px; }
.chat-quote-text { color: #FFDD44; }
.chat-replying-to {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
border-left: 3px solid #FFD700; background: rgba(255,215,0,.10);
padding: 6px 10px; margin-bottom: 8px; border-radius: 8px; font-size: 12px;
}
.chat-replying-label { color: #FFD700; font-weight: 700; }
.chat-reply-cancel { margin-left: auto; color: #FFD700; text-decoration: none; font-weight: 700; font-size: 14px; }
/* ==========================================================================
FORK_IA_UX · Módulo KARVAN (mensajes temporales + WebRTC) chat estilo burbujas
========================================================================== */
.karvan-intro h2 { color: #FFD700; margin: 0 0 6px; }
.karvan-sub { color: #FFDD44; font-size: 13px; }
.karvan-create input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
.karvan-ttl { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 10px 0; }
.karvan-ttl-lbl { color: #FFDD44; font-size: 13px; }
.karvan-ttl-opt { display: inline-flex; align-items: center; gap: 4px; color: #FFD700; font-size: 13px; white-space: nowrap; }
.karvan-create button, .karvan-compose button { padding: 10px 16px; border: 1px solid #FFD700;
border-radius: 10px; background: #FFD700; color: #121212; font-weight: 700; cursor: pointer; }
.karvan-rooms { list-style: none; margin: 8px 0 0; padding: 0; }
.karvan-rooms li { margin: 0 0 8px; }
.karvan-room-item { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 12px 14px; background: #161616; border: 1px solid #333; border-radius: 12px;
color: #FFD700; text-decoration: none; min-height: 48px; box-sizing: border-box; }
.karvan-room-item .kr-title { font-weight: 600; }
.karvan-room-item .kr-meta { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-empty { color: #FFDD44; }
/* sala */
.karvan-room-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.karvan-back { color: #FFD700; text-decoration: none; font-weight: 600; }
.karvan-room-title { flex: 1 1 auto; color: #FFD700; font-weight: 700; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.karvan-expire { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-live { display: inline-flex; align-items: center; gap: 6px; color: #FFDD44; font-size: 12px; margin-bottom: 8px; }
.karvan-live .kl-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.karvan-live.on .kl-dot { background: #35d07f; }
.karvan-live.on .kl-txt { color: #35d07f; }
.karvan-msgs { list-style: none; margin: 0; padding: 6px 0; display: flex; flex-direction: column; gap: 6px;
max-height: 58vh; overflow-y: auto; }
.karvan-msg { max-width: 82%; align-self: flex-start; background: #1c1c1c; border: 1px solid #333;
border-radius: 14px; padding: 7px 11px; box-sizing: border-box; }
.karvan-msg-self { align-self: flex-end; background: #2a2412; border-color: #FFD700; }
.karvan-msg-live { border-left: 2px solid #35d07f; }
.karvan-msg .km-from { display: block; font-size: 10px; color: #FFDD44; margin-bottom: 2px; }
.karvan-msg .km-text { color: #FFD700; word-break: break-word; }
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
/* Fase 2: invitar a un contacto por feed id */
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
.karvan-invite-msg.ok { color: #35d07f; }
.karvan-invite-msg.err { color: #ff8a8a; }
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
/* el div{background:#222;padding:20px} de style.css y el div{border} del tema
convierten cada contenedor del panel en una caja: aqui se neutralizan */
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
background-color: transparent; box-shadow: none; border: 0; border-radius: 0; padding: 0; margin: 0;
}
.karvan-call { margin: 10px 0; }
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
.karvan-call.active .karvan-videos { display: grid; }
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
.kc-local { transform: scaleX(-1); }
.kc-remote { display: contents; }
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
.kc-btn-start { font-weight: 600; }
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }
/* ==========================================================================
Selector de identidades (ajustes)
========================================================================== */
.identity-list { list-style: none; margin: 10px 0; padding: 0; }
.identity-row { display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid #2a2a2a; }
.identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.identity-name { color: #FFD700; font-weight: 700; }
.identity-feed { color: #FFDD44; font-size: 12px; font-family: monospace; word-break: break-all; }
.identity-warn { color: #ff8a8a; font-size: 12px; }
.identity-active { color: #35d07f; font-size: 13px; font-weight: 600; white-space: nowrap; }

View file

@ -89,7 +89,6 @@ code {
} }
.search-input, .search-input,
.feed-search-input,
.activity-search-input { .activity-search-input {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
@ -510,28 +509,28 @@ h3 { font-size: 1em !important; }
} }
.forum-comment { .forum-comment {
margin-left: 0 !important; margin-left: 0;
padding-left: 8px !important; padding-left: 8px;
} }
.comment-body-row { .comment-body-row {
flex-direction: column !important; flex-direction: column;
} }
.comment-vote-col, .comment-vote-col,
.comment-text-col { .comment-text-col {
width: 100% !important; width: 100%;
} }
.forum-score-box, .forum-score-box,
.forum-score-form { .forum-score-form {
flex-direction: row !important; flex-direction: row;
justify-content: center !important; justify-content: center;
} }
.new-message-form textarea, .new-message-form textarea,
.comment-textarea { .comment-textarea {
width: 100% !important; width: 100%;
} }
[style*="grid-template-columns: repeat(6"] { [style*="grid-template-columns: repeat(6"] {
@ -914,3 +913,32 @@ a.create-button {
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; } .peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
.peers-list .block-info-table td .peer-key, .peers-list .block-info-table td .peer-key,
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; } .peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
.relationship-status .relationship-actions { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; }
.relationship-status .relationship-actions form,
.relationship-status .relationship-actions button { width: 100% !important; }
.profile-sensors-box { align-items: center !important; text-align: center !important; }
.profile-sensors-box > * { margin-left: auto !important; margin-right: auto !important; justify-content: center !important; }
.tribe-feed .refeed-column,
.tribe-feed-full .refeed-column { align-self: center !important; align-items: center !important; justify-content: center !important; }
.tribe-feed .card-footer,
.tribe-feed-full .card-footer { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.invite-qr-card { display: flex !important; justify-content: center !important; border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; margin: 0 auto !important; }
.invite-qr-img { display: block; margin: 0 auto; }
.stats-kpi { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.stats-kpi-label,
.stats-kpi-value { width: 100% !important; text-align: center !important; }
.main-column audio,
.main-column video { width: 100% !important; max-width: 100% !important; }
.main-column section:has(.chat-messages-list) { border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; }
.tribe-details:has(.chat-messages-list) { display: block !important; border: none !important; background: transparent !important; padding: 6px !important; margin: 0 !important; box-shadow: none !important; }
.chat-full-width { border: none !important; background: transparent !important; padding: 0 !important; }
.chat-messages-list { padding: 0 !important; }
.chat-message { max-width: 92% !important; }
.chat-message.chat-poll { max-width: 97% !important; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,385 @@
body {
background-color: #F9F9F9 !important;
color: #2C2C2C !important;
font-family: 'Roboto', sans-serif !important;
}
.main-column {
background-color: #FFFFFF !important;
border: 1px solid #E0E0E0 !important;
}
button, input[type="submit"], input[type="button"] {
background-color: #FF6F00 !important;
color: #FFFFFF !important;
border: none !important;
border-radius: 6px !important;
padding: 10px 20px !important;
cursor: pointer !important;
font-weight: 600 !important;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #FF8F00 !important;
}
input, textarea, select {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
border: 1px solid #E0E0E0 !important;
border-radius: 4px !important;
padding: 8px !important;
font-size: 16px !important;
}
a {
color: #007BFF !important;
text-decoration: none !important;
}
a:hover {
text-decoration: underline !important;
}
p {
color: black !important;
text-decoration: none !important;
}
.created-at, .about-time, .time {
font-size: 0.9rem;
color: black;
}
table {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
width: 100% !important;
border-collapse: collapse !important;
}
table th {
background-color: #F8F8F8 !important;
padding: 12px 15px !important;
text-align: left !important;
font-weight: 600 !important;
}
table tr:nth-child(even) {
background-color: #FAFAFA !important;
}
table td {
padding: 12px 15px !important;
}
.profile {
background-color: #FFFFFF !important;
padding: 20px !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
.profile .name {
color: #FF6F00 !important;
font-size: 20px !important;
font-weight: 700 !important;
}
.avatar {
border: 3px solid #FF6F00 !important;
border-radius: 50% !important;
width: 60px !important;
height: 60px !important;
}
article, section {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
padding: 20px !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
}
.post-preview img {
border-radius: 8px !important;
max-width: 100% !important;
height: auto !important;
}
div {
background-color: #FFFFFF !important;
border: 1px solid #E0E0E0 !important;
}
::-webkit-scrollbar-thumb {
background-color: #B0B0B0 !important;
}
::-webkit-scrollbar-track {
background-color: #F9F9F9 !important;
}
.action-container {
background-color: #FFFFFF !important;
border-color: #E0E0E0 !important;
color: #2C2C2C !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
}
footer {
background-color: #FFFFFF !important;
border-top: 1px solid #E0E0E0 !important;
padding: 15px 0 !important;
}
footer a {
background-color: #007BFF !important;
color: #FFFFFF !important;
padding: 10px 20px !important;
border-radius: 6px !important;
text-decoration: none !important;
font-weight: 600 !important;
}
footer a:hover {
background-color: #0056b3 !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex !important;
flex-direction: column !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
margin: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
display: block;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
display: block;
width: 100%;
padding: 12px 16px;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
background-color: #ffffff !important;
color: #2C2C2C !important;
border: 1px solid #D0D0D0 !important;
text-align: left;
box-sizing: border-box;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #f0f0f0 !important;
}
.filter-btn,
.create-button,
.edit-btn,
.delete-btn,
.join-btn,
.leave-btn,
.buy-btn {
background-color: #FF6F00 !important;
color: #FFFFFF !important;
border: none !important;
}
.filter-btn:hover,
.create-button:hover {
background-color: #FF8F00 !important;
color: #FFFFFF !important;
}
.card {
color: #4A4A4A;
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.1);
background-color: #F4F4F4;
}
.card-label {
color: #2D2D2D;
}
.card-footer {
color: #6C6C6C;
background: none;
}
.card-field {
background: none;
}
.card-tags a.tag-link {
color: #181818;
background: #D94F4F;
}
.card-tags a.tag-link:hover {
background: #D94F4F;
color: #111;
cursor: pointer;
}
a.user-link {
background-color: #FFD600;
color: #FFFFFF;
border-color: #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
background-color: #FFD600;
border-color: #FFD600;
color: #FFFFFF;
cursor: pointer;
}
a.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #FFFFFF;
}
.date-link {
background-color: #2F3C32;
color: #fff;
}
.date-link:hover {
background-color: #3E4A3D;
color: #fff;
}
.activitySpreadInhabitant2 {
background-color: #3E4A3D;
color: #fff;
}
.activityVotePost {
background-color: #3B5C42;
color: #fff;
}
.update-banner {
background-color: #FFF3E0 !important;
border-bottom-color: #FFE0B2 !important;
color: #E65100 !important;
}
.update-banner-link {
color: #FF6F00 !important;
}
.snh-invite-code {
color: #007BFF !important;
}
.carbon-bar-track {
background: #e0e0e0 !important;
}
.carbon-bar-max {
background: #cc7700 !important;
}
.stats-kpi-label { color: #2D2D2D !important; }
.stats-kpi-value { color: #007BFF !important; }
.carbon-bar-note, .carbon-bar-formula { color: #007BFF !important; }
.graphos-node-label { fill: #2C2C2C !important; }
.graphos-node-label-me { fill: #cc7700 !important; }
.data-best-label { color: #cc7700 !important; }
.data-best-card { background: #FFF3E0 !important; border: 1px solid rgba(204, 119, 0, .4) !important; }
.graphos-legend { color: #2C2C2C !important; }
.graphos-edge { stroke: #BBB !important; }
.graphos-edge-discovered { stroke: #b8860b !important; }
.graphos-edge-unknown { stroke: #999 !important; }
.graphos-node-circle-discovered { fill: #ffd700 !important; stroke: #b8860b !important; }
.graphos-legend-dot.graphos-node-circle-discovered { background: #ffd700 !important; border-color: #b8860b !important; }
/* Blockexplorer */
.blockchain-view { background-color: #F4F4F4 !important; color: #2C2C2C !important; }
.block { background: #FFFFFF !important; box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; }
.block:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.10) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #2D2D2D !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #007BFF !important; }
.block-content-preview, .block-content { background: #F8F8F8 !important; color: #2C2C2C !important; }
.block-author { color: #FF6F00 !important; background: rgba(255,111,0,0.08) !important; }
.block-author:hover { color: #FF8F00 !important; }
.block-url { color: #007BFF !important; }
.block-row--details .block-url { background: #F0F0F0 !important; }
.block-row--details .block-url:hover { background: #E0E0E0 !important; color: #FF6F00 !important; }
.btn-singleview { background: #F0F0F0 !important; color: #FF6F00 !important; }
.btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
.block-diagram { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
.block-diagram-ruler { color: #2D2D2D !important; background: #F8F8F8 !important; border-bottom-color: #E0E0E0 !important; }
.block-diagram-ruler span { color: #2D2D2D !important; }
.block-diagram-cell { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
.bd-label { color: #2D2D2D !important; }
.bd-value { color: #007BFF !important; }
.deleted-label { color: #D32F2F !important; }
/* Tribes */
.tribe-card { background: #FFFFFF !important; border-color: #E0E0E0 !important; }
.tribe-card:hover { border-color: #007BFF !important; }
.tribe-card-title { color: #2D2D2D !important; }
.tribe-card-description { color: #555 !important; }
.tribe-info-table td { border-color: #E0E0E0 !important; }
.tribe-info-label { color: #2D2D2D !important; background: #FFFFFF !important; }
.tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
.tribe-info-empty { color: #999 !important; }
.tribe-card-members { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
.tribe-members-count { color: #FF6F00 !important; }
.tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
.tribe-action-btn { border-color: #FF6F00 !important; color: #FF6F00 !important; }
.tribe-action-btn:hover { background: #FF6F00 !important; color: #fff !important; }
.tribe-subtribe-link { background: #F0F0F0 !important; border-color: #E0E0E0 !important; color: #FF6F00 !important; }
.tribe-thumb-link { border-color: #E0E0E0 !important; }
.tribe-thumb-link:hover { border-color: #FF6F00 !important; }
.tribe-subtribe-link:hover { background: #E0E0E0 !important; }
.tribe-parent-image { border-color: #E0E0E0 !important; }
.tribe-parent-box { background: #FFFFFF !important; }
.chat-message { background: #FFFFFF !important; border: 1px solid #E0E0E0 !important; }
.chat-bubble-row-self .chat-message { background: #FFF3E0 !important; border-color: rgba(204, 119, 0, .35) !important; }
.chat-message-author { background: #FDF6EC !important; }
.chat-bubble-sender { color: #1565C0 !important; }
.chat-bubble-sender-owner { color: #cc7700 !important; }
.chat-bubble-time { color: #999 !important; }
.chat-day-chip { background: #F0F0F0 !important; color: #666 !important; border-color: #DDD !important; }
.chat-topics { background: #FAFAFA !important; border-color: #E0E0E0 !important; }
.chat-topic:hover { background: #F0F0F0 !important; }
.chat-topic-active { background: #FFF3E0 !important; }
.chat-topic-title { color: #2D2D2D !important; }
.chat-workspace-empty { border-color: #DDD !important; }
/* Karvan: su hoja (karvan.css) esta pensada para fondo oscuro; aqui se traduce
a la paleta clara, igual que este tema hace con el resto de la interfaz */
.karvan-back, .karvan-room-title, .kr-title { color: #B25000 !important; }
.karvan-sub, .karvan-expire, .karvan-live, .kr-meta,
.karvan-ttl-lbl, .karvan-ttl-opt { color: #6B5300 !important; }
.karvan-room-item, .karvan-create input[type="text"] {
background-color: #FFFFFF !important; border: 1px solid #E0E0E0 !important; color: #2C2C2C !important;
}
.karvan-msg { background-color: #FFF8E1 !important; border: 1px solid #E6CE86 !important; }
.karvan-msg-self { background-color: #FFEFC2 !important; border-color: #D9A400 !important; }
.karvan-msg .km-from { color: #8A6D00 !important; }
.karvan-msg .km-text { color: #2C2C2C !important; }

View file

@ -0,0 +1,364 @@
body {
background-color: #121212;
color: #FFD700;
}
header, footer {
background-color: #1F1F1F;
}
.sidebar-left, .sidebar-right {
background-color: #1A1A1A;
border: 1px solid #333;
}
.main-column {
background-color: #1C1C1C;
}
button, input[type="submit"], input[type="button"] {
background-color: #444;
color: #FFD700;
border: 1px solid #444;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #333;
border-color: #666;
}
input, textarea, select {
background-color: #333;
color: #FFD700;
border: 1px solid #555;
}
a {
color: #FFD700;
}
a:hover {
color: #FFDD44;
}
table {
background-color: #222;
color: #FFD700;
}
table th {
background-color: #333;
}
table tr:nth-child(even) {
background-color: #2A2A2A;
}
nav ul li a:hover {
color: #FFDD44;
text-decoration: underline;
}
.profile {
background-color: #222;
padding: 15px;
border-radius: 8px;
}
.profile .name {
color: #FFD700;
}
.avatar {
border: 3px solid #FFD700;
}
article, section {
background-color: #1C1C1C;
color: #FFD700;
}
.article img {
border: 3px solid #FFD700;
}
.post-preview img {
border: 3px solid #FFD700;
}
.post-preview .image-container {
max-width: 100%;
overflow: hidden;
display: block;
margin: 0 auto;
}
div {
background-color: #1A1A1A;
border: 1px solid #333;
}
div .header-content {
width: 100%;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #444;
border-radius: 10px;
}
::-webkit-scrollbar-track {
background-color: #222;
}
.action-container {
background-color: #1A1A1A;
border: 1px solid #333;
padding: 10px;
border-radius: 8px;
color: #FFD700;
}
footer {
background-color: #1F1F1F;
padding: 10px 0;
}
footer a {
background-color: #444;
color: #FFD700;
text-align: center;
padding: 8px 16px;
border-radius: 5px;
text-decoration: none;
}
footer a:hover {
background-color: #333;
color: #FFDD44;
}
.card {
border-radius: 16px;
padding: 0px 24px 10px 24px;
margin-bottom: 16px;
color: #FFD600;
font-family: inherit;
box-shadow: 0 2px 20px 0 #FFD60024;
}
.card-section {
border:none;
padding: 10px 0 0 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0px;
margin-top: 8px;
padding-top: 0px;
border: none;
}
.card-label {
color: #ffa300;
font-weight: bold;
letter-spacing: 1.5px;
line-height: 1.2;
margin-bottom: 0;
}
.card-footer {
margin-top: 6px;
font-weight: 500;
color: #ff9900;
font-size: 1.07em;
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding-top: 0;
margin-bottom: 6;
}
.card-body {
margin-top: 0;
margin-bottom: 4;
padding: 0;
}
.card-field {
display: flex;
align-items: baseline;
padding: 0;
margin-bottom: 0;
border: none;
background: none;
}
.card-tags {
margin: 5px 0 3px 0;
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #181818;
background: #FFD600;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #ffe86a;
color: #111;
cursor: pointer;
}
a.user-link {
background-color: #FFA500;
color: #000;
padding: 8px 16px;
border-radius: 5px;
text-align: center;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
font-size: 0.8em;
}
a.user-link:hover {
background-color: #FFD700;
border-color: #FFD700;
color: #000;
cursor: pointer;
}
a.user-link:focus {
background-color: #007B9F;
border-color: #007B9F;
color: #fff;
}
.date-link {
background-color: #444;
color: #FFD600;
padding: 8px 16px;
border-radius: 5px;
margin-left: 8px;
}
.date-link:hover {
background-color: #555;
color: #FFD700;
}
.activitySpreadInhabitant2 {
background-color: #007B9F;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.activityVotePost {
background-color: #557d3b;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.update-banner {
background-color: #1a1400;
border-bottom-color: #3a2e00;
color: #FFD700;
}
.update-banner-link {
color: #FFD700;
}
.oasis-footer-center a {
color: #FFA500;
}
.oasis-footer-center a:hover {
color: #FFD700;
}
.snh-invite-code {
color: #FFA500 !important;
}
/* Blockexplorer */
.stats-kpi-label { color: #ffa300 !important; }
.stats-kpi-value { color: #FFD700 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFD700 !important; }
.graphos-node-label { fill: #ddd !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-legend { color: #ddd !important; }
.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
.block { background: #23242a !important; }
.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #ffa300 !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFD700 !important; }
.block-content-preview, .block-content { background: #222326 !important; color: #FFD700 !important; }
.block-author { color: #FFD700 !important; background: rgba(255,163,0,0.08) !important; }
.block-author:hover { color: #FFDD44 !important; }
.block-url { color: #FFD700 !important; }
.block-row--details .block-url { background: #1f2023 !important; }
.block-row--details .block-url:hover { background: #292b36 !important; color: #ffa300 !important; }
.btn-singleview { background: #1e1f23 !important; color: #ffa300 !important; }
.btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #ffa300 !important; color: #000000 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; }
.btn-back { background: #21232b !important; color: #ffa300 !important; }
.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
.block-info-table td, .pm-info-table td { border-color: #444 !important; }
.block-diagram { border-color: #555 !important; background: #191b20 !important; }
.block-diagram-ruler { color: #ffa300 !important; background: #111 !important; border-bottom-color: #555 !important; }
.block-diagram-ruler span { color: #ffa300 !important; }
.block-diagram-cell { border-color: #555 !important; background: #1e1f23 !important; }
.bd-label { color: #ffa300 !important; }
.bd-value { color: #FFD700 !important; }
/* Tribes */
.tribe-card { background: #23242a !important; border-color: #444 !important; }
.tribe-card:hover { border-color: #ffa300 !important; }
.tribe-card-description { color: #cfd3e1 !important; }
.tribe-info-table td { border-color: #444 !important; }
.tribe-info-label { color: #ffa300 !important; background: #1e1f23 !important; }
.tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
.tribe-info-empty { color: #9aa3b2 !important; }
.tribe-card-members { border-color: #444 !important; background: #1e1f23 !important; }
.tribe-members-count { color: #ffa300 !important; }
.tribe-card-actions { border-color: #444 !important; background: #1e1f23 !important; }
.tribe-action-btn { border-color: #ffa300 !important; color: #ffa300 !important; }
.tribe-action-btn:hover { background: #ffa300 !important; color: #000 !important; }
.tribe-subtribe-link { background: #191b20 !important; border-color: #444 !important; color: #ffa300 !important; }
.tribe-thumb-link { border-color: #444 !important; }
.tribe-thumb-link:hover { border-color: #ffa300 !important; }
.tribe-subtribe-link:hover { background: #333 !important; }
.tribe-parent-image { border-color: #ffa300 !important; }
.tribe-parent-box { background: #1e1f23 !important; }

View file

@ -0,0 +1,382 @@
body {
background-color: #000000 !important;
color: #00FF00 !important;
font-family: 'Courier New', monospace;
}
header, footer {
background-color: #000000;
color: #00FF00;
padding: 20px;
text-align: center;
font-size: 18px;
}
footer {
border-top: 2px solid #00FF00;
}
.sidebar-left, .sidebar-right {
background-color: #000000;
color: #00FF00;
border-right: 2px solid #00FF00;
padding: 15px;
margin-bottom: 10px;
}
.main-column {
background-color: #000000;
color: #00FF00;
padding: 20px;
border-left: 2px solid #00FF00;
margin-bottom: 20px;
}
button, input[type="submit"], input[type="button"] {
background-color: #000000;
color: #00FF00;
border: 2px solid #00FF00;
border-radius: 8px;
padding: 10px 20px;
cursor: pointer;
font-family: 'Courier New', monospace;
text-transform: none;
font-size: 16px;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #00FF00;
color: #000000;
border-color: #00FF00;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
input, textarea, select {
background-color: #1A1A1A;
color: #00FF00;
border: 1px solid #00FF00;
border-radius: 5px;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 16px;
}
a {
color: #00FF00;
text-decoration: none;
font-weight: normal;
font-size: 16px;
}
table {
background-color: #000000;
color: #00FF00;
width: 100%;
border: 1px solid #00FF00;
font-size: 16px;
}
table th {
background-color: #00FF00;
color: #000000;
}
table tr:nth-child(even) {
background-color: #1A1A1A;
}
nav ul {
background-color: #000000;
padding: 0;
margin: 0;
}
nav ul li {
display: inline-block;
margin-right: 10px;
}
nav ul li a {
color: #00FF00;
padding: 10px;
display: inline-block;
font-family: 'Courier New', monospace;
text-transform: none;
font-size: 16px;
}
.profile {
background-color: #1A1A1A;
color: #00FF00;
padding: 20px;
border-radius: 8px;
border: 1px solid #00FF00;
box-shadow: 0 2px 5px rgba(0,255,0,0.5);
}
.profile .name {
color: #00FF00;
font-size: 18px;
font-weight: bold;
}
.avatar {
border: 4px solid #00FF00;
border-radius: 50%;
width: 80px;
height: 80px;
margin-bottom: 10px;
}
article, section {
background-color: #1A1A1A;
color: #00FF00;
padding: 20px;
border-radius: 10px;
border: 1px solid #00FF00;
box-shadow: 0 4px 10px rgba(0, 255, 0, 0.5);
}
.post-preview {
background-color: #00FF00;
padding: 15px;
border-radius: 8px;
color: #000000;
}
.post-preview img {
border-radius: 8px;
max-width: 100%;
}
::-webkit-scrollbar-thumb {
background-color: #00FF00;
}
::-webkit-scrollbar-track {
background-color: #000000;
}
.action-container {
background-color: #1A1A1A;
border-color: #00FF00;
color: #00FF00;
}
footer a {
background-color: #00FF00;
color: #000000;
padding: 8px 20px;
border-radius: 5px;
text-decoration: none;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
background-color: #000000 !important;
border: 2px solid #00FF00 !important;
padding: 12px 16px;
box-shadow: 0 0 12px #00FF00;
border-radius: 8px;
display: flex;
gap: 12px;
}
.sidebar-left,
.sidebar-right {
background-color: #000000 !important;
border: 2px solid #00FF00 !important;
box-shadow: 0 0 15px #00FF00;
padding: 16px;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
background-color: #000000 !important;
color: #00FF00 !important;
border: 1px solid #00FF00 !important;
font-weight: bold;
border-radius: 6px;
padding: 10px 14px;
display: flex;
justify-content: flex-start;
box-shadow: 0 0 6px #00FF00;
transition: background-color 0.3s ease, color 0.3s ease;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #00FF00 !important;
color: #000000 !important;
}
.card {
color: #00FF00;
font-family: 'Courier New', monospace;
background-color: #1A1A1A;
}
.card-label {
color: #00FF00;
}
.card-footer {
color: #00FF00;
}
.card-tags a.tag-link {
color: #00FF00;
background: #000000;
}
.card-tags a.tag-link:hover {
background: #00FF00;
color: #000000;
cursor: pointer;
}
a.user-link {
background-color: #00FF00;
color: #000000;
border-color: #00FF00;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
background-color: #000000;
border-color: #00FF00;
color: #00FF00;
cursor: pointer;
}
a.user-link:focus {
background-color: #00FF00;
border-color: #00FF00;
color: #000000;
}
.date-link {
background-color: #00FF00;
color: #000000;
}
.date-link:hover {
background-color: #000000;
color: #00FF00;
}
.activitySpreadInhabitant2 {
background-color: #1A1A1A;
color: #00FF00;
}
.activityVotePost {
background-color: #00FF00;
color: #000000;
}
.update-banner {
background-color: #001a00;
border-bottom-color: #003300;
color: #00FF00;
}
.update-banner-link {
color: #00FF00;
}
.snh-invite-code {
color: #00FF00 !important;
}
.stats-kpi-label { color: #00FF00 !important; }
.stats-kpi-value { color: #00FF00 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #00FF00 !important; }
.graphos-node-label { fill: #00FF00 !important; }
.graphos-node-label-me { fill: #ffa500 !important; font-weight: bold !important; }
.graphos-legend { color: #00FF00 !important; }
.graphos-edge { stroke: #003300 !important; }
/* Blockexplorer */
.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }
.block:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #00FF00 !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #00FF00 !important; }
.block-content-preview, .block-content { background: #1A1A1A !important; color: #00FF00 !important; }
.block-author { color: #00FF00 !important; background: rgba(0,255,0,0.08) !important; }
.block-author:hover { color: #00FF00 !important; }
.block-url { color: #00FF00 !important; }
.block-row--details .block-url { background: #1A1A1A !important; }
.block-row--details .block-url:hover { background: #00FF00 !important; color: #000000 !important; }
.btn-singleview { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
.btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #00FF00 !important; color: #000000 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #66FF66 !important; color: #000000 !important; }
.btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
.btn-back:hover { background: #00FF00 !important; color: #000000 !important; }
.block-info-table td, .pm-info-table td { border-color: #00FF00 !important; }
.block-diagram { border-color: #00FF00 !important; background: #000000 !important; }
.block-diagram-ruler { color: #00FF00 !important; background: #000000 !important; border-bottom-color: #00FF00 !important; }
.block-diagram-ruler span { color: #00FF00 !important; }
.block-diagram-cell { border-color: #00FF00 !important; background: #1A1A1A !important; }
.bd-label { color: #00FF00 !important; }
.bd-value { color: #00FF00 !important; }
.deleted-label { color: #ff3333 !important; }
/* Tribes */
.tribe-card { background: #1A1A1A !important; border-color: #00FF00 !important; }
.tribe-card:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
.tribe-card-title { color: #00FF00 !important; }
.tribe-card-description { color: #00FF00 !important; }
.tribe-info-table td { border-color: #00FF00 !important; }
.tribe-info-label { color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-info-empty { color: #006600 !important; }
.tribe-card-members { border-color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-members-count { color: #00FF00 !important; }
.tribe-card-actions { border-color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-action-btn { border-color: #00FF00 !important; color: #00FF00 !important; }
.tribe-action-btn:hover { background: #00FF00 !important; color: #000 !important; }
.tribe-subtribe-link { background: #000000 !important; border-color: #00FF00 !important; color: #00FF00 !important; }
.tribe-thumb-link { border-color: #00FF00 !important; }
.tribe-thumb-link:hover { box-shadow: 0 0 10px rgba(0,255,0,0.3) !important; }
.tribe-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
.tribe-parent-image { border-color: #00FF00 !important; }
.tribe-parent-box { background: #1A1A1A !important; }
.oasis-nav-header { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
.oasis-nav-header:hover { color: #000000 !important; background: #00FF00 !important; }
.oasis-nav-header .emoji, .oasis-nav-header .nav-text, .oasis-nav-arrow { color: inherit !important; }
.oasis-nav-list li a { color: #00FF00 !important; }
.oasis-nav-list li a:hover { color: #00FF00 !important; opacity: 1 !important; }
.oasis-nav-list .emoji, .oasis-nav-list .nav-text { color: inherit !important; }
nav, .sidebar-left nav, .sidebar-right nav, .sidebar-left ul, .sidebar-right ul { color: #00FF00 !important; }
.sidebar-left a, .sidebar-right a { color: #00FF00 !important; }
.ai-ask-form .ai-ask-input, .ai-ask-form .ai-ask-btn { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
.ai-ask-form .ai-ask-input::placeholder { color: rgba(0,255,0,0.55) !important; }
.ai-ask-form .ai-ask-input:focus { border-color: #00FF00 !important; background: #1A1A1A !important; box-shadow: 0 0 6px rgba(0,255,0,0.4) !important; }
.ai-ask-form .ai-ask-btn:hover { background: #00FF00 !important; color: #000000 !important; border-color: #00FF00 !important; }
.ai-nav-results, .ai-nav-result-card { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
.ai-nav-result-card .card-label { color: #00FF00 !important; }
.ai-nav-result-card a { color: #00FF00 !important; }
.ai-nav-result-card a:hover { background: #00FF00 !important; color: #000000 !important; }
.ai-nav-query { color: #00FF00 !important; }
.trending-card.own-content { box-shadow: inset 3px 0 0 #00FF00; }

View file

@ -956,23 +956,23 @@ button, input[type="submit"], input[type="button"],
El problema era una cadena de flex-stretch (comment-vote-col flex estiraba la caja a 135px). El problema era una cadena de flex-stretch (comment-vote-col flex estiraba la caja a 135px).
Rompemos el estiramiento con height:auto + inline-flex en toda la cadena, con !important Rompemos el estiramiento con height:auto + inline-flex en toda la cadena, con !important
para vencer el min-height:44px de .score-btn (762) y el !important de epsylon en mobile.css. --- */ para vencer el min-height:44px de .score-btn (762) y el !important de epsylon en mobile.css. --- */
.comment-vote-col { display: block !important; height: auto !important; min-height: 0 !important; width: auto !important; } .comment-vote-col { display: block; height: auto; min-height: 0; width: auto; }
.forum-score-box { .forum-score-box {
display: inline-flex !important; height: auto !important; min-height: 0 !important; display: inline-flex; height: auto; min-height: 0;
min-width: 0 !important; width: auto !important; min-width: 0; width: auto;
padding: 4px 8px !important; border-radius: 10px !important; padding: 4px 8px; border-radius: 10px;
flex-direction: row !important; flex-wrap: nowrap !important; gap: 12px !important; align-items: center !important; flex-direction: row; flex-wrap: nowrap; gap: 12px; align-items: center;
} }
.forum-score-form { .forum-score-box .forum-score-form {
display: inline-flex !important; height: auto !important; width: auto !important; display: inline-flex; height: auto; width: auto;
flex-direction: row !important; flex-wrap: nowrap !important; gap: 12px !important; align-items: center !important; flex-direction: row; flex-wrap: nowrap; gap: 12px; align-items: center;
} }
.score-btn { .score-btn {
flex: 0 0 auto !important; flex: 0 0 auto !important;
width: 30px !important; height: 30px !important; min-width: 30px !important; min-height: 30px !important; width: 30px !important; height: 30px !important; min-width: 30px !important; min-height: 30px !important;
font-size: 0.95em !important; border-radius: 8px !important; padding: 0 !important; font-size: 0.95em !important; border-radius: 8px !important; padding: 0 !important;
} }
.score-total { flex: 0 0 auto !important; font-size: 1.15em !important; min-height: 0 !important; height: auto !important; margin: 0 !important; white-space: nowrap; } .score-total { flex: 0 0 auto; font-size: 1.15em; min-height: 0; height: auto; margin: 0; white-space: nowrap; }
.comment-votes .votes-count { font-size: 11px; } .comment-votes .votes-count { font-size: 11px; }
/* Aplanar el comentario: quitar las cajas/recuadros internos para que sea UN bloque limpio /* Aplanar el comentario: quitar las cajas/recuadros internos para que sea UN bloque limpio
@ -982,25 +982,27 @@ button, input[type="submit"], input[type="button"],
.forum-comment .comment-vote-col, .forum-comment .comment-vote-col,
.forum-comment .comment-text-col, .forum-comment .comment-text-col,
.forum-comment .new-reply { .forum-comment .new-reply {
background: transparent !important; border: none !important; box-shadow: none !important; background: transparent; border: none; box-shadow: none;
padding: 0 !important; margin: 6px 0 0 !important; border-radius: 0 !important; padding: 0; margin: 6px 0 0; border-radius: 0;
} }
.forum-comment .comment-header { margin-top: 0 !important; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; } .forum-comment .comment-header { margin-top: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
/* la barra de votos de abajo ya muestra el score → ocultar el ▲:0 ▼:0 redundante de la cabecera */ /* la barra de votos de abajo ya muestra el score → ocultar el ▲:0 ▼:0 redundante de la cabecera */
.forum-comment .comment-header .comment-votes { display: none !important; } .forum-comment .comment-header .comment-votes { display: none; }
.forum-comment .comment-text-col p { margin: 4px 0 !important; } .forum-comment .comment-text-col p { margin: 4px 0; }
.forum-comment .comment-text-col > div { background: transparent !important; border: none !important; padding: 0 !important; } .forum-comment .comment-text-col > div { background: transparent; border: none; padding: 0; }
.forum-comment { padding: 10px 12px !important; margin-bottom: 8px !important; } .forum-comment { padding: 10px 12px; margin-bottom: 8px; }
/* barra de votos alineada a la izquierda, no centrada, para que ocupe poco */ /* barra de votos alineada a la izquierda, no centrada, para que ocupe poco.
.forum-comment .comment-body-row { display: flex !important; flex-direction: column !important; align-items: flex-start !important; } `display:flex !important` es DE PELEA: vence a mobile.css:164 `.comment-body-row{display:block!important}` */
.forum-comment .comment-body-row { display: flex !important; flex-direction: column; align-items: flex-start; }
/* --- Reply/compose del foro estilo Signal: fila [textarea que crece] + [botón REDONDO con icono ↩] --- */ /* --- Reply/compose del foro estilo Signal: fila [textarea que crece] + [botón REDONDO con icono ↩] --- */
.new-reply .comment-form, /* especificidad extra (.main-column …) para ganar a nuestra propia regla `.main-column form{display:block}` sin !important */
.new-message-form { display: flex !important; flex-direction: row !important; align-items: flex-end !important; gap: 8px !important; margin: 6px 0 0 !important; } .main-column .new-reply .comment-form,
.new-message-form br { display: none !important; } .main-column .new-message-form { display: flex; flex-direction: row; align-items: flex-end; gap: 8px; margin: 6px 0 0; }
.comment-textarea, .new-message-form textarea { .main-column .new-message-form br { display: none; }
flex: 1 1 auto !important; box-sizing: border-box; border-radius: 14px; .main-column .comment-textarea, .main-column .new-message-form textarea {
padding: 9px 14px; min-height: 42px; resize: none; font-size: 14px; margin: 0 !important; flex: 1 1 auto; box-sizing: border-box; border-radius: 14px;
padding: 9px 14px; min-height: 42px; resize: none; font-size: 14px; margin: 0;
} }
.forum-send-btn { .forum-send-btn {
flex: 0 0 auto !important; flex: 0 0 auto !important;
@ -1013,12 +1015,12 @@ button, input[type="submit"], input[type="button"],
.new-reply { margin-top: 6px; } .new-reply { margin-top: 6px; }
/* --- Fase 2: hilos anidados. mobile.css fuerza margin-left:0 !important → necesitamos !important para ganar --- */ /* --- Fase 2: hilos anidados. mobile.css fuerza margin-left:0 !important → necesitamos !important para ganar --- */
.forum-comment.level-1 { margin-left: 10px !important; border-left: 2px solid #333; } .forum-comment.level-1 { margin-left: 10px; border-left: 2px solid #333; }
.forum-comment.level-2 { margin-left: 20px !important; border-left: 2px solid #3a3a3a; } .forum-comment.level-2 { margin-left: 20px; border-left: 2px solid #3a3a3a; }
.forum-comment.level-3 { margin-left: 28px !important; border-left: 2px solid #404040; } .forum-comment.level-3 { margin-left: 28px; border-left: 2px solid #404040; }
.forum-comment.level-4 { margin-left: 34px !important; border-left: 2px solid #454545; } .forum-comment.level-4 { margin-left: 34px; border-left: 2px solid #454545; }
/* comentario destacado: acento sutil a la izquierda en vez del marco naranja enorme */ /* comentario destacado: acento sutil a la izquierda en vez del marco naranja enorme */
.forum-comment.highlighted-reply { border: 1px solid #3a3a3a !important; border-left: 3px solid #FFD700 !important; background: #1b1a16 !important; } .forum-comment.highlighted-reply { border: 1px solid #3a3a3a; border-left: 3px solid #FFD700; background: #1b1a16; }
/* --- Reply del foro SIN tocar la vista de epsylon: la cajita se agranda al enfocarla; /* --- Reply del foro SIN tocar la vista de epsylon: la cajita se agranda al enfocarla;
el botón redondo está SIEMPRE visible (el usuario pidió icono de reply, no gesto/JS). --- */ el botón redondo está SIEMPRE visible (el usuario pidió icono de reply, no gesto/JS). --- */
@ -1040,7 +1042,74 @@ button, input[type="submit"], input[type="button"],
/* --- hive ahora global en el header (debajo de los 2 botones) --- */ /* --- hive ahora global en el header (debajo de los 2 botones) --- */
.header .hive-nav { margin: 34px auto 10px; } .header .hive-nav { margin: 34px auto 10px; }
/* --- bottombar FIJA de 6 (Inbox·PM·Write·Search·Graphos·Peers): que quepan --- */ /* holgura bajo la topbar fija (el hive va en el header, solo en la home) */
.oasis-bottombar-fixed .bb-item { padding: 2px 0; gap: 2px; } body { padding-top: 84px; }
.oasis-bottombar-fixed .bb-ico { font-size: 17px; }
.oasis-bottombar-fixed .bb-lbl { font-size: 8.5px; } /* ==========================================================================
FORK_IA_UX · Módulo KARVAN (mensajes temporales + WebRTC) chat estilo burbujas
========================================================================== */
.karvan-intro h2 { color: #FFD700; margin: 0 0 6px; }
.karvan-sub { color: #FFDD44; font-size: 13px; }
.karvan-create input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
.karvan-ttl { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 10px 0; }
.karvan-ttl-lbl { color: #FFDD44; font-size: 13px; }
.karvan-ttl-opt { display: inline-flex; align-items: center; gap: 4px; color: #FFD700; font-size: 13px; }
.karvan-create button, .karvan-compose button { padding: 10px 16px; border: 1px solid #FFD700;
border-radius: 10px; background: #FFD700; color: #121212; font-weight: 700; cursor: pointer; }
.karvan-rooms { list-style: none; margin: 8px 0 0; padding: 0; }
.karvan-rooms li { margin: 0 0 8px; }
.karvan-room-item { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 12px 14px; background: #161616; border: 1px solid #333; border-radius: 12px;
color: #FFD700; text-decoration: none; min-height: 48px; box-sizing: border-box; }
.karvan-room-item .kr-title { font-weight: 600; }
.karvan-room-item .kr-meta { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-empty { color: #FFDD44; }
/* sala */
.karvan-room-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.karvan-back { color: #FFD700; text-decoration: none; font-weight: 600; }
.karvan-room-title { flex: 1 1 auto; color: #FFD700; font-weight: 700; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.karvan-expire { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-live { display: inline-flex; align-items: center; gap: 6px; color: #FFDD44; font-size: 12px; margin-bottom: 8px; }
.karvan-live .kl-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.karvan-live.on .kl-dot { background: #35d07f; }
.karvan-live.on .kl-txt { color: #35d07f; }
.karvan-msgs { list-style: none; margin: 0; padding: 6px 0; display: flex; flex-direction: column; gap: 6px;
max-height: 58vh; overflow-y: auto; }
.karvan-msg { max-width: 82%; align-self: flex-start; background: #1c1c1c; border: 1px solid #333;
border-radius: 14px; padding: 7px 11px; box-sizing: border-box; }
.karvan-msg-self { align-self: flex-end; background: #2a2412; border-color: #FFD700; }
.karvan-msg-live { border-left: 2px solid #35d07f; } /* recibido al instante por WebRTC (P2P) */
.karvan-msg .km-from { display: block; font-size: 10px; color: #FFDD44; margin-bottom: 2px; }
.karvan-msg .km-text { color: #FFD700; word-break: break-word; }
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
/* Fase 2: invitar a un contacto por feed id */
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
.karvan-invite-msg.ok { color: #35d07f; }
.karvan-invite-msg.err { color: #ff8a8a; }
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
background-color: transparent; box-shadow: none; border-radius: 0; padding: 0; margin: 0; /* anular div{#222} */
}
.karvan-call { margin: 10px 0; }
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
.karvan-call.active .karvan-videos { display: grid; }
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
.kc-local { transform: scaleX(-1); } /* espejo, como cámara frontal */
.kc-remote { display: contents; } /* los vídeos remotos entran en la misma rejilla */
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
.kc-btn-start { font-weight: 600; }
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }

View file

@ -0,0 +1,398 @@
body {
background-color: #4B0A6D;
color: #E5E5E5;
font-family: 'Arial', sans-serif;
}
footer {
border-top: 2px solid #9B1C96;
}
.sidebar-left, .sidebar-right {
background-color: #39006D;
border: 1px solid #9B1C96;
color: #E5E5E5;
padding: 15px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.main-column {
background-color: #1A1A1A;
border: 1px solid #9B1C96;
padding: 20px;
color: #E5E5E5;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
button, input[type="submit"], input[type="button"] {
background-color: #9B1C96;
color: #E5E5E5;
border: 2px solid #6A0066;
border-radius: 10px;
padding: 12px 24px;
cursor: pointer;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #6A0066;
border-color: #9B1C96;
box-shadow: 0 0 15px rgba(155, 28, 150, 0.8);
}
input, textarea, select {
background-color: #333333;
color: #E5E5E5;
border: 1px solid #9B1C96;
border-radius: 5px;
padding: 12px;
}
input:focus, textarea:focus, select:focus {
background-color: #39006D;
outline: none;
}
a {
color: #9B1C96;
text-decoration: none;
}
a:hover {
color: #E5E5E5;
}
table {
background-color: #1A1A1A;
color: #E5E5E5;
width: 100%;
border-collapse: collapse;
}
table th {
background-color: #6A0066;
}
table tr:nth-child(even) {
background-color: #333333;
}
nav ul {
background-color: #39006D;
padding: 0;
margin: 0;
list-style: none;
}
nav ul li {
display: inline-block;
margin-right: 10px;
}
nav ul li a {
color: #E5E5E5;
padding: 15px;
display: inline-block;
text-transform: none;
font-weight: bold;
letter-spacing: 0;
}
.profile {
background-color: #333333;
color: #E5E5E5;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.profile .name {
color: #9B1C96;
font-size: 20px;
font-weight: bold;
}
.avatar {
border: 4px solid #9B1C96;
border-radius: 50%;
width: 80px;
height: 80px;
margin-bottom: 10px;
}
article, section {
background-color: #333333;
color: #E5E5E5;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.post-preview {
background-color: #39006D;
padding: 15px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.post-preview img {
border-radius: 8px;
max-width: 100%;
}
::-webkit-scrollbar-thumb {
background-color: #9B1C96;
}
::-webkit-scrollbar-track {
background-color: #1A1A1A;
}
.action-container {
background-color: #333333;
border-color: #9B1C96;
color: #E5E5E5;
}
footer a {
background-color: #9B1C96;
color: #FFFFFF;
padding: 8px 20px;
border-radius: 5px;
text-decoration: none;
}
footer a:hover {
background-color: #6A0066;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
background-color: #682B94 !important;
color: #FFE082 !important;
border: 1px solid #B86ADE !important;
font-weight: 600;
border-radius: 6px;
padding: 12px 16px;
display: flex;
justify-content: flex-start;
box-sizing: border-box;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #682B94 !important;
border-color: #FFD54F !important;
color: #FFFFFF !important;
}
body {
background-color: #2D0B47 !important;
}
.main-column,
article,
section,
.action-container,
.profile,
.post-preview {
background-color: #3C1360 !important;
border-color: #B86ADE !important;
color: #FFEEDB !important;
}
input,
textarea,
select {
background-color: #4B1A72 !important;
color: #FFFFFF !important;
border-color: #BB5EFF !important;
}
button,
input[type="submit"],
input[type="button"] {
background-color: #A34AD8 !important;
color: #FFFFFF !important;
border-color: #751E9F !important;
}
header {
background-color: #5A1A85 !important;
border-bottom: 1px solid #B86ADE !important;
box-shadow: none !important;
}
.header {
background-color: #5A1A85 !important;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
background-color: #39006D !important;
border: 1px solid #9B1C96 !important;
padding: 15px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
display: flex;
gap: 12px;
border-radius: 10px;
}
.sidebar-left,
.sidebar-right {
padding: 16px;
box-sizing: border-box;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
}
.card {
color: #E5E5E5;
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.2);
background-color: #3C1360;
border-color: #B86ADE;
}
.card-label {
color: #9B1C96;
}
.card-footer {
color: #B86ADE;
}
.card-tags a.tag-link {
color: #FFFFFF;
background: #D94F4F;
}
.card-tags a.tag-link:hover {
background: #B86ADE;
color: #E5E5E5;
cursor: pointer;
}
a.user-link {
background-color: #FFD600;
color: #3C1360;
border-color: #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
background-color: #FFB600;
border-color: #FFB600;
color: #3C1360;
cursor: pointer;
}
a.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #E5E5E5;
}
.date-link {
background-color: #2F3C32;
color: #fff;
}
.date-link:hover {
background-color: #3E4A3D;
color: #fff;
}
.activitySpreadInhabitant2 {
background-color: #3E4A3D;
color: #fff;
}
.activityVotePost {
background-color: #3B5C42;
color: #fff;
}
.update-banner {
background-color: #1a0025;
border-bottom-color: #3d004d;
color: #E5E5E5;
}
.update-banner-link {
color: #c44bc1;
}
.snh-invite-code {
color: #9B1C96 !important;
}
.stats-kpi-label { color: #B86ADE !important; }
.stats-kpi-value { color: #FFEEDB !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFEEDB !important; }
.graphos-node-label { fill: #FFEEDB !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-legend { color: #FFEEDB !important; }
/* Blockexplorer */
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }
.block:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #B86ADE !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFEEDB !important; }
.block-content-preview, .block-content { background: #4B1A72 !important; color: #FFEEDB !important; }
.block-author { color: #FFD600 !important; background: rgba(255,214,0,0.08) !important; }
.block-author:hover { color: #FFB600 !important; }
.block-url { color: #B86ADE !important; }
.block-row--details .block-url { background: #4B1A72 !important; }
.block-row--details .block-url:hover { background: #5A1A85 !important; color: #FFD600 !important; }
.btn-singleview { background: #4B1A72 !important; color: #B86ADE !important; }
.btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #B86ADE !important; color: #2A0E42 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #CE8CE9 !important; color: #2A0E42 !important; }
.btn-back { background: #A34AD8 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #B86ADE !important; }
.block-diagram { border-color: #B86ADE !important; background: #2D0B47 !important; }
.block-diagram-ruler { color: #B86ADE !important; background: #1A0030 !important; border-bottom-color: #B86ADE !important; }
.block-diagram-ruler span { color: #B86ADE !important; }
.block-diagram-cell { border-color: #B86ADE !important; background: #3C1360 !important; }
.bd-label { color: #B86ADE !important; }
.bd-value { color: #FFEEDB !important; }
.deleted-label { color: #ff5555 !important; }
/* Tribes */
.tribe-card { background: #3C1360 !important; border-color: #B86ADE !important; }
.tribe-card:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
.tribe-card-title { color: #FFEEDB !important; }
.tribe-card-description { color: #FFEEDB !important; }
.tribe-info-table td { border-color: #B86ADE !important; }
.tribe-info-label { color: #B86ADE !important; background: #3C1360 !important; }
.tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
.tribe-info-empty { color: #8844aa !important; }
.tribe-card-members { border-color: #B86ADE !important; background: #2D0B47 !important; }
.tribe-members-count { color: #FFD600 !important; }
.tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; }
.tribe-action-btn { border-color: #B86ADE !important; color: #B86ADE !important; }
.tribe-action-btn:hover { background: #B86ADE !important; color: #000 !important; }
.tribe-subtribe-link { background: #4B1A72 !important; border-color: #B86ADE !important; color: #FFD600 !important; }
.tribe-thumb-link { border-color: #B86ADE !important; }
.tribe-thumb-link:hover { border-color: #FFD600 !important; }
.tribe-subtribe-link:hover { background: #5A1A85 !important; }
.tribe-parent-image { border-color: #B86ADE !important; }
.tribe-parent-box { background: #3C1360 !important; }
.trending-card.own-content { box-shadow: inset 3px 0 0 #B86ADE; }

View file

@ -12,4 +12,9 @@ languages.forEach(language => {
} }
}); });
try {
const overlay = require('./i18n_fork.js');
languages.forEach(l => Object.assign(i18n[l] = i18n[l] || {}, overlay[l] || overlay.en || {}));
} catch (e) {}
module.exports = i18n; module.exports = i18n;

View file

@ -0,0 +1,718 @@
// Claves de traduccion propias de la rama experimental.
// Se fusionan sobre las de upstream desde i18n.js, para no editar los 11
// ficheros oasis_*.js (upstream los reescribe entero en cada release).
//
// Contiene tres grupos:
// 1. Claves que anade la rama: el modulo Karvan, el selector de identidades,
// la barra inferior (bb*) y el filtro Personal/Social de la topbar.
// 2. Claves que upstream tenia en 0.9.1 y elimino en 0.9.5, pero que el
// codigo de la rama sigue usando (menuBlogs, spreadHint, chatShareUrl...).
// 3. Textos de upstream que la rama corrige (el grupo spread en castellano).
//
// Los nombres de las categorias NO se tocan: son los de epsylon y se leen de
// upstream, para que la rama no se separe de el en algo tan visible.
//
// Los 11 idiomas estan completos. Si anades una clave, anadela en los 11.
module.exports = {
en: {
// Karvan
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "In use",
identitiesBackup: "Export or import an identity",
identitiesCreate: "Create identity",
identitiesDescription: "Each identity lives in its own folder, with its own key, database and blobs. The change applies on restart.",
identitiesInvalidName: "Invalid name. Letters, numbers, hyphen and underscore, up to 32.",
identitiesNewName: "Name for the new identity",
identitiesRestart: "Restart now",
identitiesRestartNeeded: "Selected. Restart Oasis to enter with this identity.",
identitiesRestarting: "Restarting. Open Oasis again in a few seconds.",
identitiesSameFeed: "Shares key with another identity: never publish from both.",
identitiesTitle: "Identities",
identitiesUse: "Use this one",
// Barra inferior
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
// Menu y filtros
menuAll: "All",
menuBlogs: "Blogs",
menuSocial: "Social",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Category",
bookmarkCategoryPlaceholder: "Optional",
chatReply: "Reply",
chatShareUrl: "Share URL",
filter: "Filter",
forumFilterHot: "Hot",
peerLastChange: "Last change",
profileVisibilityDevice: "Device",
publishBlog: "Publish Blog",
spreadHint: "Spread this to your supporters (replicates via your feed).",
spreadMore: "more"
},
es: {
// Karvan
karvanActive: "Salas activas",
karvanBlurb: "Salas que viven solo en memoria y se destruyen solas.",
karvanCall: "Llamar",
karvanCreate: "Crear sala efímera",
karvanEphemeral: "Salas efímeras",
karvanInvite: "Invitar",
karvanInviteBad: "✗ Eso no es un @feed-id válido.",
karvanInviteFail: "✗ No se ha podido enviar la invitación.",
karvanInvitePlaceholder: "Invitar a un contacto por @feed-id…",
karvanInviteSent: "✓ Invitación enviada a su bandeja.",
karvanNone: "No hay salas activas. Crea una: desaparece sola.",
karvanRelay: "a través del servidor",
karvanRoomName: "Nombre de la sala (opcional)",
karvanRooms: "Salas",
karvanSay: "Mensaje temporal…",
karvanSend: "Enviar",
karvanTitle: "Karvan",
karvanTtl: "Se autodestruye en:",
modulesKarvanDescription: "Salas efímeras: los mensajes viven solo en memoria y se autodestruyen.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "En uso",
identitiesBackup: "Exportar o importar una identidad",
identitiesCreate: "Crear identidad",
identitiesDescription: "Cada identidad vive en su propia carpeta, con su clave, su base de datos y sus blobs. El cambio se aplica al reiniciar.",
identitiesInvalidName: "Nombre no válido. Letras, números, guion y guion bajo, hasta 32.",
identitiesNewName: "Nombre de la nueva identidad",
identitiesRestart: "Reiniciar ahora",
identitiesRestartNeeded: "Seleccionada. Reinicia Oasis para entrar con esta identidad.",
identitiesRestarting: "Reiniciando. Vuelve a abrir Oasis en unos segundos.",
identitiesSameFeed: "Comparte clave con otra identidad: no publiques nunca desde las dos.",
identitiesTitle: "Identidades",
identitiesUse: "Usar esta",
// Barra inferior
bbAdd: "Añadir",
bbDone: "Hecho",
bbFull: "Máximo alcanzado",
bbManage: "Editar",
bbPickDesc: "Elige hasta 4 accesos directos para la barra inferior.",
bbPickNone: "Ninguno",
bbPickTitle: "Personalizar la barra",
bbQuickActions: "Acciones rápidas",
bbRemove: "Quitar",
bbYourBar: "Tu barra",
// Menu y filtros
menuAll: "Todo",
menuBlogs: "Blogs",
menuSocial: "Social",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Categoría",
bookmarkCategoryPlaceholder: "Opcional",
chatReply: "Responder",
chatShareUrl: "Compartir enlace",
filter: "Filtrar",
forumFilterHot: "Candente",
peerLastChange: "Último cambio",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Publicar blog",
spreadHint: "Difunde esto entre quienes te siguen (se replica por tu feed).",
spreadMore: "más",
// Correcciones al castellano de upstream
spreadChron: "Difusión",
spreadLabel: "Difundir",
spreaded: "Difundido",
spreadedDescription: "Lista de posts replicados por habitante.",
totalspreads: "Réplicas totales"
},
fr: {
// Karvan
karvanActive: "Salons actifs",
karvanBlurb: "Des salons qui ne vivent qu'en mémoire et se détruisent d'eux-mêmes.",
karvanCall: "Appeler",
karvanCreate: "Créer un salon éphémère",
karvanEphemeral: "Salons éphémères",
karvanInvite: "Inviter",
karvanInviteBad: "✗ Ce n'est pas un @feed-id valide.",
karvanInviteFail: "✗ Impossible d'envoyer l'invitation.",
karvanInvitePlaceholder: "Inviter un contact par @feed-id…",
karvanInviteSent: "✓ Invitation envoyée dans sa boîte de réception.",
karvanNone: "Aucun salon actif. Créez-en un : il disparaîtra tout seul.",
karvanRelay: "relais par le serveur",
karvanRoomName: "Nom du salon (facultatif)",
karvanRooms: "Salons",
karvanSay: "Message temporaire…",
karvanSend: "Envoyer",
karvanTitle: "Karvan",
karvanTtl: "Autodestruction dans :",
modulesKarvanDescription: "Salons éphémères : les messages ne vivent qu'en mémoire et s'autodétruisent.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "En cours d'utilisation",
identitiesBackup: "Exporter ou importer une identité",
identitiesCreate: "Créer une identité",
identitiesDescription: "Chaque identité vit dans son propre dossier, avec sa clé, sa base de données et ses blobs. Le changement s'applique au redémarrage.",
identitiesInvalidName: "Nom invalide. Lettres, chiffres, tiret et tiret bas, jusqu'à 32.",
identitiesNewName: "Nom de la nouvelle identité",
identitiesRestart: "Redémarrer maintenant",
identitiesRestartNeeded: "Sélectionnée. Redémarrez Oasis pour entrer avec cette identité.",
identitiesRestarting: "Redémarrage. Rouvrez Oasis dans quelques secondes.",
identitiesSameFeed: "Partage sa clé avec une autre identité : ne publiez jamais depuis les deux.",
identitiesTitle: "Identités",
identitiesUse: "Utiliser celle-ci",
// Barra inferior
bbAdd: "Ajouter",
bbDone: "Terminé",
bbFull: "Maximum atteint",
bbManage: "Modifier",
bbPickDesc: "Choisissez jusqu'à 4 raccourcis pour la barre du bas.",
bbPickNone: "Aucun",
bbPickTitle: "Personnaliser la barre",
bbQuickActions: "Actions rapides",
bbRemove: "Retirer",
bbYourBar: "Votre barre",
// Menu y filtros
menuAll: "Tout",
menuBlogs: "Blogs",
menuSocial: "Social",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Catégorie",
bookmarkCategoryPlaceholder: "Facultatif",
chatReply: "Répondre",
chatShareUrl: "Partager le lien",
filter: "Filtrer",
forumFilterHot: "Populaire",
peerLastChange: "Dernier changement",
profileVisibilityDevice: "Appareil",
publishBlog: "Publier le blog",
spreadHint: "Diffusez ceci auprès de vos soutiens (se réplique via votre flux).",
spreadMore: "plus"
},
eu: {
// Karvan
karvanActive: "Gela aktiboak",
karvanBlurb: "Memorian bakarrik bizi diren eta beren kabuz suntsitzen diren gelak.",
karvanCall: "Deitu",
karvanCreate: "Sortu gela iragankorra",
karvanEphemeral: "Gela iragankorrak",
karvanInvite: "Gonbidatu",
karvanInviteBad: "✗ Hori ez da @feed-id baliozkoa.",
karvanInviteFail: "✗ Ezin izan da gonbidapena bidali.",
karvanInvitePlaceholder: "Gonbidatu kontaktu bat @feed-id bidez…",
karvanInviteSent: "✓ Gonbidapena bere sarrera-ontzira bidali da.",
karvanNone: "Ez dago gela aktiborik. Sortu bat: bere kabuz desagertuko da.",
karvanRelay: "zerbitzariaren bidez",
karvanRoomName: "Gelaren izena (aukerakoa)",
karvanRooms: "Gelak",
karvanSay: "Aldi baterako mezua…",
karvanSend: "Bidali",
karvanTitle: "Karvan",
karvanTtl: "Bere burua suntsituko du:",
modulesKarvanDescription: "Gela iragankorrak: mezuak memorian bakarrik bizi dira eta beren burua suntsitzen dute.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "Erabiltzen",
identitiesBackup: "Esportatu edo inportatu nortasun bat",
identitiesCreate: "Sortu nortasuna",
identitiesDescription: "Nortasun bakoitza bere karpetan bizi da, bere gakoarekin, datu-basearekin eta blob-ekin. Aldaketa berrabiaraztean aplikatzen da.",
identitiesInvalidName: "Izen baliogabea. Letrak, zenbakiak, marratxoa eta azpimarra, 32 arte.",
identitiesNewName: "Nortasun berriaren izena",
identitiesRestart: "Berrabiarazi orain",
identitiesRestartNeeded: "Hautatuta. Berrabiarazi Oasis nortasun honekin sartzeko.",
identitiesRestarting: "Berrabiarazten. Ireki Oasis berriro segundo batzuk barru.",
identitiesSameFeed: "Beste nortasun batekin gakoa partekatzen du: ez argitaratu inoiz bietatik.",
identitiesTitle: "Nortasunak",
identitiesUse: "Erabili hau",
// Barra inferior
bbAdd: "Gehitu",
bbDone: "Eginda",
bbFull: "Gehienekora iritsi zara",
bbManage: "Editatu",
bbPickDesc: "Aukeratu 4 lasterbide arte beheko barrarako.",
bbPickNone: "Bat ere ez",
bbPickTitle: "Pertsonalizatu barra",
bbQuickActions: "Ekintza bizkorrak",
bbRemove: "Kendu",
bbYourBar: "Zure barra",
// Menu y filtros
menuAll: "Dena",
menuBlogs: "Blogak",
menuSocial: "Soziala",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Kategoria",
bookmarkCategoryPlaceholder: "Aukerakoa",
chatReply: "Erantzun",
chatShareUrl: "Partekatu esteka",
filter: "Iragazi",
forumFilterHot: "Bero",
peerLastChange: "Azken aldaketa",
profileVisibilityDevice: "Gailua",
publishBlog: "Argitaratu bloga",
spreadHint: "Zabaldu hau zure jarraitzaileen artean (zure jarioaren bidez errepikatzen da).",
spreadMore: "gehiago"
},
de: {
// Karvan
karvanActive: "Aktive Räume",
karvanBlurb: "Räume, die nur im Speicher leben und sich von selbst zerstören.",
karvanCall: "Anrufen",
karvanCreate: "Flüchtigen Raum erstellen",
karvanEphemeral: "Flüchtige Räume",
karvanInvite: "Einladen",
karvanInviteBad: "✗ Das ist keine gültige @feed-id.",
karvanInviteFail: "✗ Einladung konnte nicht gesendet werden.",
karvanInvitePlaceholder: "Kontakt per @feed-id einladen…",
karvanInviteSent: "✓ Einladung an den Posteingang gesendet.",
karvanNone: "Keine aktiven Räume. Erstelle einen — er verschwindet von selbst.",
karvanRelay: "über den Server",
karvanRoomName: "Raumname (optional)",
karvanRooms: "Räume",
karvanSay: "Temporäre Nachricht…",
karvanSend: "Senden",
karvanTitle: "Karvan",
karvanTtl: "Selbstzerstörung in:",
modulesKarvanDescription: "Flüchtige Räume: Nachrichten leben nur im Speicher und zerstören sich selbst.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "In Benutzung",
identitiesBackup: "Identität exportieren oder importieren",
identitiesCreate: "Identität erstellen",
identitiesDescription: "Jede Identität liegt in einem eigenen Ordner, mit eigenem Schlüssel, eigener Datenbank und eigenen Blobs. Die Änderung greift beim Neustart.",
identitiesInvalidName: "Ungültiger Name. Buchstaben, Ziffern, Bindestrich und Unterstrich, bis zu 32.",
identitiesNewName: "Name der neuen Identität",
identitiesRestart: "Jetzt neu starten",
identitiesRestartNeeded: "Ausgewählt. Starte Oasis neu, um mit dieser Identität einzutreten.",
identitiesRestarting: "Neustart. Öffne Oasis in ein paar Sekunden erneut.",
identitiesSameFeed: "Teilt den Schlüssel mit einer anderen Identität: niemals von beiden veröffentlichen.",
identitiesTitle: "Identitäten",
identitiesUse: "Diese verwenden",
// Barra inferior
bbAdd: "Hinzufügen",
bbDone: "Fertig",
bbFull: "Maximum erreicht",
bbManage: "Bearbeiten",
bbPickDesc: "Wähle bis zu 4 Verknüpfungen für die untere Leiste.",
bbPickNone: "Keine",
bbPickTitle: "Leiste anpassen",
bbQuickActions: "Schnellzugriff",
bbRemove: "Entfernen",
bbYourBar: "Deine Leiste",
// Menu y filtros
menuAll: "Alle",
menuBlogs: "Blogs",
menuSocial: "Sozial",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Kategorie",
bookmarkCategoryPlaceholder: "Optional",
chatReply: "Antworten",
chatShareUrl: "Link teilen",
filter: "Filtern",
forumFilterHot: "Angesagt",
peerLastChange: "Letzte Änderung",
profileVisibilityDevice: "Gerät",
publishBlog: "Blog veröffentlichen",
spreadHint: "Verbreite dies an deine Unterstützer (repliziert über deinen Feed).",
spreadMore: "mehr"
},
it: {
// Karvan
karvanActive: "Stanze attive",
karvanBlurb: "Stanze che vivono solo in memoria e si distruggono da sole.",
karvanCall: "Chiama",
karvanCreate: "Crea stanza effimera",
karvanEphemeral: "Stanze effimere",
karvanInvite: "Invita",
karvanInviteBad: "✗ Non è un @feed-id valido.",
karvanInviteFail: "✗ Impossibile inviare l'invito.",
karvanInvitePlaceholder: "Invita un contatto con @feed-id…",
karvanInviteSent: "✓ Invito inviato alla sua casella.",
karvanNone: "Nessuna stanza attiva. Creane una: sparisce da sola.",
karvanRelay: "tramite il server",
karvanRoomName: "Nome della stanza (facoltativo)",
karvanRooms: "Stanze",
karvanSay: "Messaggio temporaneo…",
karvanSend: "Invia",
karvanTitle: "Karvan",
karvanTtl: "Autodistruzione tra:",
modulesKarvanDescription: "Stanze effimere: i messaggi vivono solo in memoria e si autodistruggono.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "In uso",
identitiesBackup: "Esporta o importa un'identità",
identitiesCreate: "Crea identità",
identitiesDescription: "Ogni identità vive nella sua cartella, con la sua chiave, il suo database e i suoi blob. Il cambio si applica al riavvio.",
identitiesInvalidName: "Nome non valido. Lettere, numeri, trattino e trattino basso, fino a 32.",
identitiesNewName: "Nome della nuova identità",
identitiesRestart: "Riavvia ora",
identitiesRestartNeeded: "Selezionata. Riavvia Oasis per entrare con questa identità.",
identitiesRestarting: "Riavvio in corso. Riapri Oasis tra qualche secondo.",
identitiesSameFeed: "Condivide la chiave con un'altra identità: non pubblicare mai da entrambe.",
identitiesTitle: "Identità",
identitiesUse: "Usa questa",
// Barra inferior
bbAdd: "Aggiungi",
bbDone: "Fatto",
bbFull: "Massimo raggiunto",
bbManage: "Modifica",
bbPickDesc: "Scegli fino a 4 scorciatoie per la barra in basso.",
bbPickNone: "Nessuno",
bbPickTitle: "Personalizza la barra",
bbQuickActions: "Azioni rapide",
bbRemove: "Rimuovi",
bbYourBar: "La tua barra",
// Menu y filtros
menuAll: "Tutto",
menuBlogs: "Blog",
menuSocial: "Sociale",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Categoria",
bookmarkCategoryPlaceholder: "Facoltativo",
chatReply: "Rispondi",
chatShareUrl: "Condividi link",
filter: "Filtra",
forumFilterHot: "Caldo",
peerLastChange: "Ultima modifica",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Pubblica blog",
spreadHint: "Diffondi questo tra chi ti segue (si replica tramite il tuo feed).",
spreadMore: "altro"
},
pt: {
// Karvan
karvanActive: "Salas ativas",
karvanBlurb: "Salas que vivem apenas em memória e destroem-se sozinhas.",
karvanCall: "Chamar",
karvanCreate: "Criar sala efémera",
karvanEphemeral: "Salas efémeras",
karvanInvite: "Convidar",
karvanInviteBad: "✗ Isso não é um @feed-id válido.",
karvanInviteFail: "✗ Não foi possível enviar o convite.",
karvanInvitePlaceholder: "Convidar um contacto por @feed-id…",
karvanInviteSent: "✓ Convite enviado para a caixa de entrada.",
karvanNone: "Não há salas ativas. Cria uma: desaparece sozinha.",
karvanRelay: "através do servidor",
karvanRoomName: "Nome da sala (opcional)",
karvanRooms: "Salas",
karvanSay: "Mensagem temporária…",
karvanSend: "Enviar",
karvanTitle: "Karvan",
karvanTtl: "Autodestrói-se em:",
modulesKarvanDescription: "Salas efémeras: as mensagens vivem apenas em memória e autodestroem-se.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "Em uso",
identitiesBackup: "Exportar ou importar uma identidade",
identitiesCreate: "Criar identidade",
identitiesDescription: "Cada identidade vive na sua própria pasta, com a sua chave, base de dados e blobs. A mudança aplica-se ao reiniciar.",
identitiesInvalidName: "Nome inválido. Letras, números, hífen e sublinhado, até 32.",
identitiesNewName: "Nome da nova identidade",
identitiesRestart: "Reiniciar agora",
identitiesRestartNeeded: "Selecionada. Reinicia o Oasis para entrar com esta identidade.",
identitiesRestarting: "A reiniciar. Volta a abrir o Oasis dentro de alguns segundos.",
identitiesSameFeed: "Partilha a chave com outra identidade: nunca publiques a partir das duas.",
identitiesTitle: "Identidades",
identitiesUse: "Usar esta",
// Barra inferior
bbAdd: "Adicionar",
bbDone: "Concluído",
bbFull: "Máximo atingido",
bbManage: "Editar",
bbPickDesc: "Escolhe até 4 atalhos para a barra inferior.",
bbPickNone: "Nenhum",
bbPickTitle: "Personalizar a barra",
bbQuickActions: "Ações rápidas",
bbRemove: "Remover",
bbYourBar: "A tua barra",
// Menu y filtros
menuAll: "Tudo",
menuBlogs: "Blogues",
menuSocial: "Social",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Categoria",
bookmarkCategoryPlaceholder: "Opcional",
chatReply: "Responder",
chatShareUrl: "Partilhar ligação",
filter: "Filtrar",
forumFilterHot: "Em alta",
peerLastChange: "Última alteração",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Publicar blogue",
spreadHint: "Difunde isto entre quem te segue (replica-se pelo teu feed).",
spreadMore: "mais"
},
zh: {
// Karvan
karvanActive: "活跃房间",
karvanBlurb: "只存在于内存中、会自行销毁的房间。",
karvanCall: "通话",
karvanCreate: "创建临时房间",
karvanEphemeral: "临时房间",
karvanInvite: "邀请",
karvanInviteBad: "✗ 这不是有效的 @feed-id。",
karvanInviteFail: "✗ 无法发送邀请。",
karvanInvitePlaceholder: "用 @feed-id 邀请联系人…",
karvanInviteSent: "✓ 邀请已发送至对方收件箱。",
karvanNone: "没有活跃房间。创建一个吧——它会自行消失。",
karvanRelay: "经服务器中转",
karvanRoomName: "房间名称(可选)",
karvanRooms: "房间",
karvanSay: "临时消息…",
karvanSend: "发送",
karvanTitle: "Karvan",
karvanTtl: "自动销毁于:",
modulesKarvanDescription: "临时房间:消息只存在于内存中,并会自动销毁。",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "使用中",
identitiesBackup: "导出或导入身份",
identitiesCreate: "创建身份",
identitiesDescription: "每个身份都有自己的文件夹,包含各自的密钥、数据库和 blob。更改在重启后生效。",
identitiesInvalidName: "名称无效。字母、数字、连字符和下划线,最多 32 个字符。",
identitiesNewName: "新身份的名称",
identitiesRestart: "立即重启",
identitiesRestartNeeded: "已选择。重启 Oasis 以使用该身份进入。",
identitiesRestarting: "正在重启。请过几秒再打开 Oasis。",
identitiesSameFeed: "与另一个身份共用密钥:切勿从两者同时发布。",
identitiesTitle: "身份",
identitiesUse: "使用这个",
// Barra inferior
bbAdd: "添加",
bbDone: "完成",
bbFull: "已达上限",
bbManage: "编辑",
bbPickDesc: "为底部工具栏选择最多 4 个快捷方式。",
bbPickNone: "无",
bbPickTitle: "自定义工具栏",
bbQuickActions: "快捷操作",
bbRemove: "移除",
bbYourBar: "你的工具栏",
// Menu y filtros
menuAll: "全部",
menuBlogs: "博客",
menuSocial: "社群",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "分类",
bookmarkCategoryPlaceholder: "可选",
chatReply: "回复",
chatShareUrl: "分享链接",
filter: "筛选",
forumFilterHot: "热门",
peerLastChange: "最后变更",
profileVisibilityDevice: "设备",
publishBlog: "发布博客",
spreadHint: "把它扩散给支持你的人(通过你的 feed 复制传播)。",
spreadMore: "更多"
},
ar: {
// Karvan
karvanActive: "الغرف النشطة",
karvanBlurb: "غرف تعيش في الذاكرة فقط وتدمّر نفسها بنفسها.",
karvanCall: "اتصال",
karvanCreate: "إنشاء غرفة مؤقتة",
karvanEphemeral: "غرف مؤقتة",
karvanInvite: "دعوة",
karvanInviteBad: "✗ هذا ليس @feed-id صالحًا.",
karvanInviteFail: "✗ تعذّر إرسال الدعوة.",
karvanInvitePlaceholder: "ادعُ جهة اتصال عبر @feed-id…",
karvanInviteSent: "✓ تم إرسال الدعوة إلى صندوق الوارد.",
karvanNone: "لا توجد غرف نشطة. أنشئ واحدة، وستختفي وحدها.",
karvanRelay: "عبر الخادم",
karvanRoomName: "اسم الغرفة (اختياري)",
karvanRooms: "الغرف",
karvanSay: "رسالة مؤقتة…",
karvanSend: "إرسال",
karvanTitle: "Karvan",
karvanTtl: "التدمير الذاتي خلال:",
modulesKarvanDescription: "غرف مؤقتة: الرسائل تعيش في الذاكرة فقط وتدمّر نفسها.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "قيد الاستخدام",
identitiesBackup: "تصدير هوية أو استيرادها",
identitiesCreate: "إنشاء هوية",
identitiesDescription: "كل هوية تعيش في مجلدها الخاص، بمفتاحها وقاعدة بياناتها وملفاتها. يسري التغيير عند إعادة التشغيل.",
identitiesInvalidName: "اسم غير صالح. حروف وأرقام وشرطة وشرطة سفلية، حتى 32 حرفًا.",
identitiesNewName: "اسم الهوية الجديدة",
identitiesRestart: "أعد التشغيل الآن",
identitiesRestartNeeded: "تم الاختيار. أعد تشغيل Oasis للدخول بهذه الهوية.",
identitiesRestarting: "جارٍ إعادة التشغيل. افتح Oasis مجددًا بعد ثوانٍ.",
identitiesSameFeed: "تشترك في المفتاح مع هوية أخرى: لا تنشر أبدًا من كلتيهما.",
identitiesTitle: "الهويات",
identitiesUse: "استخدم هذه",
// Barra inferior
bbAdd: "إضافة",
bbDone: "تم",
bbFull: "بلغت الحد الأقصى",
bbManage: "تحرير",
bbPickDesc: "اختر حتى 4 اختصارات للشريط السفلي.",
bbPickNone: "لا شيء",
bbPickTitle: "تخصيص الشريط",
bbQuickActions: "إجراءات سريعة",
bbRemove: "إزالة",
bbYourBar: "شريطك",
// Menu y filtros
menuAll: "الكل",
menuBlogs: "المدونات",
menuSocial: "اجتماعي",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "الفئة",
bookmarkCategoryPlaceholder: "اختياري",
chatReply: "رد",
chatShareUrl: "مشاركة الرابط",
filter: "تصفية",
forumFilterHot: "الأكثر تفاعلًا",
peerLastChange: "آخر تغيير",
profileVisibilityDevice: "الجهاز",
publishBlog: "نشر المدونة",
spreadHint: "انشر هذا بين مؤيديك (يُنسخ عبر تدفقك).",
spreadMore: "المزيد"
},
hi: {
// Karvan
karvanActive: "सक्रिय कक्ष",
karvanBlurb: "ऐसे कक्ष जो केवल मेमोरी में रहते हैं और स्वयं नष्ट हो जाते हैं।",
karvanCall: "कॉल",
karvanCreate: "अस्थायी कक्ष बनाएँ",
karvanEphemeral: "अस्थायी कक्ष",
karvanInvite: "आमंत्रित करें",
karvanInviteBad: "✗ यह मान्य @feed-id नहीं है।",
karvanInviteFail: "✗ आमंत्रण नहीं भेजा जा सका।",
karvanInvitePlaceholder: "@feed-id से संपर्क आमंत्रित करें…",
karvanInviteSent: "✓ आमंत्रण उनके इनबॉक्स में भेजा गया।",
karvanNone: "कोई सक्रिय कक्ष नहीं। एक बनाएँ — यह स्वयं गायब हो जाएगा।",
karvanRelay: "सर्वर के माध्यम से",
karvanRoomName: "कक्ष का नाम (वैकल्पिक)",
karvanRooms: "कक्ष",
karvanSay: "अस्थायी संदेश…",
karvanSend: "भेजें",
karvanTitle: "Karvan",
karvanTtl: "स्वतः नष्ट होगा:",
modulesKarvanDescription: "अस्थायी कक्ष: संदेश केवल मेमोरी में रहते हैं और स्वयं नष्ट हो जाते हैं।",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "उपयोग में",
identitiesBackup: "पहचान निर्यात या आयात करें",
identitiesCreate: "पहचान बनाएँ",
identitiesDescription: "हर पहचान अपने अलग फ़ोल्डर में रहती है, अपनी कुंजी, डेटाबेस और ब्लॉब्स के साथ। बदलाव पुनः आरंभ करने पर लागू होता है।",
identitiesInvalidName: "अमान्य नाम। अक्षर, अंक, हाइफ़न और अंडरस्कोर, अधिकतम 32।",
identitiesNewName: "नई पहचान का नाम",
identitiesRestart: "अभी पुनः आरंभ करें",
identitiesRestartNeeded: "चयनित। इस पहचान से प्रवेश करने के लिए Oasis पुनः आरंभ करें।",
identitiesRestarting: "पुनः आरंभ हो रहा है। कुछ सेकंड बाद Oasis फिर खोलें।",
identitiesSameFeed: "किसी अन्य पहचान से कुंजी साझा करती है: दोनों से कभी प्रकाशित न करें।",
identitiesTitle: "पहचानें",
identitiesUse: "इसका उपयोग करें",
// Barra inferior
bbAdd: "जोड़ें",
bbDone: "हो गया",
bbFull: "अधिकतम सीमा",
bbManage: "संपादित करें",
bbPickDesc: "नीचे की बार के लिए अधिकतम 4 शॉर्टकट चुनें।",
bbPickNone: "कोई नहीं",
bbPickTitle: "बार अनुकूलित करें",
bbQuickActions: "त्वरित क्रियाएँ",
bbRemove: "हटाएँ",
bbYourBar: "आपकी बार",
// Menu y filtros
menuAll: "सभी",
menuBlogs: "ब्लॉग",
menuSocial: "सामाजिक",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "श्रेणी",
bookmarkCategoryPlaceholder: "वैकल्पिक",
chatReply: "उत्तर दें",
chatShareUrl: "लिंक साझा करें",
filter: "छानें",
forumFilterHot: "चर्चित",
peerLastChange: "अंतिम बदलाव",
profileVisibilityDevice: "डिवाइस",
publishBlog: "ब्लॉग प्रकाशित करें",
spreadHint: "इसे अपने समर्थकों तक फैलाएँ (आपकी फ़ीड से प्रतिकृति होती है)।",
spreadMore: "और"
},
ru: {
// Karvan
karvanActive: "Активные комнаты",
karvanBlurb: "Комнаты, которые живут только в памяти и уничтожаются сами.",
karvanCall: "Позвонить",
karvanCreate: "Создать эфемерную комнату",
karvanEphemeral: "Эфемерные комнаты",
karvanInvite: "Пригласить",
karvanInviteBad: "✗ Это неверный @feed-id.",
karvanInviteFail: "✗ Не удалось отправить приглашение.",
karvanInvitePlaceholder: "Пригласить контакт по @feed-id…",
karvanInviteSent: "✓ Приглашение отправлено во входящие.",
karvanNone: "Активных комнат нет. Создайте — она исчезнет сама.",
karvanRelay: "через сервер",
karvanRoomName: "Название комнаты (необязательно)",
karvanRooms: "Комнаты",
karvanSay: "Временное сообщение…",
karvanSend: "Отправить",
karvanTitle: "Karvan",
karvanTtl: "Самоуничтожение через:",
modulesKarvanDescription: "Эфемерные комнаты: сообщения живут только в памяти и самоуничтожаются.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "Используется",
identitiesBackup: "Экспортировать или импортировать личность",
identitiesCreate: "Создать личность",
identitiesDescription: "Каждая личность живёт в своей папке — со своим ключом, базой данных и блобами. Изменение вступает в силу после перезапуска.",
identitiesInvalidName: "Недопустимое имя. Буквы, цифры, дефис и подчёркивание, до 32 символов.",
identitiesNewName: "Имя новой личности",
identitiesRestart: "Перезапустить сейчас",
identitiesRestartNeeded: "Выбрано. Перезапустите Oasis, чтобы войти с этой личностью.",
identitiesRestarting: "Перезапуск. Откройте Oasis снова через несколько секунд.",
identitiesSameFeed: "Ключ совпадает с другой личностью: никогда не публикуйте с обеих.",
identitiesTitle: "Личности",
identitiesUse: "Использовать эту",
// Barra inferior
bbAdd: "Добавить",
bbDone: "Готово",
bbFull: "Достигнут максимум",
bbManage: "Изменить",
bbPickDesc: "Выберите до 4 ярлыков для нижней панели.",
bbPickNone: "Нет",
bbPickTitle: "Настроить панель",
bbQuickActions: "Быстрые действия",
bbRemove: "Убрать",
bbYourBar: "Ваша панель",
// Menu y filtros
menuAll: "Все",
menuBlogs: "Блоги",
menuSocial: "Социальное",
// Rescatadas de 0.9.1 y otras
bookmarkCategoryLabel: "Категория",
bookmarkCategoryPlaceholder: "Необязательно",
chatReply: "Ответить",
chatShareUrl: "Поделиться ссылкой",
filter: "Фильтр",
forumFilterHot: "Горячее",
peerLastChange: "Последнее изменение",
profileVisibilityDevice: "Устройство",
publishBlog: "Опубликовать блог",
spreadHint: "Распространите это среди тех, кто вас читает (реплицируется через вашу ленту).",
spreadMore: "ещё"
}
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -42,7 +42,7 @@ const collectLocalIPs = () => {
module.exports = ({ host, port, middleware, allowHost }) => { module.exports = ({ host, port, middleware, allowHost }) => {
const assets = new Koa() const assets = new Koa()
assets.use(koaStatic(join(__dirname, "..", "client", "assets"))); assets.use(koaStatic(join(__dirname, "..", "client", "assets"), { maxage: 60 * 60 * 1000 }));
const app = new Koa(); const app = new Koa();
const validHosts = []; const validHosts = [];
@ -79,12 +79,14 @@ module.exports = ({ host, port, middleware, allowHost }) => {
return true; return true;
}; };
const httpDebug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
app.on("error", (err, ctx) => { app.on("error", (err, ctx) => {
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) { if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
return; return;
} }
if (err && (err.name === 'BadRequestError' || err.status === 400)) { if (err && (err.name === 'BadRequestError' || err.status === 400)) {
console.error(`[400] ${err.message}`); if (httpDebug) console.error(`[400] ${err.message}`);
return null; return null;
} }
console.error(err); console.error(err);
@ -114,8 +116,8 @@ module.exports = ({ host, port, middleware, allowHost }) => {
app.use(koaStatic(path.join(__dirname, 'public'))); app.use(koaStatic(path.join(__dirname, 'public')));
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
//console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests if (httpDebug) console.log(`[http] ${ctx.method} ${ctx.path}`);
const isClearnet = isClearnetPath(ctx.request); const isClearnet = isClearnetPath(ctx.request);
const csp = isClearnet const csp = isClearnet

View file

@ -0,0 +1,316 @@
/* KARVAN client mensajes temporales + WebRTC (data-channel).
* Capa fiable: relay por servidor (RAM, autodestrucción) con polling.
* Capa turbo: data-channel WebRTC P2P (instantáneo) con "perfect negotiation".
* Todo degrada: si no hay WebRTC o no conecta, el chat sigue por el relay.
* Sin cámara/mic (solo data-channel) => no necesita permisos del wrapper. */
(function () {
"use strict";
var root = document.querySelector(".karvan-room");
if (!root) return;
var roomId = root.getAttribute("data-room");
var selfId = root.getAttribute("data-self") || "";
var peerId = "p" + Math.random().toString(16).slice(2, 12);
var fromLabel = selfId || peerId;
var listEl = document.getElementById("karvan-msgs");
var formEl = document.getElementById("karvan-form");
var textEl = document.getElementById("karvan-text");
var liveTxt = document.getElementById("karvan-live-txt");
var liveWrap = document.getElementById("karvan-live");
var seen = Object.create(null);
var lastSeq = 0;
var lastSig = 0;
var peers = Object.create(null);
// --- Fase 1: videollamada (media sobre el MISMO WebRTC del chat) ---
var callRoot = document.getElementById("karvan-call");
var localVid = document.getElementById("karvan-local");
var remoteEl = document.getElementById("karvan-remote");
var statusEl = document.getElementById("kc-status");
var btnStart = document.getElementById("kc-start");
var btnMic = document.getElementById("kc-mic");
var btnCam = document.getElementById("kc-cam");
var btnStop = document.getElementById("kc-stop");
var localStream = (window.MediaStream ? new MediaStream() : null);
if (localVid && localStream) localVid.srcObject = localStream;
var micActive = false, camActive = false;
function esc(s) { return String(s == null ? "" : s); }
function shortId(id) { id = esc(id || "?"); return id.charAt(0) === "@" ? id.slice(1, 7) : id.slice(0, 6); }
function showMsg(m, live) {
var key = m.mid ? "m" + m.mid : "s" + (m.seq || Math.random());
if (seen[key]) return;
seen[key] = 1;
var li = document.createElement("li");
li.className = "karvan-msg" + (m.from === fromLabel ? " karvan-msg-self" : "") + (live ? " karvan-msg-live" : "");
var f = document.createElement("span"); f.className = "km-from"; f.textContent = shortId(m.from);
var t = document.createElement("span"); t.className = "km-text"; t.textContent = esc(m.text);
li.appendChild(f); li.appendChild(t);
listEl.appendChild(li);
listEl.scrollTop = listEl.scrollHeight;
}
// --- relay por servidor (siempre activo) ---
function pollMsgs() {
fetch("/karvan/" + roomId + "/msgs?since=" + lastSeq, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.messages) return;
d.messages.forEach(function (m) { if (m.seq > lastSeq) lastSeq = m.seq; showMsg(m, false); });
})
.catch(function () {});
}
function sendMessage(text) {
if (!text) return;
var mid = peerId + "-" + Date.now().toString(36) + Math.random().toString(16).slice(2, 6);
var m = { mid: mid, from: fromLabel, text: text };
showMsg(m, false); // optimista
broadcastDC({ kind: "chat", mid: mid, from: fromLabel, text: text }); // instantáneo a los pares
fetch("/karvan/" + roomId + "/msg", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: fromLabel, text: text, mid: mid })
}).catch(function () {});
}
if (formEl) {
formEl.addEventListener("submit", function (e) {
e.preventDefault();
var v = (textEl.value || "").trim();
if (!v) return;
textEl.value = "";
sendMessage(v);
});
}
// --- señalización (mailbox RAM del servidor) ---
function sendSig(to, payload) {
fetch("/karvan/" + roomId + "/signal", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: peerId, to: to || "", payload: payload })
}).catch(function () {});
}
function announce() { sendSig("", { kind: "hello" }); }
function pollSignals() {
fetch("/karvan/" + roomId + "/signal?for=" + peerId + "&since=" + lastSig, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
(d.signals || []).forEach(function (s) {
if (s.seq > lastSig) lastSig = s.seq;
onSignal(s.from, s.payload);
});
(d.members || []).forEach(function (mid2) { if (mid2 && mid2 !== peerId) ensurePeer(mid2); });
})
.catch(function () {});
}
// --- WebRTC (data-channel, perfect negotiation) ---
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection;
// Sin servidores ICE por defecto: solo candidatos host (LAN y NAT amable).
// Un STUN de terceros aprende la IP publica y el momento de cada llamada, asi que
// no se pone ninguno de fabrica. Si el nodo ofrece TURN propio, /karvan/ice lo da.
var rtcConfig = { iceServers: [] };
function setLive(on) {
if (!liveTxt) return;
liveTxt.textContent = on ? "🔒 P2P (WebRTC)" : "server relay";
if (liveWrap) liveWrap.className = "karvan-live" + (on ? " on" : "");
}
function broadcastDC(obj) {
var s = JSON.stringify(obj);
for (var k in peers) {
var dc = peers[k] && peers[k].dc;
if (dc && dc.readyState === "open") { try { dc.send(s); } catch (e) {} }
}
}
function attachDC(peer, dc) {
peer.dc = dc;
dc.onopen = function () { setLive(true); };
dc.onmessage = function (e) {
try { var m = JSON.parse(e.data); if (m && m.kind === "chat") showMsg(m, true); } catch (x) {}
};
dc.onclose = function () {
var any = false;
for (var k in peers) if (peers[k].dc && peers[k].dc.readyState === "open") any = true;
if (!any) setLive(false);
};
}
function ensurePeer(remoteId) {
if (peers[remoteId] || !RTC) return peers[remoteId];
var polite = peerId > remoteId; // el id mayor es "polite"
var pc = new RTC(rtcConfig);
var peer = { pc: pc, dc: null, polite: polite, makingOffer: false, ignoreOffer: false, vid: null, remoteStream: null };
peers[remoteId] = peer;
if (!polite) { attachDC(peer, pc.createDataChannel("chat")); } // el impolite inicia el canal
pc.ondatachannel = function (e) { attachDC(peer, e.channel); };
// media: publicar los tracks locales que ya tengamos (si la llamada está activa) + recibir los del par
if (localStream) { localStream.getTracks().forEach(function (t) { try { pc.addTrack(t, localStream); } catch (e) {} }); }
pc.ontrack = function (e) { attachRemote(peer, remoteId, e); };
pc.onicecandidate = function (e) { if (e.candidate) sendSig(remoteId, { kind: "ice", candidate: e.candidate }); };
pc.onnegotiationneeded = function () {
peer.makingOffer = true;
Promise.resolve()
.then(function () { return pc.setLocalDescription(); })
.then(function () { sendSig(remoteId, { kind: "desc", desc: pc.localDescription }); })
.catch(function () {})
.then(function () { peer.makingOffer = false; });
};
pc.onconnectionstatechange = function () {
var st = pc.connectionState;
if (st === "failed" || st === "closed") { try { pc.close(); } catch (e) {} removeRemoteVideo(peer); delete peers[remoteId]; }
};
return peer;
}
// --- media / videollamada (Fase 1) ---
function setCallActive(on) { if (callRoot) callRoot.className = "karvan-call" + (on ? " active" : ""); }
function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
function statusText() { var p = []; if (micActive) p.push("mic ON"); if (camActive) p.push("cam ON"); return p.length ? p.join(" · ") : ""; }
function updateBtns() {
if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : "");
if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : "");
}
function attachRemote(peer, remoteId, e) {
if (!remoteEl) return;
if (!peer.vid) {
peer.vid = document.createElement("video");
peer.vid.autoplay = true; peer.vid.playsInline = true; peer.vid.className = "kc-video kc-remote-vid";
peer.vid.setAttribute("data-peer", remoteId);
remoteEl.appendChild(peer.vid);
}
if (e.streams && e.streams[0]) peer.vid.srcObject = e.streams[0];
else { if (!peer.remoteStream) peer.remoteStream = new MediaStream(); peer.remoteStream.addTrack(e.track); peer.vid.srcObject = peer.remoteStream; }
setCallActive(true);
}
function removeRemoteVideo(peer) {
if (peer && peer.vid && peer.vid.parentNode) { try { peer.vid.parentNode.removeChild(peer.vid); } catch (_) {} peer.vid = null; }
}
function addLocalTrack(track) {
if (!localStream) return;
localStream.addTrack(track);
for (var k in peers) {
var pc = peers[k].pc, senders = pc.getSenders(), existing = null;
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === track.kind) { existing = senders[s]; break; }
if (existing) { try { existing.replaceTrack(track); } catch (_) {} } // swap (cambio de cámara) — sin renegociar
else { try { pc.addTrack(track, localStream); } catch (_) {} } // kind nuevo — dispara onnegotiationneeded
}
}
function removeLocalKind(kind) {
if (!localStream) return;
localStream.getTracks().filter(function (t) { return t.kind === kind; }).forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) {
var senders = peers[k].pc.getSenders();
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === kind) { try { senders[s].replaceTrack(null); } catch (_) {} }
}
}
function onMediaErr(kind, x) {
var name = (x && x.name) || "", msg = (x && x.message) || String(x);
if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus("⛔ Cámara/micro denegados (en el móvil, pendiente del wrapper). El chat sigue.");
else if (name === "NotFoundError") setStatus("⚠ No hay " + (kind === "audio" ? "micrófono" : "cámara") + ".");
else if (name === "NotReadableError") setStatus("⚠ Dispositivo ocupado.");
else setStatus("⚠ " + (name || "error") + ": " + msg);
}
function getMedia(kind) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
var c = (kind === "audio") ? { audio: true, video: false } : { audio: false, video: true };
setStatus((kind === "audio" ? "🎤" : "🎥") + " pidiendo permiso…");
navigator.mediaDevices.getUserMedia(c).then(function (s) {
s.getTracks().forEach(addLocalTrack);
if (kind === "audio") micActive = true; else camActive = true;
setCallActive(true); updateBtns(); setStatus(statusText());
}).catch(function (x) { onMediaErr(kind, x); });
}
function startCall() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
setStatus("📞 pidiendo cámara y micro…");
navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function (s) {
s.getTracks().forEach(addLocalTrack);
micActive = true; camActive = true; setCallActive(true); updateBtns(); setStatus(statusText());
}).catch(function (x) {
if (x && x.name === "NotFoundError") { getMedia("audio"); return; } // sin cámara → intenta solo audio
onMediaErr("call", x);
});
}
function hangUp() {
if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) { var sn = peers[k].pc.getSenders(); for (var s = 0; s < sn.length; s++) { try { sn[s].replaceTrack(null); } catch (_) {} } }
micActive = false; camActive = false; setCallActive(false); updateBtns(); setStatus("");
}
if (btnStart) btnStart.addEventListener("click", startCall);
if (btnMic) btnMic.addEventListener("click", function () { if (micActive) { removeLocalKind("audio"); micActive = false; updateBtns(); setStatus(statusText()); } else getMedia("audio"); });
if (btnCam) btnCam.addEventListener("click", function () { if (camActive) { removeLocalKind("video"); camActive = false; updateBtns(); setStatus(statusText()); } else getMedia("video"); });
if (btnStop) btnStop.addEventListener("click", hangUp);
function onSignal(fromRemote, payload) {
if (!RTC || !payload) return;
var peer = ensurePeer(fromRemote);
if (!peer) return;
var pc = peer.pc;
if (payload.kind === "desc") {
var desc = payload.desc;
if (!desc) return; // señal malformada (sin desc) — no reventar el lote de sondeo
var collision = desc.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable");
peer.ignoreOffer = !peer.polite && collision;
if (peer.ignoreOffer) return;
Promise.resolve()
.then(function () { return pc.setRemoteDescription(desc); })
.then(function () {
if (desc.type === "offer") {
return pc.setLocalDescription().then(function () {
sendSig(fromRemote, { kind: "desc", desc: pc.localDescription });
});
}
})
.catch(function () {});
} else if (payload.kind === "ice") {
Promise.resolve().then(function () { return pc.addIceCandidate(payload.candidate); }).catch(function () {});
}
}
// --- arranque ---
showMsgInitialSeq();
function showMsgInitialSeq() {
// registrar los mensajes ya renderizados por el servidor para no duplicarlos
var items = listEl ? listEl.querySelectorAll(".karvan-msg") : [];
for (var i = 0; i < items.length; i++) {
var seq = parseInt(items[i].getAttribute("data-seq"), 10) || 0;
var mid = items[i].getAttribute("data-mid") || "";
if (seq > lastSeq) lastSeq = seq;
seen[mid ? "m" + mid : "s" + seq] = 1;
}
}
pollMsgs();
setInterval(pollMsgs, 2000);
if (RTC) {
setLive(false);
// La configuracion ICE tiene que estar resuelta antes de la primera
// RTCPeerConnection: no se puede cambiar de forma fiable una ya creada.
var iceReady = fetch("/karvan/ice", { headers: { accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && Array.isArray(d.iceServers) && d.iceServers.length) {
rtcConfig = { iceServers: d.iceServers };
if (d.relayOnly) rtcConfig.iceTransportPolicy = "relay";
}
})
.catch(function () {});
var iceTimeout = new Promise(function (res) { setTimeout(res, 2000); });
Promise.race([iceReady, iceTimeout]).then(function () {
announce();
setInterval(announce, 8000);
pollSignals();
setInterval(pollSignals, 2000);
});
}
})();

View file

@ -0,0 +1,76 @@
document.addEventListener('DOMContentLoaded', () => {
if (typeof pdfjsLib === 'undefined') return;
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs';
document.querySelectorAll('.pdf-viewer-container').forEach(async container => {
const pdfUrl = container.getAttribute('data-pdf-url');
if (!pdfUrl) return;
const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
let currentPage = 1;
let scale = 1.5;
let rotation = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
container.innerHTML = '';
container.appendChild(canvas);
const controls = document.createElement('div');
controls.className = 'pdf-controls';
controls.innerHTML = `
<button id="prev"></button>
<button id="next"></button>
<button id="zoomIn">🔍+</button>
<button id="zoomOut">🔍</button>
<button id="rotate"></button>
<button id="download"></button>
<button id="fullscreen">🔲</button>
<button id="metadata"></button>
`;
container.appendChild(controls);
const renderPage = async (num) => {
const page = await pdf.getPage(num);
const viewport = page.getViewport({ scale, rotation });
canvas.width = viewport.width;
canvas.height = viewport.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport }).promise;
};
const goToPage = (delta) => {
const newPage = currentPage + delta;
if (newPage >= 1 && newPage <= pdf.numPages) {
currentPage = newPage;
renderPage(currentPage);
}
};
renderPage(currentPage);
controls.querySelector('#prev').onclick = () => goToPage(-1);
controls.querySelector('#next').onclick = () => goToPage(1);
controls.querySelector('#zoomIn').onclick = () => { scale += 0.2; renderPage(currentPage); };
controls.querySelector('#zoomOut').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
controls.querySelector('#rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
controls.querySelector('#download').onclick = () => {
const a = document.createElement('a');
a.href = pdfUrl;
a.download = 'document.pdf';
a.click();
};
controls.querySelector('#fullscreen').onclick = () => {
if (canvas.requestFullscreen) canvas.requestFullscreen();
else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen();
else if (canvas.mozRequestFullScreen) canvas.mozRequestFullScreen();
else if (canvas.msRequestFullscreen) canvas.msRequestFullscreen();
};
controls.querySelector('#metadata').onclick = async () => {
const info = await pdf.getMetadata();
alert(`Title: ${info.info.Title || 'N/A'}\nAuthor: ${info.info.Author || 'N/A'}\nPDF Producer: ${info.info.Producer || 'N/A'}`);
};
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,3 @@
{ {
"discardedItems": [] "discardedItems": []
} }

View file

@ -12,12 +12,16 @@ if (!fs.existsSync(configFilePath)) {
"current": "blocks" "current": "blocks"
}, },
"modules": { "modules": {
"blogsMod": "on",
"housingMod": "off",
"karvanMod": "on",
"popularMod": "on", "popularMod": "on",
"topicsMod": "on", "topicsMod": "on",
"summariesMod": "on", "summariesMod": "on",
"latestMod": "on", "latestMod": "on",
"threadsMod": "on", "threadsMod": "on",
"multiverseMod": "on", "multiverseMod": "on",
"pollsMod": "on",
"fediverseMod": "off", "fediverseMod": "off",
"invitesMod": "on", "invitesMod": "on",
"walletMod": "on", "walletMod": "on",
@ -96,8 +100,10 @@ const getConfig = () => {
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux }; if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' }; if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks'; if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav') cfg.ux.current = 'blocks'; if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks'; if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks';
let pins = Array.isArray(cfg.bottomBarPins) let pins = Array.isArray(cfg.bottomBarPins)
? cfg.bottomBarPins ? cfg.bottomBarPins
: (typeof cfg.bottomBarPin === 'string' : (typeof cfg.bottomBarPin === 'string'

View file

@ -0,0 +1,27 @@
{
"audios": [],
"blogs": [],
"bookmarks": [],
"calendars": [],
"chats": [],
"documents": [],
"events": [],
"forum": [],
"housing": [],
"images": [],
"jobs": [],
"logs": [],
"maps": [],
"market": [],
"pads": [],
"polls": [],
"projects": [],
"reports": [],
"shopProducts": [],
"shops": [],
"tasks": [],
"torrents": [],
"transfers": [],
"videos": [],
"votes": []
}

View file

@ -1,13 +0,0 @@
{
"audios": [],
"bookmarks": [],
"calendars": [],
"chats": [],
"documents": [],
"images": [],
"maps": [],
"pads": [],
"shops": [],
"torrents": [],
"videos": []
}

View file

@ -6,12 +6,8 @@
"current": "blocks" "current": "blocks"
}, },
"modules": { "modules": {
"popularMod": "on", "blogsMod": "on",
"topicsMod": "on", "pollsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"fediverseMod": "on", "fediverseMod": "on",
"invitesMod": "on", "invitesMod": "on",
"walletMod": "off", "walletMod": "off",
@ -34,6 +30,7 @@
"reportsMod": "on", "reportsMod": "on",
"opinionsMod": "on", "opinionsMod": "on",
"padsMod": "on", "padsMod": "on",
"karvanMod": "on",
"calendarsMod": "on", "calendarsMod": "on",
"transfersMod": "off", "transfersMod": "off",
"feedMod": "on", "feedMod": "on",
@ -56,7 +53,8 @@
"chatsMod": "on", "chatsMod": "on",
"torrentsMod": "off", "torrentsMod": "off",
"graphosMod": "on", "graphosMod": "on",
"larpMod": "on" "larpMod": "on",
"housingMod": "off"
}, },
"wallet": { "wallet": {
"url": "http://localhost:7474", "url": "http://localhost:7474",
@ -83,5 +81,14 @@
"inbox", "inbox",
"publish", "publish",
"activity" "activity"
] ],
"rtc": {
"stun": [],
"turn": {
"urls": [],
"secret": "",
"ttlSec": 3600
},
"relayOnly": false
}
} }

View file

@ -9,6 +9,9 @@ let _ecoValue = null;
let _lastActivity = null; let _lastActivity = null;
let _maxBlockBytes = 0; let _maxBlockBytes = 0;
let _inhabitantCount = 0; let _inhabitantCount = 0;
let _mentionsCount = 0;
let _bestMatch = null;
let _dismissedSuggestion = null;
module.exports = { module.exports = {
getInboxCount: () => _inboxCount, getInboxCount: () => _inboxCount,
setInboxCount: (n) => { _inboxCount = n; }, setInboxCount: (n) => { _inboxCount = n; },
@ -30,6 +33,12 @@ module.exports = {
setLastActivity: (a) => { _lastActivity = a; }, setLastActivity: (a) => { _lastActivity = a; },
getMaxBlockBytes: () => _maxBlockBytes, getMaxBlockBytes: () => _maxBlockBytes,
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); }, setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
getMentionsCount: () => _mentionsCount,
setMentionsCount: (n) => { _mentionsCount = Math.max(0, Number(n) || 0); },
getInhabitantCount: () => _inhabitantCount, getInhabitantCount: () => _inhabitantCount,
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); } setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); },
getBestMatch: () => _bestMatch,
setBestMatch: (m) => { _bestMatch = m || null; },
getDismissedSuggestion: () => _dismissedSuggestion,
setDismissedSuggestion: (href) => { _dismissedSuggestion = href || null; }
}; };

View file

@ -81,14 +81,21 @@ pins = json.loads(sys.argv[1])
im = Image.open(sys.argv[2]).copy() im = Image.open(sys.argv[2]).copy()
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
for p in pins: def draw_pin(draw, x, y, main):
x, y, main = p['x'], p['y'], p.get('main', False) sw = 7 if main else 5
sw = 3 if main else 2 sh = 34 if main else 26
sh = 18 if main else 13 head = 11 if main else 8
clr = '#e74c3c' if main else '#3498db' clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9' dark = '#c0392b' if main else '#2980b9'
draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr) cy = y - sh
draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1) draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
for p in pins:
draw_pin(draw, p['x'], p['y'], p.get('main', False))
im.save(sys.argv[3], optimize=True) im.save(sys.argv[3], optimize=True)
`; `;
@ -193,15 +200,23 @@ cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y +
result = cropped.resize((1024, 1024), Image.LANCZOS) result = cropped.resize((1024, 1024), Image.LANCZOS)
draw = ImageDraw.Draw(result) draw = ImageDraw.Draw(result)
def draw_pin(draw, x, y, main):
sw = 7 if main else 5
sh = 34 if main else 26
head = 11 if main else 8
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
cy = y - sh
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
for p in pins: for p in pins:
px, py, main = p['px'], p['py'], p.get('main', False) px, py, main = p['px'], p['py'], p.get('main', False)
if -20 <= px <= 1044 and -20 <= py <= 1044: if -40 <= px <= 1064 and -40 <= py <= 1064:
sw = 3 if main else 2 draw_pin(draw, px, py, main)
sh = 18 if main else 13
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
draw.polygon([(px, py + 2), (px - sw, py - sh + sw * 2), (px + sw, py - sh + sw * 2)], fill=clr)
draw.ellipse([px - sw - 1, py - sh - sw, px + sw + 1, py - sh + sw], fill=dark, outline='white', width=1)
result.save(out_file, optimize=True) result.save(out_file, optimize=True)
`; `;

View file

@ -164,7 +164,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
// ties correctly — the mock and real SSB can stamp equal timestamps. // ties correctly — the mock and real SSB can stamp equal timestamps.
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue; if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
const cc = a.content || {}; const cc = a.content || {};
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') }); stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
} }
const msgsByRoot = new Map(); const msgsByRoot = new Map();
for (const a of idToAction.values()) { for (const a of idToAction.values()) {
@ -181,7 +181,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const val = await getMsg(ssbClient, root); const val = await getMsg(ssbClient, root);
const cc = val && val.content; const cc = val && val.content;
if (cc && typeof cc === 'object' && cc.type === 'chat') { if (cc && typeof cc === 'object' && cc.type === 'chat') {
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') }); stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
} else { } else {
stateByRoot.set(root, null); stateByRoot.set(root, null);
} }
@ -202,6 +202,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
chatRoot: root, chatRoot: root,
title: info.title || '', title: info.title || '',
description: info.description || '', description: info.description || '',
members: info.members || 0,
messageCount: asc.length,
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' })) replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
} }
}); });
@ -251,6 +253,21 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const idToAction = new Map(); const idToAction = new Map();
const rawById = new Map(); const rawById = new Map();
const voteTally = buildVoteTally(results); const voteTally = buildVoteTally(results);
const pollTally = new Map();
for (const m of results) {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'pollVote' || typeof c.target !== 'string') continue;
if (!Array.isArray(c.choices)) continue;
const entry = pollTally.get(c.target) || { counts: {}, voters: new Map() };
entry.voters.set(m.value.author, { ts: m.value.timestamp || 0, choices: c.choices });
pollTally.set(c.target, entry);
}
for (const entry of pollTally.values()) {
entry.counts = {};
for (const v of entry.voters.values()) {
for (const choice of v.choices) entry.counts[choice] = (entry.counts[choice] || 0) + 1;
}
}
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
const accessibleTribeIds = await buildAccessibleTribeIds(); const accessibleTribeIds = await buildAccessibleTribeIds();
@ -577,6 +594,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const actionRoot = rootOf(a.id); const actionRoot = rootOf(a.id);
const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {}; const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null; const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
if (a.type === 'poll') {
const tally = pollTally.get(a.id) || pollTally.get(actionRoot);
a.pollCounts = tally ? tally.counts : {};
a.pollVoters = tally ? tally.voters.size : 0;
}
let content = voteAgg ? { ...c, ...voteAgg } : a.content; let content = voteAgg ? { ...c, ...voteAgg } : a.content;
if (a.type === 'feed') { if (a.type === 'feed') {
const base = a.content || {}; const base = a.content || {};
@ -688,12 +710,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
} }
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']); const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']); const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'logPublic', 'gameScore', 'pollVote', 'pollClose', 'pollOpinion', 'curriculum']);
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped); const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
deduped = deduped.concat(chatThreadItems); deduped = deduped.concat(chatThreadItems);
const isAllowedTribeActivity = (a) => { const isAllowedTribeActivity = (a) => {
if (tribeInternalTypes.has(a.type)) return false; if (tribeInternalTypes.has(a.type)) return false;
const c = a.content || {}; const c = a.content || {};
if (a.type === 'poll' && c.chatId) return false;
if (c.tribeId) return false; if (c.tribeId) return false;
if (a.type === 'tribe') { if (a.type === 'tribe') {
const isInitial = !c.replaces; const isInitial = !c.replaces;
@ -704,8 +727,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
} }
return true; return true;
}; };
const itemVisible = (a) => { const itemVisible = (a, opts = {}) => {
if (hiddenTypes.has(a.type)) return false; if (hiddenTypes.has(a.type) && !(opts.allowHidden && opts.allowHidden.has(a.type))) return false;
const c = a.content || {}; const c = a.content || {};
if (c.encryptedPayload) return false; if (c.encryptedPayload) return false;
if (a.type === 'pad' && c.status !== 'OPEN') return false; if (a.type === 'pad' && c.status !== 'OPEN') return false;
@ -715,6 +738,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false; if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false; if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false; if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
if (a.type === 'housing' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false; if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false; if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false; if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
@ -722,8 +746,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false; if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
return true; return true;
}; };
const isVisible = (a) => { const isVisible = (a, opts) => {
if (!itemVisible(a)) return false; if (!itemVisible(a, opts)) return false;
if (a.type === 'post' || a.type === 'opinion') { if (a.type === 'post' || a.type === 'opinion') {
const c = a.content || {}; const c = a.content || {};
const ref = (typeof c.root === 'string' && c.root) const ref = (typeof c.root === 'string' && c.root)
@ -742,6 +766,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a)); if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) } else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a)); else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'top') {
const visible = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
const perAuthor = new Map();
for (const a of visible) perAuthor.set(a.author, (perAuthor.get(a.author) || 0) + 1);
out = visible
.map(a => ({ ...a, authorActions: perAuthor.get(a.author) || 0 }))
.sort((x, y) => (y.authorActions - x.authorActions) || ((y.ts || 0) - (x.ts || 0)));
}
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim'); else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe')); else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread'); else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
@ -756,6 +788,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}); });
else if (filter === 'task') else if (filter === 'task')
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment'); out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
else if (filter === 'votes')
out = deduped.filter(a => (a.type === 'votes' || a.type === 'poll') && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'industry') else if (filter === 'industry')
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a)); out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN'); else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
@ -764,7 +798,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a)); else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
else out = deduped.filter(a => a.type === filter); else out = deduped.filter(a => a.type === filter);
out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); if (filter !== 'top') out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return out; return out;
})(); })();
_feedCacheInflight.set(cacheKey, promise); _feedCacheInflight.set(cacheKey, promise);

View file

@ -19,7 +19,7 @@ function writeAgendaConfig(cfg) {
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2)); fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
} }
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }) => { module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel }) => {
let ssb; let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
@ -188,7 +188,11 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
? industryModel.listMyBuilds().then(normalize).catch(() => []) ? industryModel.listMyBuilds().then(normalize).catch(() => [])
: []; : [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll] = await Promise.all([ const housingViaModel = housingModel && typeof housingModel.listHousing === 'function'
? housingModel.listHousing('ALL', userId).then(normalize).catch(() => [])
: [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll] = await Promise.all([
tasksViaModel, tasksViaModel,
eventsViaModel, eventsViaModel,
fetchItems('transfer'), fetchItems('transfer'),
@ -198,7 +202,8 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
jobsViaModel, jobsViaModel,
projectsViaModel, projectsViaModel,
calendarsViaModel, calendarsViaModel,
industryViaModel industryViaModel,
housingViaModel
]); ]);
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' })); const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
@ -212,6 +217,9 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title })); const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
const projects = projectsAll.map(p => ({ ...p, type: 'project' })); const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title })); const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title }));
const housingPlaces = housingAll
.filter(h => h.author === userId || (Array.isArray(h.requests) && h.requests.includes(userId)))
.map(h => ({ ...h, type: 'housing', date: h.availableFrom || h.createdAt, requested: Array.isArray(h.requests) && h.requests.includes(userId) }));
const myCalendars = calendarsAll const myCalendars = calendarsAll
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId))); .filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' })); const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
@ -248,6 +256,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
...jobs, ...jobs,
...projects, ...projects,
...industryBuilds, ...industryBuilds,
...housingPlaces,
...calendars, ...calendars,
...calendarDates ...calendarDates
]; ];
@ -268,6 +277,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job'); else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project'); else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry'); else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry');
else if (filter === 'housing') filtered = filtered.filter(i => i.type === 'housing');
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate'); else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
else if (filter === 'today') { else if (filter === 'today') {
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
@ -322,6 +332,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
jobs: mainItems.filter(i => i.type === 'job').length, jobs: mainItems.filter(i => i.type === 'job').length,
projects: mainItems.filter(i => i.type === 'project').length, projects: mainItems.filter(i => i.type === 'project').length,
industry: mainItems.filter(i => i.type === 'industry').length, industry: mainItems.filter(i => i.type === 'industry').length,
housing: mainItems.filter(i => i.type === 'housing').length,
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length, calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length, today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
upcoming: mainItems.filter(i => itemTs(i) > now).length, upcoming: mainItems.filter(i => itemTs(i) > now).length,

168
src/models/blog_model.js Normal file
View file

@ -0,0 +1,168 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const OPINION_TYPE = 'blogOpinion';
module.exports = ({ cooler, isPublic = false }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const isRootPost = (c) =>
!!c && c.type === 'post' && typeof c.text === 'string' &&
!c.root && !c.fork && !c.branch && !c.about && !c.target;
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const posts = new Map();
const commentsByRoot = new Map();
const opinionsByRoot = new Map();
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object') continue;
if (tomb.has(m.key)) continue;
if (c.type === 'post' && typeof c.text === 'string') {
if (isRootPost(c)) {
posts.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
} else if (typeof c.root === 'string') {
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
}
continue;
}
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
const entry = opinionsByRoot.get(c.target) || { counts: {}, voters: [] };
if (!entry.voters.includes(v.author)) {
entry.voters.push(v.author);
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
}
opinionsByRoot.set(c.target, entry);
}
}
return { tomb, posts, commentsByRoot, opinionsByRoot };
};
const buildBlog = (node, idx) => {
const c = node.c || {};
const op = idx.opinionsByRoot.get(node.key) || { counts: {}, voters: [] };
return {
id: node.key,
key: node.key,
author: node.author,
subject: typeof c.contentWarning === 'string' ? c.contentWarning : '',
text: c.text || '',
mentions: Array.isArray(c.mentions) ? c.mentions : [],
allowComments: c.allowComments !== false,
createdAt: new Date(node.ts).toISOString(),
commentCount: idx.commentsByRoot.get(node.key) || 0,
opinions: op.counts,
opinions_inhabitants: op.voters
};
};
const collect = async () => {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const list = [];
for (const node of idx.posts.values()) list.push(buildBlog(node, idx));
return { list, idx, viewerId: ssbClient.id };
};
const opinionScore = (blog) =>
Object.values(blog.opinions || {}).reduce((n, v) => n + (Number(v) || 0), 0);
return {
type: 'post',
async listAll(filter = 'ALL', opts = {}) {
const { list, viewerId } = await collect();
const f = String(filter || 'ALL').toUpperCase();
const favorites = Array.isArray(opts.favorites) ? new Set(opts.favorites.map(String)) : null;
let out = list.slice();
if (f === 'MINE') out = out.filter(b => String(b.author) === String(viewerId));
else if (f === 'FAVORITES') out = favorites ? out.filter(b => favorites.has(String(b.id))) : [];
const q = String(opts.q || '').trim().toLowerCase();
if (q) out = out.filter(b =>
b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q));
if (f === 'TOP') {
out.sort((a, b) => (opinionScore(b) + b.commentCount) - (opinionScore(a) + a.commentCount) ||
new Date(b.createdAt) - new Date(a.createdAt));
} else {
out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return out;
},
async getBlogById(id) {
const { list } = await collect();
const blog = list.find(b => b.id === id);
if (!blog) throw new Error('Blog not found');
return blog;
},
async createBlog({ text, subject = '', mentions = [], allowComments = true } = {}) {
if (isPublic) throw new Error('Not available in public mode');
const body = String(text || '').trim();
if (!body) throw new Error('Blog text is required');
const ssbClient = await openSsb();
const content = {
type: 'post',
text: body,
allowComments: allowComments !== false,
...(Array.isArray(mentions) && mentions.length ? { mentions } : {}),
...(String(subject || '').trim() ? { contentWarning: String(subject).trim() } : {})
};
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories');
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const blog = await this.getBlogById(id);
if (blog.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
const content = { type: OPINION_TYPE, target: blog.id, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
const c = msg && (msg.content || (msg.value && msg.value.content));
if (c && c.type === 'post' && typeof c.root === 'string') return c.root;
return id;
},
async blogHrefFor(id) {
const ssbClient = await openSsb();
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
const c = msg && (msg.content || (msg.value && msg.value.content));
if (!c || c.type !== 'post' || typeof c.text !== 'string') return null;
if (c.private === true || Array.isArray(c.recps)) return null;
if (typeof c.root === 'string') {
return `/blogs/${encodeURIComponent(c.root)}#${encodeURIComponent(id)}`;
}
if (c.fork || c.branch || c.about || c.target) return null;
return `/blogs/${encodeURIComponent(id)}`;
}
};
};

View file

@ -145,7 +145,6 @@ module.exports = ({ cooler }) => {
updatedAt: c.updatedAt || null, updatedAt: c.updatedAt || null,
lastVisit: c.lastVisit || null, lastVisit: c.lastVisit || null,
tags: safeArr(c.tags), tags: safeArr(c.tags),
category: c.category || "",
description: c.description || "", description: c.description || "",
opinions, opinions,
opinions_inhabitants: voters, opinions_inhabitants: voters,
@ -182,7 +181,7 @@ module.exports = ({ cooler }) => {
return root; return root;
}, },
async createBookmark(url, tagsRaw, description, category, lastVisit) { async createBookmark(url, tagsRaw, description, lastVisit) {
const ssbClient = await openSsb(); const ssbClient = await openSsb();
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -195,7 +194,6 @@ module.exports = ({ cooler }) => {
url: u, url: u,
tags: normalizeTags(tagsRaw), tags: normalizeTags(tagsRaw),
description: description || "", description: description || "",
category: category || "",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
lastVisit: coerceLastVisit(lastVisit), lastVisit: coerceLastVisit(lastVisit),
@ -231,7 +229,6 @@ module.exports = ({ cooler }) => {
url, url,
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags), tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "", description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit), lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
createdAt: oldMsg.content.createdAt, createdAt: oldMsg.content.createdAt,
updatedAt: now updatedAt: now
@ -296,11 +293,10 @@ module.exports = ({ cooler }) => {
if (q) { if (q) {
list = list.filter((b) => { list = list.filter((b) => {
const url = String(b.url || "").toLowerCase(); const url = String(b.url || "").toLowerCase();
const cat = String(b.category || "").toLowerCase();
const desc = String(b.description || "").toLowerCase(); const desc = String(b.description || "").toLowerCase();
const tags = safeArr(b.tags).join(" ").toLowerCase(); const tags = safeArr(b.tags).join(" ").toLowerCase();
const author = String(b.author || "").toLowerCase(); const author = String(b.author || "").toLowerCase();
return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q); return url.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
}); });
} }

View file

@ -14,25 +14,26 @@ const normalizeTags = (raw) => {
return String(raw).split(",").map(t => t.trim()).filter(Boolean) return String(raw).split(",").map(t => t.trim()).filter(Boolean)
} }
const hasAnyInterval = (w, m, y) => !!(w || m || y) const hasAnyInterval = (w, m, y) => !!(w || m || y)
const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => { const { expandRecurrence } = require('./recurrence')
const start = new Date(firstDate)
const out = [start] const ts = (v) => {
if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out const t = new Date(v).getTime()
const end = new Date(deadline).getTime() return Number.isFinite(t) ? t : null
const seen = new Set([start.getTime()]) }
const walk = (mutate) => {
const n = new Date(start) const assertCalendarDates = ({ deadline, date, until }) => {
mutate(n) const now = Date.now()
while (n.getTime() <= end) { const dl = deadline ? ts(deadline) : null
const t = n.getTime() const dt = date ? ts(date) : null
if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) } const un = until ? ts(until) : null
mutate(n) if (deadline && dl === null) throw new Error("Deadline is not a valid date")
} if (date && dt === null) throw new Error("Date is not a valid date")
} if (until && un === null) throw new Error("The recurrence end is not a valid date")
if (weekly) walk((d) => d.setDate(d.getDate() + 7)) if (dl !== null && dl <= now) throw new Error("Deadline must be in the future")
if (monthly) walk((d) => d.setMonth(d.getMonth() + 1)) if (dt !== null && dt <= now) throw new Error("Date must be in the future")
if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1)) if (dt !== null && dl !== null && dt > dl) throw new Error("Date cannot be later than the deadline")
return out.sort((a, b) => a.getTime() - b.getTime()) if (un !== null && dt !== null && un < dt) throw new Error("The recurrence cannot end before it starts")
if (un !== null && dl !== null && un > dl) throw new Error("The recurrence cannot end after the deadline")
} }
module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => { module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
@ -108,6 +109,12 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
return tribeCrypto.encryptContent(content, [key], true) return tribeCrypto.encryptContent(content, [key], true)
} }
const decryptScoped = async (content, rootId) => {
if (!content) return content
if (content.tribeId) return await decryptIfTribe(content)
return decryptCalendarRoot(content, rootId)
}
const decryptCalendarRoot = (content, rootId) => { const decryptCalendarRoot = (content, rootId) => {
if (!content || !content.encryptedPayload) return content if (!content || !content.encryptedPayload) return content
if (!tribeCrypto) return content if (!tribeCrypto) return content
@ -259,6 +266,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: c.status || "OPEN", status: c.status || "OPEN",
deadline: undec ? "" : (c.deadline || ""), deadline: undec ? "" : (c.deadline || ""),
tags: Array.isArray(c.tags) ? c.tags : [], tags: Array.isArray(c.tags) ? c.tags : [],
mapUrl: typeof c.mapUrl === "string" ? c.mapUrl : "",
author: c.author || node.author, author: c.author || node.author,
participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []), participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []),
invites: Array.isArray(c.invites) ? c.invites : [], invites: Array.isArray(c.invites) ? c.invites : [],
@ -339,14 +347,18 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
return tip return tip
}, },
async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) { async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline, mapUrl, tribeId }) {
const ssbClient = await openSsb() const ssbClient = await openSsb()
const userId = ssbClient.id const userId = ssbClient.id
const now = new Date().toISOString() const now = new Date().toISOString()
const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN" const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future") if (!firstDate) throw new Error("First date must be in the future")
if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future") assertCalendarDates({
deadline,
date: firstDate,
until: hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? intervalDeadline : ""
})
let plainContent = { let plainContent = {
type: "calendar", type: "calendar",
@ -354,6 +366,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: validStatus, status: validStatus,
deadline: deadline || "", deadline: deadline || "",
tags: normalizeTags(tags), tags: normalizeTags(tags),
mapUrl: safeText(mapUrl),
author: userId, author: userId,
participants: [userId], participants: [userId],
invites: [], invites: [],
@ -400,6 +413,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: validStatus, status: validStatus,
deadline: dec.deadline || "", deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [], tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: userId, author: userId,
participants: [userId], participants: [userId],
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }], invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
@ -424,7 +438,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
...(intervalWeekly ? { intervalWeekly: true } : {}), ...(intervalWeekly ? { intervalWeekly: true } : {}),
...(intervalMonthly ? { intervalMonthly: true } : {}), ...(intervalMonthly ? { intervalMonthly: true } : {}),
...(intervalYearly ? { intervalYearly: true } : {}), ...(intervalYearly ? { intervalYearly: true } : {}),
...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}), ...(hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) && (intervalDeadline || deadline) ? { intervalDeadline: intervalDeadline || deadline } : {}),
...(tribeId ? { tribeId } : {}) ...(tribeId ? { tribeId } : {})
} }
if (tribeId) dateContent = await encryptIfTribe(dateContent) if (tribeId) dateContent = await encryptIfTribe(dateContent)
@ -467,12 +481,15 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
: decryptCalendarRoot(item.content, rootId) : decryptCalendarRoot(item.content, rootId)
assertReadable(oldDec, "Calendar") assertReadable(oldDec, "Calendar")
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author") if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
const nextDeadline = data.deadline !== undefined ? data.deadline : (oldDec.deadline || "")
if (nextDeadline !== (oldDec.deadline || "")) assertCalendarDates({ deadline: nextDeadline })
let updated = { let updated = {
type: "calendar", type: "calendar",
title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""), title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"), status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"),
deadline: data.deadline !== undefined ? data.deadline : (oldDec.deadline || ""), deadline: nextDeadline,
tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []), tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
mapUrl: data.mapUrl !== undefined ? safeText(data.mapUrl) : (oldDec.mapUrl || ""),
author: oldDec.author || userId, author: oldDec.author || userId,
participants: oldDec.participants || [userId], participants: oldDec.participants || [userId],
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [], invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
@ -495,7 +512,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
const userId = ssbClient.id const userId = ssbClient.id
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
if (!item || !item.content) throw new Error("Calendar not found") if (!item || !item.content) throw new Error("Calendar not found")
const dec = await decryptIfTribe(item.content) const rootId = await this.resolveRootId(id)
const dec = await decryptScoped(item.content, rootId)
assertReadable(dec, "Calendar") assertReadable(dec, "Calendar")
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author) const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
if (contentAuthor !== userId) throw new Error("Not the author") if (contentAuthor !== userId) throw new Error("Not the author")
@ -588,9 +606,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
const cal = await this.getCalendarById(rootId) const cal = await this.getCalendarById(rootId)
if (!cal) throw new Error("Calendar not found") if (!cal) throw new Error("Calendar not found")
if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar") if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future") if (!date) throw new Error("Date must be in the future")
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
assertCalendarDates({
deadline: cal.deadline || "",
date,
until: hasInterval ? intervalDeadline : ""
})
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : "" const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
let dateContent = { let dateContent = {
type: "calendarDate", type: "calendarDate",
@ -748,8 +771,9 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
const userId = ssbClient.id const userId = ssbClient.id
const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it))) const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
if (!item || !item.content) throw new Error("Note not found") if (!item || !item.content) throw new Error("Note not found")
const dec = await decryptIfTribe(item.content) const noteRoot = item.content.calendarId || null
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author") const dec = await decryptScoped(item.content, noteRoot)
if (((dec && dec.author) || item.content.author) !== userId) throw new Error("Not the author")
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg)) ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
}) })
@ -777,8 +801,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
if (tombstoned.has(m.key)) continue if (tombstoned.has(m.key)) continue
if (c.calendarId !== rootId || c.dateId !== dateId) continue if (c.calendarId !== rootId || c.dateId !== dateId) continue
let dec = c let dec = c
if (c.encryptedPayload && tribeCrypto && tribesModel) { if (c.encryptedPayload) {
const r = await tribeCrypto.decryptFromTribe(c, tribesModel) const r = await decryptScoped(c, rootId)
if (r && !r._undecryptable) dec = r if (r && !r._undecryptable) dec = r
else continue else continue
} }
@ -929,6 +953,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: dec.status || "OPEN", status: dec.status || "OPEN",
deadline: dec.deadline || "", deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [], tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: dec.author, author: dec.author,
participants: Array.isArray(dec.participants) ? dec.participants : [userId], participants: Array.isArray(dec.participants) ? dec.participants : [userId],
invites, invites,
@ -975,6 +1000,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: dec.status || "OPEN", status: dec.status || "OPEN",
deadline: dec.deadline || "", deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [], tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: dec.author, author: dec.author,
participants: Array.isArray(dec.participants) ? dec.participants : [userId], participants: Array.isArray(dec.participants) ? dec.participants : [userId],
invites, invites,
@ -1009,7 +1035,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
let calKey = null let calKey = null
if (tribeCrypto && typeof matchedInvite === "object") { if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) { if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt) const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
if (Array.isArray(chain) && chain.length) { if (Array.isArray(chain) && chain.length) {
for (const entry of chain) { for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) { if (Array.isArray(entry.keys) && entry.keys.length) {
@ -1043,6 +1069,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
status: dec.status || "OPEN", status: dec.status || "OPEN",
deadline: dec.deadline || "", deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [], tags: Array.isArray(dec.tags) ? dec.tags : [],
mapUrl: dec.mapUrl || "",
author: dec.author, author: dec.author,
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId], participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
invites, invites,

View file

@ -17,10 +17,22 @@ const normalizeTags = (raw) => {
const INVITE_CODE_BYTES = 16 const INVITE_CODE_BYTES = 16
const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"] const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
const DEFAULT_MESSAGES_PER_HOUR = 60
module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
let ssb let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const MESSAGES_PER_HOUR = (() => {
try {
const raw = getConfig()?.chats?.messagesPerHour
const n = parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MESSAGES_PER_HOUR
} catch (_) {
return DEFAULT_MESSAGES_PER_HOUR
}
})()
const ownCrypto = chatCrypto || tribeCrypto const ownCrypto = chatCrypto || tribeCrypto
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
const lookupKeys = (rid) => { const lookupKeys = (rid) => {
@ -292,9 +304,9 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
chatId: c.chatId || "", chatId: c.chatId || "",
text, text,
image: c.image || null, image: c.image || null,
replyTo: c.replyTo || null,
author: c.author || node.author, author: c.author || node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(), createdAt: c.createdAt || new Date(node.ts).toISOString()
replyTo: c.replyTo || null
} }
} }
@ -358,6 +370,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
return { return {
type: "chat", type: "chat",
async encryptionKeyFor(chatRootId, tribeId = null) {
if (!tribeCrypto) return null
if (tribeId) {
const k = await getTribeFirstKeyFor(tribeId)
if (k) return k
}
return lookupKey(chatRootId) || null
},
async resolveRootId(id) { async resolveRootId(id) {
const ssbClient = await openSsb() const ssbClient = await openSsb()
const messages = await readAll(ssbClient) const messages = await readAll(ssbClient)
@ -597,6 +618,18 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid) let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid)
const lastMsgAt = new Map()
const lastMineAt = new Map()
for (const node of idx.msgNodes.values()) {
const cid = node.c && node.c.chatId
if (!cid) continue
const r = idx.rawRootOf(cid) || cid
const t = node.ts || 0
if (t > (lastMsgAt.get(r) || 0)) lastMsgAt.set(r, t)
if (node.author === uid && t > (lastMineAt.get(r) || 0)) lastMineAt.set(r, t)
}
list = list.map(c => ({ ...c, lastMsgAt: lastMsgAt.get(c.rootId) || 0, lastMineAt: lastMineAt.get(c.rootId) || 0 }))
if (filter === "mine") list = list.filter(c => c.author === uid) if (filter === "mine") list = list.filter(c => c.author === uid)
else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000) else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY") else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
@ -707,7 +740,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
let chatKey = null let chatKey = null
if (tribeCrypto && typeof matchedInvite === "object") { if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) { if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt) const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
if (Array.isArray(chain) && chain.length) { if (Array.isArray(chain) && chain.length) {
for (const entry of chain) { for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) { if (Array.isArray(entry.keys) && entry.keys.length) {
@ -809,7 +842,12 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
const c = m.value?.content const c = m.value?.content
return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
}).length }).length
if (process.env.OASIS_MOBILE !== '1' && recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour") if (recentCount >= MESSAGES_PER_HOUR) {
const err = new Error(`Rate limit: max ${MESSAGES_PER_HOUR} messages per hour`)
err.code = "CHAT_RATE_LIMIT"
err.retryAfterMinutes = 60
throw err
}
const now = new Date().toISOString() const now = new Date().toISOString()
let content = { let content = {

View file

@ -223,14 +223,27 @@ module.exports = (configPath, namespace = 'tribes') => {
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt)); return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
}; };
const INVITE_ENCRYPT_ATTEMPTS = 3;
const encryptChainForInvite = (ancestryRootIds, code, salt) => { const encryptChainForInvite = (ancestryRootIds, code, salt) => {
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) })); const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null; if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
const k = deriveInviteKey(code, salt); const plain = JSON.stringify(chain);
return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt)); const aad = inviteAad(code, salt);
for (let attempt = 0; attempt < INVITE_ENCRYPT_ATTEMPTS; attempt++) {
const k = deriveInviteKey(code, salt);
const payload = encryptWithKey(plain, k.toString('hex'), aad);
if (!payload) continue;
const readBack = decryptChainFromInvite(payload, code, salt);
if (readBack && JSON.stringify(readBack.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))
=== JSON.stringify(chain.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))) {
return payload;
}
}
throw new Error('Could not produce a verifiable invite on this machine');
}; };
const decryptChainFromInvite = (encryptedPayload, code, salt) => { const decryptChainOnce = (encryptedPayload, code, salt) => {
const k = deriveInviteKey(code, salt); const k = deriveInviteKey(code, salt);
try { try {
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt)); const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
@ -247,6 +260,15 @@ module.exports = (configPath, namespace = 'tribes') => {
return null; return null;
}; };
const decryptChainFromInvite = (encryptedPayload, code, salt, attempts = 1) => {
const tries = Math.max(1, Number(attempts) || 1);
for (let i = 0; i < tries; i++) {
const chain = decryptChainOnce(encryptedPayload, code, salt);
if (chain) return chain;
}
return null;
};
const inviteMatchesCode = (inv, code) => { const inviteMatchesCode = (inv, code) => {
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false; if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
return inv.codeHash === hashInviteCode(code, inv.salt); return inv.codeHash === hashInviteCode(code, inv.salt);

View file

@ -47,6 +47,8 @@ module.exports = ({ cooler }) => {
status: data.status || 'LOOKING FOR WORK', status: data.status || 'LOOKING FOR WORK',
preferences: data.preferences || 'REMOTE WORKING', preferences: data.preferences || 'REMOTE WORKING',
visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC', visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
aiManaged: data.aiManaged === undefined ? true : (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true'),
matchThreshold: Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80)),
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -85,7 +87,7 @@ module.exports = ({ cooler }) => {
author: userId, author: userId,
name: data.name, name: data.name,
description: data.description, description: data.description,
photo: extractBlobId(photoBlobId) || null, photo: extractBlobId(photoBlobId) || old.content.photo || null,
contact: userId, contact: userId,
personalSkills: parseCSV(data.personalSkills), personalSkills: parseCSV(data.personalSkills),
personalExperiences: data.personalExperiences || '', personalExperiences: data.personalExperiences || '',
@ -102,6 +104,12 @@ module.exports = ({ cooler }) => {
visibility: data.visibility !== undefined visibility: data.visibility !== undefined
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC') ? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
: (old.content.visibility || 'PUBLIC'), : (old.content.visibility || 'PUBLIC'),
aiManaged: data.aiManaged !== undefined
? (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true')
: (old.content.aiManaged !== false),
matchThreshold: data.matchThreshold !== undefined
? Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80))
: (Number(old.content.matchThreshold) || 80),
createdAt: old.content.createdAt, createdAt: old.content.createdAt,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };

292
src/models/data_model.js Normal file
View file

@ -0,0 +1,292 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const KINDS = {
inhabitants: { type: 'curriculum', href: (id, c) => `/author/${encodeURIComponent(c.author)}` },
jobs: { type: 'job', href: (id) => `/jobs/${encodeURIComponent(id)}` },
projects: { type: 'project', href: (id) => `/projects/${encodeURIComponent(id)}` },
events: { type: 'event', href: (id) => `/events/${encodeURIComponent(id)}` },
tribes: { type: 'tribe', href: (id) => `/tribe/${encodeURIComponent(id)}` },
market: { type: 'market', href: (id) => `/market/${encodeURIComponent(id)}` },
housing: { type: 'housing', href: (id) => `/housing/${encodeURIComponent(id)}` },
industry: { type: 'industry', href: (id) => `/industry/${encodeURIComponent(id)}` },
tasks: { type: 'task', href: (id) => `/tasks/${encodeURIComponent(id)}` },
reports: { type: 'report', href: (id) => `/reports/${encodeURIComponent(id)}` },
votes: { type: 'poll', href: (id) => `/polls/${encodeURIComponent(id)}` },
audios: { type: 'audio', href: (id) => `/audios/${encodeURIComponent(id)}` },
videos: { type: 'video', href: (id) => `/videos/${encodeURIComponent(id)}` },
images: { type: 'image', href: (id) => `/images/${encodeURIComponent(id)}` },
documents: { type: 'document', href: (id) => `/documents/${encodeURIComponent(id)}` },
bookmarks: { type: 'bookmark', href: (id) => `/bookmarks/${encodeURIComponent(id)}` },
torrents: { type: 'torrent', href: (id) => `/torrents/${encodeURIComponent(id)}` },
chats: { type: 'chat', href: (id) => `/chats/${encodeURIComponent(id)}` },
pads: { type: 'pad', href: (id) => `/pads/${encodeURIComponent(id)}` },
maps: { type: 'map', href: (id) => `/maps/${encodeURIComponent(id)}` },
calendars: { type: 'calendar', href: (id) => `/calendars/${encodeURIComponent(id)}` },
forum: { type: 'forum', href: (id) => `/forum/${encodeURIComponent(id)}` }
};
const KIND_BY_TYPE = Object.fromEntries(Object.entries(KINDS).map(([k, v]) => [v.type, k]));
const cvSkills = (c) => [
...(c.personalSkills || []),
...(c.oasisSkills || []),
...(c.educationalSkills || []),
...(c.professionalSkills || [])
];
const PLACEHOLDERS = new Set(['unknown', 'n/a', 'na', 'none', '-', 'other']);
const TITLE_STOPWORDS = new Set([
'the', 'this', 'that', 'and', 'for', 'with', 'from', 'into', 'about', 'new', 'all', 'not', 'are', 'was', 'you', 'your', 'our',
'los', 'las', 'del', 'una', 'unos', 'unas', 'este', 'esta', 'esto', 'que', 'con', 'para', 'por', 'sin', 'sobre', 'bajo', 'mas', 'más'
]);
const titleTerms = (c) => String(c.title || c.name || c.question || c.concept || '')
.toLowerCase()
.split(/[^0-9A-Za-zÀ-￿]+/) // sin \p{}: nodejs-mobile no lleva ICU completo
.filter(w => w.length >= 3 && !TITLE_STOPWORDS.has(w) && !/^\d+$/.test(w));
const termsOf = (kind, c) => {
const out = [];
if (kind === 'inhabitants') {
out.push(...cvSkills(c));
if (c.languages) out.push(...String(c.languages).split(/[,;]/));
} else if (kind === 'jobs') {
out.push(...(c.tasks || []), ...(c.tags || []), c.job_type, c.location);
} else if (kind === 'industry') {
out.push(...(c.tags || []), c.sector, ...(c.skills || []));
} else {
out.push(...(c.tags || []));
if (c.category) out.push(c.category);
}
return Array.from(new Set(out.map(norm).filter(t => t && !PLACEHOLDERS.has(t))));
};
const titleOf = (kind, c, author) => {
if (kind === 'inhabitants') return c.name || author || '';
return c.title || c.name || c.question || c.concept || '';
};
const jaccard = (a, b) => {
if (!a.size || !b.size) return { score: 0, common: [] };
const common = [...b].filter(x => a.has(x));
const union = a.size + b.size - common.length;
return { score: union > 0 ? common.length / union : 0, common };
};
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const buildGraph = async () => {
const ssbClient = await openSsb();
const viewerId = ssbClient.id;
const messages = await getAllMessages(ssbClient);
const tomb = buildValidatedTombstoneSet(messages);
const latestByKey = new Map();
const replaced = new Set();
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object' || !c.type) continue;
if (tomb.has(m.key)) continue;
if (c.encryptedPayload || c.encryptedQuestion) continue;
if (c.tribeId && c.type !== 'tribe') continue;
const kind = KIND_BY_TYPE[c.type];
if (!kind) continue;
if (typeof c.replaces === 'string') replaced.add(c.replaces);
latestByKey.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, kind, c });
}
const nodes = [];
const byAuthorCv = new Map();
for (const node of latestByKey.values()) {
if (replaced.has(node.key)) continue;
const coreTerms = termsOf(node.kind, node.c);
const extra = node.kind === 'inhabitants' ? [] : titleTerms(node.c).map(norm).filter(t => t && !PLACEHOLDERS.has(t));
const terms = Array.from(new Set([...coreTerms, ...extra]));
if (!terms.length) continue;
const entry = {
id: node.key,
kind: node.kind,
author: node.c.author || node.author,
title: titleOf(node.kind, node.c, node.c.author || node.author),
terms,
termSet: new Set(terms),
coreTermSet: new Set(coreTerms),
ts: node.ts,
createdAt: node.c.createdAt || new Date(node.ts).toISOString(),
href: KINDS[node.kind].href(node.key, node.c)
};
if (node.kind === 'inhabitants') {
const prev = byAuthorCv.get(entry.author);
if (prev && prev.ts >= entry.ts) continue;
byAuthorCv.set(entry.author, entry);
continue;
}
nodes.push(entry);
}
for (const cv of byAuthorCv.values()) nodes.push(cv);
return { viewerId, nodes, cvByAuthor: byAuthorCv };
};
const MAX_ENTITIES = 400;
const MAX_PAIRS = 300;
const strip = (n) => ({
id: n.id, kind: n.kind, author: n.author, title: n.title,
href: n.href, createdAt: n.createdAt, ts: n.ts
});
return {
KINDS: Object.keys(KINDS),
async listMatches(filter = 'ALL', opts = {}) {
const { viewerId, nodes, cvByAuthor } = await buildGraph();
const use = nodes.slice(0, MAX_ENTITIES);
const f = String(filter || 'ALL').toUpperCase();
const myTermSet = new Set();
const mineCv = cvByAuthor.get(viewerId);
if (mineCv) for (const t of mineCv.terms) myTermSet.add(t);
for (const n of use) {
if (String(n.author) === String(viewerId)) for (const t of n.terms) myTermSet.add(t);
}
const df = new Map();
for (const n of use) for (const t of n.termSet) df.set(t, (df.get(t) || 0) + 1);
const total = use.length || 1;
const weightOf = (t) => Math.log(1 + total / (df.get(t) || 1));
let out = [];
for (const n of use) {
if (String(n.author) === String(viewerId)) continue;
const common = [...n.termSet].filter(t => myTermSet.has(t));
if (!common.length) continue;
let commonW = 0;
for (const t of common) commonW += weightOf(t);
let itemW = 0;
for (const t of n.termSet) itemW += weightOf(t);
const score = itemW > 0 ? Math.min(1, commonW / itemW) : 0;
if (score <= 0) continue;
common.sort((x, y) => weightOf(y) - weightOf(x) || x.localeCompare(y));
out.push({ ...strip(n), score, common, connections: common.length });
}
if (f === 'RECENT') {
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
out = out.filter(s => s.ts >= cutoff);
} else if (f !== 'ALL' && f !== 'TOP') {
const kind = f.toLowerCase();
if (KINDS[kind]) out = out.filter(s => s.kind === kind);
}
const q = norm(opts.q);
if (q) out = out.filter(s => norm(s.title).includes(q) || s.common.some(t => t.includes(q)));
if (f === 'RECENT') out.sort((x, y) => y.ts - x.ts || y.score - x.score);
else out.sort((x, y) => y.score - x.score || y.ts - x.ts);
return {
matches: out.slice(0, MAX_PAIRS),
total: out.length,
hasProfile: myTermSet.size > 0,
myTerms: [...myTermSet]
};
},
async jobMatchesFor(viewerId, { minScore = 0.8 } = {}) {
const { nodes, cvByAuthor } = await buildGraph();
const mine = cvByAuthor.get(viewerId);
if (!mine) return [];
return nodes
.filter(n => n.kind === 'jobs' && String(n.author) !== String(viewerId))
.map(n => {
const { score, common } = jaccard(mine.termSet, n.coreTermSet || n.termSet);
return { id: n.id, title: n.title, author: n.author, href: n.href, score, common };
})
.filter(m => m.score >= minScore)
.sort((a, b) => b.score - a.score);
},
async cohesion() {
const { cvByAuthor, nodes } = await buildGraph();
const use = nodes.slice(0, MAX_ENTITIES);
let comparisons = 0;
let sum = 0;
let connectedPairs = 0;
const linkedEntities = new Set();
for (let i = 0; i < use.length; i++) {
for (let j = i + 1; j < use.length; j++) {
const a = use[i], b = use[j];
if (a.kind === 'inhabitants' && b.kind === 'inhabitants' && a.author === b.author) continue;
const { score } = jaccard(a.termSet, b.termSet);
comparisons += 1;
sum += score;
if (score > 0) { connectedPairs += 1; linkedEntities.add(a.id); linkedEntities.add(b.id); }
}
}
const coefficient = comparisons > 0 ? sum / comparisons : 0;
const people = [...cvByAuthor.values()];
const linked = new Set();
let cvComparisons = 0;
let cvSum = 0;
for (let i = 0; i < people.length; i++) {
for (let j = i + 1; j < people.length; j++) {
const { score } = jaccard(people[i].termSet, people[j].termSet);
cvComparisons += 1;
cvSum += score;
if (score > 0) { linked.add(people[i].author); linked.add(people[j].author); }
}
}
const cvCoefficient = cvComparisons > 0 ? cvSum / cvComparisons : 0;
const skillSet = new Set();
for (const p of people) for (const t of p.terms) skillSet.add(t);
const termCount = new Map();
for (const n of nodes) for (const t of n.terms) termCount.set(t, (termCount.get(t) || 0) + 1);
const topTerms = [...termCount.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 12)
.map(([term, count]) => ({ term, count }));
const perKind = {};
for (const n of nodes) perKind[n.kind] = (perKind[n.kind] || 0) + 1;
return {
coefficient,
percent: Math.round(coefficient * 1000) / 10,
comparisons,
pairs: connectedPairs,
entities: use.length,
distinctTerms: termCount.size,
topTerms,
perKind,
people: people.length,
skills: skillSet.size,
connected: linkedEntities.size,
isolated: Math.max(0, use.length - linkedEntities.size),
cvCoefficient,
cvPercent: Math.round(cvCoefficient * 1000) / 10
};
}
};
};

View file

@ -109,12 +109,9 @@ const MODULE_ALIASES = {
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' }, ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] }, aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
docs: { paths: ['/documents'] }, docs: { paths: ['/documents'] },
latest: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest'] }, blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
popular: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/popular'] }, polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
topics: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/topics'] }, data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
summaries: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/summaries'] },
threads: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/threads'] },
multiverse: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/extended'] },
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' }, invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' } graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
}; };

View file

@ -3,7 +3,7 @@ const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories"); const categories = require("../backend/opinion_categories");
const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe'); const { dedupeBy, norm } = require('../backend/dedupe');
const mediaFavorites = require("../backend/media-favorites"); const contentFavorites = require("../backend/content_favorites");
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
@ -154,7 +154,7 @@ module.exports = ({ cooler }) => {
const favoritesSetForDocuments = async () => { const favoritesSetForDocuments = async () => {
try { try {
return await mediaFavorites.getFavoriteSet("documents"); return await contentFavorites.getFavoriteSet("documents");
} catch { } catch {
return new Set(); return new Set();
} }

View file

@ -1,5 +1,7 @@
const pull = require('../server/node_modules/pull-stream'); const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment'); const moment = require('../server/node_modules/moment');
const { normalizeImages, normalizeVideo } = require('./media_gallery');
const { truthy, hasAnyInterval, nextOccurrence, upcomingOccurrences } = require('./recurrence');
const crypto = require('crypto'); const crypto = require('crypto');
const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { getConfig } = require('../configs/config-manager.js'); const { getConfig } = require('../configs/config-manager.js');
@ -72,8 +74,22 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
return m.toISOString(); return m.toISOString();
}; };
const recurrenceOf = (c) => ({
weekly: truthy(c.intervalWeekly),
monthly: truthy(c.intervalMonthly),
yearly: truthy(c.intervalYearly),
until: c.recurrenceUntil || ''
});
const effectiveDate = (c) => {
const r = recurrenceOf(c);
if (!hasAnyInterval(r.weekly, r.monthly, r.yearly) || !r.until) return c.date;
const next = nextOccurrence(c.date, r.until, r.weekly, r.monthly, r.yearly);
return next ? next.toISOString() : c.date;
};
const deriveStatus = (c) => { const deriveStatus = (c) => {
const dateM = moment(c.date); const dateM = moment(effectiveDate(c));
let status = String(c.status || 'OPEN').toUpperCase(); let status = String(c.status || 'OPEN').toUpperCase();
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED'; if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN'; if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
@ -171,7 +187,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
return idx.rootOf(id); return idx.rootOf(id);
}, },
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) { async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false, media = {}, recurrence = {}) {
const ssbClient = await openSsb(); const ssbClient = await openSsb();
const userId = await me(); const userId = await me();
@ -203,6 +219,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
isPublic: visibility, isPublic: visibility,
mapUrl: String(mapUrl || "").trim(), mapUrl: String(mapUrl || "").trim(),
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on', clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
images: normalizeImages(media && media.images),
video: normalizeVideo(media && media.video),
intervalWeekly: truthy(recurrence && recurrence.weekly),
intervalMonthly: truthy(recurrence && recurrence.monthly),
intervalYearly: truthy(recurrence && recurrence.yearly),
recurrenceUntil: String((recurrence && recurrence.until) || '').trim(),
opinions: {}, opinions: {},
opinions_inhabitants: [] opinions_inhabitants: []
}; };
@ -461,6 +483,15 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
status, status,
isPublic: normalizePrivacy(c.isPublic), isPublic: normalizePrivacy(c.isPublic),
mapUrl: c.mapUrl || "", mapUrl: c.mapUrl || "",
images: normalizeImages(c.images),
video: normalizeVideo(c.video),
intervalWeekly: truthy(c.intervalWeekly),
intervalMonthly: truthy(c.intervalMonthly),
intervalYearly: truthy(c.intervalYearly),
recurrenceUntil: c.recurrenceUntil || '',
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
nextDate: effectiveDate(c),
occurrences: upcomingOccurrences(c.date, c.recurrenceUntil, truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)).map(d => d.toISOString()),
clearnetPublic: !!c.clearnetPublic, clearnetPublic: !!c.clearnetPublic,
encrypted: normalizePrivacy(c.isPublic) === 'private', encrypted: normalizePrivacy(c.isPublic) === 'private',
opinions: agg.opinions, opinions: agg.opinions,
@ -503,6 +534,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic), isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic, clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []), attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
images: normalizeImages(updatedData.images !== undefined ? updatedData.images : c.images),
video: updatedData.video !== undefined ? normalizeVideo(updatedData.video) : normalizeVideo(c.video),
intervalWeekly: updatedData.intervalWeekly !== undefined ? truthy(updatedData.intervalWeekly) : truthy(c.intervalWeekly),
intervalMonthly: updatedData.intervalMonthly !== undefined ? truthy(updatedData.intervalMonthly) : truthy(c.intervalMonthly),
intervalYearly: updatedData.intervalYearly !== undefined ? truthy(updatedData.intervalYearly) : truthy(c.intervalYearly),
recurrenceUntil: updatedData.recurrenceUntil !== undefined ? String(updatedData.recurrenceUntil || '').trim() : (c.recurrenceUntil || ''),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
replaces: eventId replaces: eventId
}; };
@ -610,6 +647,14 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
status, status,
isPublic: normalizePrivacy(c.isPublic), isPublic: normalizePrivacy(c.isPublic),
mapUrl: c.mapUrl || "", mapUrl: c.mapUrl || "",
images: normalizeImages(c.images),
video: normalizeVideo(c.video),
intervalWeekly: truthy(c.intervalWeekly),
intervalMonthly: truthy(c.intervalMonthly),
intervalYearly: truthy(c.intervalYearly),
recurrenceUntil: c.recurrenceUntil || '',
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
nextDate: effectiveDate(c),
encrypted: normalizePrivacy(c.isPublic) === 'private', encrypted: normalizePrivacy(c.isPublic) === 'private',
opinions: agg.opinions, opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants opinions_inhabitants: agg.opinions_inhabitants

View file

@ -6,8 +6,8 @@ const archiver = require('../server/node_modules/archiver');
module.exports = { module.exports = {
exportSSB: async (outputPath) => { exportSSB: async (outputPath) => {
try { try {
const homeDir = os.homedir(); // se exporta la cuenta activa, no siempre la de por defecto
const ssbPath = path.join(homeDir, '.ssb'); const ssbPath = require('../server/ssb_config').path;
const output = fs.createWriteStream(outputPath); const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { const archive = archiver('zip', {
zlib: { level: 9 } zlib: { level: 9 }

View file

@ -1,4 +1,4 @@
const mediaFavorites = require("../backend/media-favorites"); const contentFavorites = require("../backend/content_favorites");
const safeArr = (v) => (Array.isArray(v) ? v : []); const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim(); const safeText = (v) => String(v || "").trim();
@ -15,7 +15,7 @@ const toTs = (d) => {
return Number.isFinite(t) ? t : 0; return Number.isFinite(t) ? t : 0;
}; };
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }) => { module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel, eventsModel, tasksModel, reportsModel, votesModel, jobsModel, housingModel, projectsModel, transfersModel, forumModel, blogsModel, pollsModel }) => {
const kindConfig = { const kindConfig = {
audios: { audios: {
base: "/audios/", base: "/audios/",
@ -64,10 +64,58 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
shopProducts: { shopProducts: {
base: "/shops/product/", base: "/shops/product/",
getById: getFn(shopsModel, ["getProductById"]) getById: getFn(shopsModel, ["getProductById"])
},
events: {
base: "/events/",
getById: getFn(eventsModel, ["getEventById", "getById"])
},
tasks: {
base: "/tasks/",
getById: getFn(tasksModel, ["getTaskById", "getById"])
},
reports: {
base: "/reports/",
getById: getFn(reportsModel, ["getReportById", "getById"])
},
votes: {
base: "/votes/",
getById: getFn(votesModel, ["getVoteById", "getById"])
},
jobs: {
base: "/jobs/",
getById: getFn(jobsModel, ["getJobById", "getById"])
},
housing: {
base: "/housing/",
getById: getFn(housingModel, ["getHousingById", "getById"])
},
projects: {
base: "/projects/",
getById: getFn(projectsModel, ["getProjectById", "getById"])
},
transfers: {
base: "/transfers/",
getById: getFn(transfersModel, ["getTransferById", "getById"])
},
forum: {
base: "/forum/",
getById: getFn(forumModel, ["getForumById", "getById"])
},
blogs: {
base: "/blogs/",
getById: getFn(blogsModel, ["getBlogById", "getById"])
},
polls: {
base: "/polls/",
getById: getFn(pollsModel, ["getPollById", "getById"])
},
shops: {
base: "/shops/",
getById: getFn(shopsModel, ["getShopById", "getById"])
} }
}; };
const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "torrents", "videos", "market", "shopProducts"]; const kindOrder = ["audios", "blogs", "bookmarks", "calendars", "chats", "documents", "events", "forum", "housing", "images", "jobs", "maps", "market", "pads", "polls", "projects", "reports", "shopProducts", "shops", "tasks", "torrents", "transfers", "videos", "votes"];
const hydrateKind = async (kind, ids) => { const hydrateKind = async (kind, ids) => {
const cfg = kindConfig[kind]; const cfg = kindConfig[kind];
@ -79,9 +127,11 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
if (!id) return null; if (!id) return null;
try { try {
const obj = await cfg.getById(id); const obj = await cfg.getById(id);
if (!obj || typeof obj !== "object") return null;
const viewId = safeText(obj?.key || obj?.id || id); const viewId = safeText(obj?.key || obj?.id || id);
return { return {
content: obj,
kind, kind,
favId: id, favId: id,
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`, viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
@ -104,7 +154,7 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
}; };
const loadAll = async () => { const loadAll = async () => {
const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k))); const sets = await Promise.all(kindOrder.map((k) => contentFavorites.getFavoriteSet(k)));
const idsByKind = {}; const idsByKind = {};
kindOrder.forEach((k, i) => { kindOrder.forEach((k, i) => {
idsByKind[k] = Array.from(sets[i] || []); idsByKind[k] = Array.from(sets[i] || []);
@ -118,21 +168,8 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
const flat = kindOrder.flatMap((k) => byKind[k]); const flat = kindOrder.flatMap((k) => byKind[k]);
const counts = { const counts = { all: flat.length };
audios: byKind.audios.length, for (const k of kindOrder) counts[k] = (byKind[k] || []).length;
bookmarks: byKind.bookmarks.length,
calendars: byKind.calendars.length,
chats: byKind.chats.length,
documents: byKind.documents.length,
images: byKind.images.length,
maps: byKind.maps.length,
pads: byKind.pads.length,
torrents: byKind.torrents.length,
videos: byKind.videos.length,
market: byKind.market.length,
shopProducts: byKind.shopProducts.length,
all: flat.length
};
const recentFlat = flat const recentFlat = flat
.slice() .slice()
@ -167,11 +204,13 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
return { items: grouped, counts }; return { items: grouped, counts };
}, },
kinds: kindOrder.slice(),
async removeFavorite(kind, id) { async removeFavorite(kind, id) {
const k = safeText(kind); const k = safeText(kind);
const favId = safeText(id); const favId = safeText(id);
if (!k || !favId) return; if (!k || !favId) return;
await mediaFavorites.removeFavorite(k, favId); await contentFavorites.removeFavorite(k, favId);
} }
}; };
}; };

View file

@ -503,8 +503,7 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
text: c.text, text: c.text,
author: c.author, author: c.author,
timestamp: m.value.timestamp, timestamp: m.value.timestamp,
parent: c.branch || null, parent: c.branch || null
subtopic: c.subtopic || null
})); }));
for (let r of replies) { for (let r of replies) {
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key); const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);

554
src/models/housing_model.js Normal file
View file

@ -0,0 +1,554 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const categories = require("../backend/opinion_categories")
const { getConfig } = require("../configs/config-manager.js")
const { dedupeByPreferring } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
const HOUSING_TYPES = ["sale", "rent", "couchsurfing"]
const PROPERTY_TYPES = ["apartment", "house", "room", "land", "other"]
const norm = (s) => String(s || "").trim().toLowerCase()
const safeArr = (v) => (Array.isArray(v) ? v : [])
const toNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const toInt = (v, fallback = 0) => {
const n = parseInt(String(v ?? ""), 10)
return Number.isFinite(n) ? n : fallback
}
const nonNeg = (v) => {
const n = toNum(v)
return Number.isFinite(n) && n >= 0 ? n : 0
}
const safeDate = (v) => {
const s = String(v || "").trim()
if (!s) return ""
const d = new Date(s)
return Number.isFinite(d.getTime()) ? s : ""
}
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const safeType = (v) => {
const t = norm(v)
return HOUSING_TYPES.includes(t) ? t : ""
}
const safeProperty = (v) => {
const t = norm(v)
return PROPERTY_TYPES.includes(t) ? t : "other"
}
const { MAX_IMAGES, normalizeImages, normalizeVideo } = require('./media_gallery')
const matchSearch = (item, q) => {
const qq = norm(q)
if (!qq) return true
const hay = [
item.title,
item.description,
item.place,
item.rules,
item.housing_type,
item.property_type,
safeArr(item.tags).join(" ")
].map(x => norm(x)).join(" ")
return hay.includes(qq)
}
module.exports = ({ cooler, tribeCrypto }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const isEncrypted = (c) => !!(c && c.encryptedPayload)
const keysForRoot = (rootId) => {
if (!tribeCrypto || !rootId) return []
const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || []
if (ks.length) return ks
const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null
return k ? [k] : []
}
const decryptHousing = (c, rootId) => {
if (!isEncrypted(c)) return c
if (!tribeCrypto) return { ...c, _undecryptable: true }
const keys = keysForRoot(rootId)
if (!keys.length) return { ...c, _undecryptable: true }
return tribeCrypto.decryptContent(c, keys.map(k => [k]))
}
const publishHousing = (ssbClient, content, rootId) => new Promise((resolve, reject) => {
const hidden = String(content.visibility || "PUBLIC").toUpperCase() === "HIDDEN"
if (!hidden || !tribeCrypto) {
ssbClient.publish(content, (err, msg) => {
if (err) return reject(err)
if (msg && msg.key && tribeCrypto && !rootId) {
try { tribeCrypto.setKey(msg.key, tribeCrypto.generateTribeKey(), 1) } catch (_) {}
}
resolve(msg)
})
return
}
let key = rootId ? (keysForRoot(rootId)[0] || null) : null
if (!key) key = tribeCrypto.generateTribeKey()
let envelope
try { envelope = tribeCrypto.encryptContent(content, [key], true) } catch (err) { return reject(err) }
ssbClient.publish(envelope, (err, msg) => {
if (err) return reject(err)
try { tribeCrypto.setKey(rootId || (msg && msg.key), key, 1) } catch (_) {}
resolve(msg)
})
})
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
)
const buildIndex = (messages, ssbClient) => {
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
const naiveReplaces = new Map()
const requestLatest = new Map()
const opinionMsgs = []
for (const m of messages) {
const key = m.key
const v = m.value || {}
const c = v.content
if (!c || typeof c !== "object") continue
if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
if (c.type === "housing") {
nodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) naiveReplaces.set(key, c.replaces)
continue
}
if (c.type === "housingOpinion" && c.target) {
opinionMsgs.push({ target: c.target, author: v.author, category: c.category })
continue
}
if (c.type === "housingRequest" && c.housingId) {
const author = v.author
if (!author) continue
const ts = v.timestamp || m.timestamp || 0
const k = `${c.housingId}::${author}`
const prev = requestLatest.get(k)
if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId })
continue
}
}
for (const [key, replacesId] of naiveReplaces.entries()) {
const node = nodes.get(key)
if (!node) continue
const orig = nodes.get(replacesId)
if (!orig) continue
if (String(orig.author) !== String(node.author)) { nodes.delete(key); continue }
parent.set(key, replacesId)
child.set(replacesId, key)
}
const rootOf = (id) => {
let cur = id, guard = 0
while (parent.has(cur) && guard++ < 100000) cur = parent.get(cur)
return cur
}
const tipOf = (id) => {
let cur = id, guard = 0
while (child.has(cur) && guard++ < 100000) cur = child.get(cur)
return cur
}
const roots = new Set()
for (const id of nodes.keys()) roots.add(rootOf(id))
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, tipOf(r))
if (ssbClient) {
for (const m of messages) {
if (typeof m.value?.content !== 'string') continue
try {
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
const c = dec?.value?.content
if (!c || c.type !== 'housingRequest' || !c.housingId) continue
const author = dec.value.author
if (!author) continue
const ts = dec.value.timestamp || m.timestamp || 0
const k = `${c.housingId}::${author}`
const prev = requestLatest.get(k)
if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId })
} catch {}
}
}
const requestsByRoot = new Map()
const everRequestedByRoot = new Map()
for (const { housingId, author, value } of requestLatest.values()) {
if (!requestsByRoot.has(housingId)) requestsByRoot.set(housingId, new Set())
if (!everRequestedByRoot.has(housingId)) everRequestedByRoot.set(housingId, new Set())
everRequestedByRoot.get(housingId).add(author)
const set = requestsByRoot.get(housingId)
if (value) set.add(author)
else set.delete(author)
}
const opinionsByRoot = new Map()
for (const op of opinionMsgs) {
if (!nodes.has(op.target)) continue
const r = rootOf(op.target)
if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, [])
opinionsByRoot.get(r).push(op)
}
const aggregateFor = (rootId, content, ownerId) => {
const opinions = { ...((content && content.opinions) || {}) }
const voters = safeArr(content && content.opinions_inhabitants).slice()
const voterSet = new Set(voters)
for (const op of (opinionsByRoot.get(rootId) || [])) {
if (!op.author || op.author === ownerId) continue
if (voterSet.has(op.author)) continue
if (!categories.includes(op.category)) continue
voterSet.add(op.author); voters.push(op.author)
opinions[op.category] = (opinions[op.category] || 0) + 1
}
return { opinions, opinions_inhabitants: voters }
}
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, requestsByRoot, everRequestedByRoot, aggregateFor }
}
const buildObject = (node, rootId, idx, viewer) => {
const raw = node.c || {}
const c = decryptHousing(raw, rootId)
if (c._undecryptable) return null
const author = node.author || c.author
const requests = Array.from(idx.requestsByRoot.get(rootId) || [])
const visibleRequests = (author === viewer) ? requests : (requests.includes(viewer) ? [viewer] : [])
const agg = idx.aggregateFor(rootId, c, author)
return {
id: node.key,
rootId,
housing_type: safeType(c.housing_type),
property_type: safeProperty(c.property_type),
title: String(c.title || ""),
description: String(c.description || ""),
rules: String(c.rules || ""),
place: String(c.place || ""),
mapUrl: String(c.mapUrl || ""),
price: Number.isFinite(toNum(c.price)) ? toNum(c.price).toFixed(6) : "0.000000",
rooms: toInt(c.rooms, 0),
size: nonNeg(c.size),
capacity: toInt(c.capacity, 0),
availableFrom: safeDate(c.availableFrom),
availableTo: safeDate(c.availableTo),
images: normalizeImages(c.images && c.images.length ? c.images : c.image),
image: normalizeImages(c.images && c.images.length ? c.images : c.image)[0] || null,
video: normalizeVideo(c.video),
author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
status: String(c.status || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN",
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
requests: visibleRequests,
requestCount: requests.length,
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants,
requestedByViewer: requests.includes(viewer),
everRequestedByViewer: (idx.everRequestedByRoot.get(rootId) || new Set()).has(viewer),
ratedByViewer: agg.opinions_inhabitants.includes(viewer),
visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
}
const buildContent = (data, opts = {}) => {
const housing_type = safeType(data.housing_type)
if (!housing_type) throw new Error("Invalid housing type")
const title = String(data.title || "").trim()
if (!title) throw new Error("Invalid title")
const description = String(data.description || "").trim()
if (!description) throw new Error("Invalid description")
const availableFrom = safeDate(data.availableFrom)
const availableTo = safeDate(data.availableTo)
if (!availableFrom) throw new Error("The start date is required")
if (opts.enforceFutureStart) {
const [y, m, d] = String(availableFrom).split("-").map(Number)
const startsAt = new Date(y, (m || 1) - 1, d || 1)
const today = new Date()
today.setHours(0, 0, 0, 0)
if (startsAt < today) throw new Error("The start date cannot be earlier than today")
}
if (availableTo && new Date(availableTo) < new Date(availableFrom)) {
throw new Error("The end date cannot be earlier than the start date")
}
const images = normalizeImages(data.images !== undefined ? data.images : data.image)
const video = normalizeVideo(data.video)
return {
type: "housing",
housing_type,
property_type: safeProperty(data.property_type),
title,
description,
rules: String(data.rules || "").trim(),
place: String(data.place || "").trim(),
mapUrl: String(data.mapUrl || "").trim(),
price: housing_type === "couchsurfing" ? "0.000000" : nonNeg(data.price).toFixed(6),
rooms: Math.max(0, toInt(data.rooms, 0)),
size: nonNeg(data.size),
capacity: Math.max(0, toInt(data.capacity, 0)),
availableFrom,
availableTo,
images,
image: images[0] || null,
video,
tags: normalizeTags(data.tags),
visibility: String(data.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
}
return {
type: "housing",
HOUSING_TYPES,
PROPERTY_TYPES,
MAX_IMAGES,
async createHousing(data) {
const ssbClient = await openSsb()
const content = {
...buildContent(data, { enforceFutureStart: true }),
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "OPEN",
opinions: {},
opinions_inhabitants: []
}
return publishHousing(ssbClient, content, null)
},
async resolveCurrentId(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tip = idx.tipOf(id)
if (idx.tomb.has(tip)) throw new Error("Housing not found")
return tip
},
async resolveRootId(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tip = idx.tipOf(id)
if (idx.tomb.has(tip)) throw new Error("Housing not found")
return idx.rootOf(tip)
},
async updateHousing(id, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node || !node.c) throw new Error("Housing not found")
if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized")
const rootId = idx.rootOf(tipId)
const current = decryptHousing(node.c, rootId)
if (current._undecryptable) throw new Error("Cannot decrypt this listing")
const startChanged = data.availableFrom !== undefined && safeDate(data.availableFrom) !== safeDate(current.availableFrom)
const merged = buildContent({
housing_type: data.housing_type === undefined ? current.housing_type : data.housing_type,
property_type: data.property_type === undefined ? current.property_type : data.property_type,
title: data.title === undefined ? current.title : data.title,
description: data.description === undefined ? current.description : data.description,
rules: data.rules === undefined ? current.rules : data.rules,
place: data.place === undefined ? current.place : data.place,
mapUrl: data.mapUrl === undefined ? current.mapUrl : data.mapUrl,
price: data.price === undefined ? current.price : data.price,
rooms: data.rooms === undefined ? current.rooms : data.rooms,
size: data.size === undefined ? current.size : data.size,
capacity: data.capacity === undefined ? current.capacity : data.capacity,
availableFrom: data.availableFrom === undefined ? current.availableFrom : data.availableFrom,
availableTo: data.availableTo === undefined ? current.availableTo : data.availableTo,
images: data.images === undefined
? (current.images && current.images.length ? current.images : current.image)
: data.images,
video: data.video === undefined ? current.video : data.video,
tags: data.tags === undefined ? current.tags : data.tags,
visibility: data.visibility === undefined ? current.visibility : data.visibility
}, { enforceFutureStart: startChanged })
let status = String(current.status || "OPEN").toUpperCase()
if (data.status !== undefined) {
const s = String(data.status || "").toUpperCase()
if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
status = s
}
const next = {
...merged,
status,
author: current.author,
createdAt: current.createdAt,
updatedAt: new Date().toISOString(),
opinions: current.opinions || {},
opinions_inhabitants: safeArr(current.opinions_inhabitants),
replaces: tipId
}
const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
return publishHousing(ssbClient, next, rootId)
},
async updateHousingStatus(id, status) {
return this.updateHousing(id, { status: String(status || "").toUpperCase() })
},
async deleteHousing(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Housing not found")
if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized")
const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
},
async requestHousing(id) {
const ssbClient = await openSsb()
const me = ssbClient.id
const item = await this.getHousingById(id)
if (item.author === me) throw new Error("Cannot request your own listing")
if (item.status !== "OPEN") throw new Error("This listing is closed")
if (safeArr(item.requests).includes(me)) return { alreadyRequested: true }
const msg = { type: "housingRequest", housingId: item.rootId, value: true, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m)))
},
async cancelRequest(id) {
const ssbClient = await openSsb()
const me = ssbClient.id
const item = await this.getHousingById(id)
if (item.author === me) throw new Error("Cannot cancel a request on your own listing")
if (!safeArr(item.requests).includes(me)) return { notRequested: true }
const msg = { type: "housingRequest", housingId: item.rootId, value: false, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m)))
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid category")
const ssbClient = await openSsb()
const me = ssbClient.id
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Housing not found")
const rootId = idx.rootOf(tipId)
const owner = node.author || node.c.author
if (owner === me) throw new Error("You cannot rate your own listing")
const agg = idx.aggregateFor(rootId, decryptHousing(node.c, rootId), owner)
if (agg.opinions_inhabitants.includes(me)) throw new Error("Already voted")
const everRequested = idx.everRequestedByRoot.get(rootId) || new Set()
if (!everRequested.has(me)) throw new Error("You can rate only a place you have requested")
const content = { type: "housingOpinion", target: rootId, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async listHousing(filter = "ALL", viewerId = null, query = {}) {
const ssbClient = await openSsb()
const viewer = viewerId || ssbClient.id
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const items = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node) continue
const item = buildObject(node, rootId, idx, viewer)
if (!item) continue
if (item.visibility === "HIDDEN" && item.author !== viewer) continue
items.push(item)
}
const F = String(filter || "ALL").toUpperCase()
let list = dedupeByPreferring(
items,
(h) => (h.author && h.createdAt) ? [norm(h.author), norm(h.createdAt), norm(h.title)].join("|") : null,
(h) => h.requestCount
)
if (F === "MINE") list = list.filter(h => h.author === viewer)
else if (F === "REQUESTED") list = list.filter(h => safeArr(h.requests).includes(viewer))
else if (HOUSING_TYPES.includes(norm(F))) list = list.filter(h => h.housing_type === norm(F))
else if (F === "OPEN") list = list.filter(h => h.status === "OPEN")
else if (F === "CLOSED") list = list.filter(h => h.status === "CLOSED")
else if (F === "RECENT") list = list.filter(h => moment(h.createdAt).isAfter(moment().subtract(24, "hours")))
const search = String(query.search || query.q || "").trim()
if (search) list = list.filter(h => matchSearch(h, search))
const minP = toNum(query.minPrice)
const maxP = toNum(query.maxPrice)
if (Number.isFinite(minP)) list = list.filter(h => toNum(h.price) >= minP)
if (Number.isFinite(maxP)) list = list.filter(h => toNum(h.price) <= maxP)
const place = String(query.place || "").trim()
if (place) list = list.filter(h => norm(h.place).includes(norm(place)))
const ratingOf = (h) => safeArr(h.opinions_inhabitants).length
const sort = String(query.sort || "").trim()
if (F === "TOP" || sort === "rating") list.sort((a, b) => ratingOf(b) - ratingOf(a))
else if (sort === "price") list.sort((a, b) => toNum(a.price) - toNum(b.price))
else if (sort === "requests") list.sort((a, b) => b.requestCount - a.requestCount)
else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async getHousingById(id, viewerId = null) {
const ssbClient = await openSsb()
const viewer = viewerId || ssbClient.id
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Housing not found")
const rootId = idx.rootOf(tipId)
const item = buildObject(node, rootId, idx, viewer)
if (!item) throw new Error("Housing not found")
if (item.visibility === "HIDDEN" && item.author !== viewer) throw new Error("Housing not found")
return item
}
}
}

View file

@ -142,7 +142,6 @@ module.exports = ({ cooler }) => {
title: c.title || "", title: c.title || "",
description: c.description || "", description: c.description || "",
mapUrl: c.mapUrl || "", mapUrl: c.mapUrl || "",
meme: !!c.meme,
opinions: agg ? agg.opinions : (c.opinions || {}), opinions: agg ? agg.opinions : (c.opinions || {}),
opinions_inhabitants: voters, opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false, hasVoted: viewerId ? voters.includes(viewerId) : false,
@ -177,7 +176,7 @@ module.exports = ({ cooler }) => {
return root; return root;
}, },
async createImage(blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) { async createImage(blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb(); const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown); const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || []; const tags = normalizeTags(tagsRaw) || [];
@ -193,7 +192,6 @@ module.exports = ({ cooler }) => {
title: title || "", title: title || "",
description: description || "", description: description || "",
mapUrl: mapUrl || "", mapUrl: mapUrl || "",
meme: !!memeBool,
opinions: {}, opinions: {},
opinions_inhabitants: [] opinions_inhabitants: []
}; };
@ -203,7 +201,7 @@ module.exports = ({ cooler }) => {
}); });
}, },
async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) { async updateImageById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb(); const ssbClient = await openSsb();
const userId = ssbClient.id; const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id); const tipId = await this.resolveCurrentId(id);
@ -226,7 +224,6 @@ module.exports = ({ cooler }) => {
title: title !== undefined ? title || "" : oldMsg.content.title || "", title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "", description: description !== undefined ? description || "" : oldMsg.content.description || "",
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "", mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
createdAt: oldMsg.content.createdAt, createdAt: oldMsg.content.createdAt,
updatedAt: now updatedAt: now
}; };
@ -280,7 +277,6 @@ module.exports = ({ cooler }) => {
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId)); if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000); else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
else if (filter === "meme") list = list.filter((im) => im.meme === true);
else if (filter === "top") { else if (filter === "top") {
list = list list = list
.slice() .slice()

View file

@ -130,9 +130,9 @@ module.exports = ({ cooler }) => {
return filterInactive(users); return filterInactive(users);
} }
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP ECO') { if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP INACTIVITY' || filter === 'TOP ECO') {
let users = await listAllBase(ssbClient); let users = await listAllBase(ssbClient);
if (filter !== 'TOP ACTIVITY') { if (filter !== 'TOP ACTIVITY' && filter !== 'TOP INACTIVITY') {
users = filterInactive(users); users = filterInactive(users);
} }
if (search) { if (search) {
@ -170,6 +170,7 @@ module.exports = ({ cooler }) => {
})); }));
if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0)); if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0)); if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
if (filter === 'TOP INACTIVITY') return withMetrics.sort((a, b) => (a.lastActivityTs || 0) - (b.lastActivityTs || 0));
if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0)); if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0));
return withMetrics; return withMetrics;
} }
@ -254,7 +255,7 @@ module.exports = ({ cooler }) => {
); );
} }
if (filter === 'CVs' || filter === 'MATCHSKILLS') { if (filter === 'CVs') {
const records = await new Promise((res, rej) => { const records = await new Promise((res, rej) => {
pull( pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true}), ssbClient.createLogStream({ limit: logLimit, reverse: true}),
@ -295,54 +296,13 @@ module.exports = ({ cooler }) => {
return out; return out;
} }
if (filter === 'MATCHSKILLS') {
let base = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const norm = this._normalizeCurriculum(c, photo);
const karmaScore = await getLastKarmaScore(c.author).catch(() => 0);
return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range, karmaScore };
}));
base = filterInactive(base);
const mecv = await this.getCVByUserId();
const userSkills = Array.from(new Set(
(mecv
? [
...(mecv.personalSkills || []),
...(mecv.oasisSkills || []),
...(mecv.educationalSkills || []),
...(mecv.professionalSkills || [])
]
: []).map(s => String(s || '').toLowerCase()).filter(Boolean)
));
if (!userSkills.length) return [];
const userSet = new Set(userSkills);
const matches = base.map(c => {
if (c.id === userId) return null;
const theirSkillsRaw = (c.skills || []).map(s => String(s || '').toLowerCase()).filter(Boolean);
const theirSet = new Set(theirSkillsRaw);
const common = Array.from(theirSet).filter(s => userSet.has(s));
if (!common.length) return null;
const unionSize = userSet.size + theirSet.size - common.length;
const matchScore = unionSize > 0 ? common.length / unionSize : 0;
const matchCoverage = userSet.size > 0 ? common.length / userSet.size : 0;
return { ...c, commonSkills: common, matchScore, matchCoverage };
}).filter(Boolean);
return matches.sort((a, b) =>
(b.matchScore - a.matchScore) ||
(b.commonSkills.length - a.commonSkills.length) ||
((b.karmaScore || 0) - (a.karmaScore || 0)) ||
((b.lastActivityTs || 0) - (a.lastActivityTs || 0))
);
}
} }
return []; return [];
}, },
_normalizeCurriculum(c, photoUrl) { _normalizeCurriculum(c, photoUrl) {
const photo = photoUrl || toImageUrl(c.photo, 256); const photo = c.photo ? toImageUrl(c.photo, 256) : (photoUrl || toImageUrl(null, 256));
return { return {
id: c.author, id: c.author,
name: c.name, name: c.name,
@ -358,6 +318,8 @@ module.exports = ({ cooler }) => {
languages: typeof c.languages === 'string' languages: typeof c.languages === 'string'
? c.languages.split(',').map(x => x.trim()) ? c.languages.split(',').map(x => x.trim())
: Array.isArray(c.languages) ? c.languages : [], : Array.isArray(c.languages) ? c.languages : [],
status: c.status,
preferences: c.preferences,
createdAt: c.createdAt createdAt: c.createdAt
}; };
}, },
@ -407,7 +369,7 @@ module.exports = ({ cooler }) => {
const isOwner = viewer === target; const isOwner = viewer === target;
const arr = (v) => Array.isArray(v) ? v : []; const arr = (v) => Array.isArray(v) ? v : [];
const up = (v) => String(v || '').toUpperCase(); const up = (v) => String(v || '').toUpperCase();
const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','industry','shop','image','video','audio','document','bookmark','transfer','map']); const COUNTED = new Set(['post','event','task','forum','tribe','market','job','housing','project','industry','shop','image','video','audio','document','bookmark','transfer','map']);
const accessible = (type, c) => { const accessible = (type, c) => {
if (c.encryptedPayload) return false; if (c.encryptedPayload) return false;
switch (type) { switch (type) {
@ -415,6 +377,7 @@ module.exports = ({ cooler }) => {
case 'event': return String(c.isPublic || '').toLowerCase() !== 'private' || isOwner || arr(c.attendees).includes(viewer); case 'event': return String(c.isPublic || '').toLowerCase() !== 'private' || isOwner || arr(c.attendees).includes(viewer);
case 'forum': return c.isPrivate !== true || isOwner; case 'forum': return c.isPrivate !== true || isOwner;
case 'job': return up(c.visibility) !== 'HIDDEN' || isOwner || arr(c.subscribers).includes(viewer); case 'job': return up(c.visibility) !== 'HIDDEN' || isOwner || arr(c.subscribers).includes(viewer);
case 'housing': return up(c.visibility) !== 'HIDDEN' || isOwner;
case 'market': return up(c.visibility) !== 'HIDDEN' || isOwner; case 'market': return up(c.visibility) !== 'HIDDEN' || isOwner;
case 'shop': return up(c.visibility) !== 'CLOSED' || isOwner; case 'shop': return up(c.visibility) !== 'CLOSED' || isOwner;
case 'tribe': { const st = up(c.status); return !(st === 'PRIVATE' || st === 'INVITE-ONLY') || isOwner || arr(c.members).includes(viewer); } case 'tribe': { const st = up(c.status); return !(st === 'PRIVATE' || st === 'INVITE-ONLY') || isOwner || arr(c.members).includes(viewer); }

View file

@ -79,7 +79,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const jobId = c.jobId const jobId = c.jobId
const k = `${jobId}::${author}` const k = `${jobId}::${author}`
const prev = jobSubLatest.get(k) const prev = jobSubLatest.get(k)
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId }) if (!prev || ts >= prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
continue continue
} }
} }
@ -134,7 +134,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const jobId = c.jobId const jobId = c.jobId
const k = `${jobId}::${author}` const k = `${jobId}::${author}`
const prev = jobSubLatest.get(k) const prev = jobSubLatest.get(k)
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId }) if (!prev || ts >= prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
} catch {} } catch {}
} }
} }

198
src/models/karvan_model.js Normal file
View file

@ -0,0 +1,198 @@
"use strict";
// Módulo KARVAN (self-contained) para Oasis Mobile — inspirado en karvan-protocol
// (packages/core/addons/ephemeral.js): salas EFÍMERAS en RAM que se AUTODESTRUYEN por TTL.
// - Nada se escribe a disco. Los mensajes viven solo en memoria y desaparecen con la sala.
// - TTL de inactividad (idle) que se resetea con cada actividad + TTL absoluto que nunca se resetea.
// - Mailbox RAM para señalización WebRTC (data-channel) entre pares.
// No depende de @karvan/core ni de cripto nativa: es una capa local sobre el backend de Oasis.
const crypto = require("crypto");
const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000; // 30 min sin actividad -> autodestrucción
const DEFAULT_ABS_TTL_MS = 2 * 60 * 60 * 1000; // 2 h absoluto (tope)
const MAX_ABS_TTL_MS = 24 * 60 * 60 * 1000; // nunca más de 24 h
const MAX_MSGS = 250; // anillo: solo los últimos N en RAM
const MAX_SIGNALS = 500;
const MAX_ROOMS = 100;
const MAX_MEMBERS = 50; // tope de miembros por sala (evita crecimiento sin límite del Set)
const MAX_SIGNAL_BYTES = 16384; // SDP/ICE son pequeños; rechaza payloads gigantes (anti-DoS de memoria)
module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_MS } = {}) => {
const rooms = new Map(); // id -> room
const publicRoom = (r) => ({
id: r.id, title: r.title, createdAt: r.createdAt, expiresAt: r.expiresAt,
count: r.messages.length, members: r.members.size,
});
const resetIdle = (room) => {
if (room.idleTimer) clearTimeout(room.idleTimer);
room.idleTimer = setTimeout(() => destroy(room.id), idleTtlMs);
if (room.idleTimer.unref) room.idleTimer.unref();
};
const destroy = (id) => {
const room = rooms.get(id);
if (!room) return;
clearTimeout(room.idleTimer);
clearTimeout(room.absTimer);
// AUTODESTRUCCIÓN: vaciar todo el material en RAM
room.messages.length = 0;
room.signals.length = 0;
room.members.clear();
room.remoteFeeds.clear();
rooms.delete(id);
};
const createRoom = ({ title = "", ttlMinutes } = {}) => {
if (rooms.size >= MAX_ROOMS) {
// desalojar la más antigua para no crecer sin límite
const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
if (oldest) destroy(oldest.id);
}
const id = crypto.randomBytes(9).toString("hex"); // 18 hex
const createdAt = Date.now();
let absMs = absTtlMs;
const t = parseInt(ttlMinutes, 10);
if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS);
const room = {
id,
title: String(title || "").slice(0, 80),
createdAt,
expiresAt: createdAt + absMs,
messages: [],
signals: [],
members: new Set(),
remoteFeeds: new Set(), // feeds SSB de otros nodos con esta sala (para relay cross-device por privados)
seq: 0,
sigSeq: 0,
idleTimer: null,
absTimer: null,
};
rooms.set(id, room);
room.absTimer = setTimeout(() => destroy(id), absMs);
if (room.absTimer.unref) room.absTimer.unref();
resetIdle(room);
return publicRoom(room);
};
// Fase 2 (cross-device): registra el feed SSB de otro participante (de una invitación) para relayarle.
const addRemoteFeed = (id, feed) => {
const room = rooms.get(id);
if (!room || typeof feed !== "string" || !/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(feed)) return false;
if (room.remoteFeeds.has(feed) || room.remoteFeeds.size < MAX_MEMBERS) { room.remoteFeeds.add(feed); return true; }
return false;
};
const getRemoteFeeds = (id) => { const r = rooms.get(id); return r ? [...r.remoteFeeds] : []; };
// ¿hay ALGUNA sala con feeds remotos? (para no montar el relay si no hace falta)
const anyRemoteRooms = () => { for (const r of rooms.values()) if (r.remoteFeeds.size) return true; return false; };
// Fase 2 (invitar): unirse a una sala por su id creando un ESPEJO local (desde un enlace/invitación).
// El id es la "capacidad": quien lo tiene puede unirse. Aún NO conecta con la sala del otro nodo
// (eso es la señalización cross-device, pendiente); crea la sala local para poder abrir el enlace.
const adoptRoom = ({ id, title = "", ttlMinutes } = {}) => {
if (!id || typeof id !== "string" || !/^[0-9a-f]{6,64}$/.test(id)) return null;
const existing = rooms.get(id);
if (existing) return publicRoom(existing);
if (rooms.size >= MAX_ROOMS) {
const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
if (oldest) destroy(oldest.id);
}
const createdAt = Date.now();
let absMs = absTtlMs;
const t = parseInt(ttlMinutes, 10);
if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS);
const room = {
id, title: String(title || "").slice(0, 80), createdAt, expiresAt: createdAt + absMs,
messages: [], signals: [], members: new Set(), remoteFeeds: new Set(), seq: 0, sigSeq: 0, idleTimer: null, absTimer: null,
};
rooms.set(id, room);
room.absTimer = setTimeout(() => destroy(id), absMs);
if (room.absTimer.unref) room.absTimer.unref();
resetIdle(room);
return publicRoom(room);
};
const listRooms = () =>
[...rooms.values()].map(publicRoom).sort((a, b) => b.createdAt - a.createdAt);
const getRoom = (id) => {
const r = rooms.get(id);
return r ? publicRoom(r) : null;
};
const postMessage = (id, { from = "?", text = "", mid = "" } = {}) => {
const room = rooms.get(id);
if (!room) return null;
const clean = String(text || "").slice(0, 2000);
if (!clean) return null;
const msg = {
seq: ++room.seq,
mid: String(mid || "").slice(0, 40),
from: String(from || "?").slice(0, 80),
text: clean,
ts: Date.now(),
};
room.messages.push(msg);
if (room.messages.length > MAX_MSGS) room.messages.shift();
if (room.members.has(msg.from) || room.members.size < MAX_MEMBERS) room.members.add(msg.from);
resetIdle(room);
return msg;
};
const listMessages = (id, since = 0) => {
const room = rooms.get(id);
if (!room) return null;
const s = parseInt(since, 10) || 0;
return room.messages.filter((m) => m.seq > s);
};
// --- señalización WebRTC (mailbox RAM) ---
const postSignal = (id, { from = "", to = "", payload = null } = {}) => {
const room = rooms.get(id);
if (!room) return null;
if (payload != null) { // rechaza payloads gigantes (SDP/ICE son pequeños)
try { if (JSON.stringify(payload).length > MAX_SIGNAL_BYTES) return null; }
catch (e) { return null; }
}
const sig = {
seq: ++room.sigSeq,
from: String(from || "").slice(0, 80),
to: String(to || "").slice(0, 80),
payload,
ts: Date.now(),
};
room.signals.push(sig);
if (room.signals.length > MAX_SIGNALS) room.signals.shift();
if (sig.from && (room.members.has(sig.from) || room.members.size < MAX_MEMBERS)) room.members.add(sig.from);
resetIdle(room);
return sig;
};
const getSignals = (id, { forId = "", since = 0 } = {}) => {
const room = rooms.get(id);
if (!room) return null;
const s = parseInt(since, 10) || 0;
return {
signals: room.signals.filter(
(x) => x.seq > s && x.from !== forId && (!x.to || x.to === forId)
),
members: [...room.members].filter((m) => m !== forId),
};
};
return {
createRoom,
adoptRoom,
addRemoteFeed,
getRemoteFeeds,
anyRemoteRooms,
listRooms,
getRoom,
postMessage,
listMessages,
postSignal,
getSignals,
_destroy: destroy,
};
};

View file

@ -165,6 +165,11 @@ function getGoverningHouseKey(now = new Date()) {
return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length]; return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length];
} }
function getGoverningPeriodId(now = new Date()) {
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${getGoverningHouseKey(now)}:${now.getFullYear()}-${month}`;
}
module.exports = ({ cooler, tribesModel, tribeCrypto }) => { module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
let ssb; let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
@ -568,10 +573,14 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
}); });
} }
function wallIsPublic(houseKey, { isGoverning = false } = {}) {
return houseKey === 'academia' || isGoverning === true;
}
async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) { async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
if (!VALID_KEY(houseKey)) return []; if (!VALID_KEY(houseKey)) return [];
const viewerIsMember = viewerHouse === houseKey; const viewerIsMember = viewerHouse === houseKey;
if (!viewerIsMember && !isGoverning) return []; if (!viewerIsMember && !wallIsPublic(houseKey, { isGoverning })) return [];
const client = await openSsb(); const client = await openSsb();
const memberships = await listAllMemberships(); const memberships = await listAllMemberships();
return new Promise((resolve) => { return new Promise((resolve) => {
@ -912,11 +921,13 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
return { return {
HOUSES, HOUSES,
HOUSE_KEYS, HOUSE_KEYS,
wallIsPublic,
TEST_COOLDOWN_MS, TEST_COOLDOWN_MS,
TEST_QUESTIONS_COUNT, TEST_QUESTIONS_COUNT,
PROFILE_QUESTIONS, PROFILE_QUESTIONS,
computeCycle, computeCycle,
getGoverningHouseKey, getGoverningHouseKey,
getGoverningPeriodId,
publishJoin, publishJoin,
publishLeaveLarp, publishLeaveLarp,
getUserHouse, getUserHouse,

View file

@ -49,8 +49,8 @@ module.exports = {
exportData: async (password) => { exportData: async (password) => {
try { try {
const pw = normalizePassword(password); const pw = normalizePassword(password);
const homeDir = os.homedir(); // identidad de la cuenta activa
const secretFilePath = path.join(homeDir, '.ssb', 'secret'); const secretFilePath = path.join(require('../server/ssb_config').path, 'secret');
if (!fs.existsSync(secretFilePath)) { if (!fs.existsSync(secretFilePath)) {
throw new Error(".ssb/secret file doesn't exist"); throw new Error(".ssb/secret file doesn't exist");
} }
@ -78,8 +78,8 @@ module.exports = {
} catch (_) { } catch (_) {
throw new Error('Wrong password or corrupt backup file.'); throw new Error('Wrong password or corrupt backup file.');
} }
const homeDir = os.homedir(); // identidad de la cuenta activa
const ssbDir = path.join(homeDir, '.ssb'); const ssbDir = require('../server/ssb_config').path;
fs.mkdirSync(ssbDir, { recursive: true }); fs.mkdirSync(ssbDir, { recursive: true });
const secretPath = path.join(ssbDir, 'secret'); const secretPath = path.join(ssbDir, 'secret');
if (fs.existsSync(secretPath)) { if (fs.existsSync(secretPath)) {

View file

@ -6,6 +6,7 @@ const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_MS = 7 * DAY_MS; const WEEK_MS = 7 * DAY_MS;
const MONTH_MS = 30 * DAY_MS; const MONTH_MS = 30 * DAY_MS;
@ -311,7 +312,16 @@ module.exports = ({ cooler }) => {
ref: c.ref || null ref: c.ref || null
}); });
} }
const parentOf = new Map();
for (const i of items) if (i.replaces) parentOf.set(i.key, i.replaces);
const rootOf = (key) => {
let cur = key;
const seen = new Set();
while (parentOf.has(cur) && !seen.has(cur)) { seen.add(cur); cur = parentOf.get(cur); }
return cur;
};
const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key)); const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key));
for (const i of survivors) i.rootId = rootOf(i.key);
survivors.sort((a, b) => b.ts - a.ts); survivors.sort((a, b) => b.ts - a.ts);
return survivors; return survivors;
} }
@ -332,11 +342,14 @@ module.exports = ({ cooler }) => {
async function updateLog(id, { text, label, mode }) { async function updateLog(id, { text, label, mode }) {
const current = await getLogById(id); const current = await getLogById(id);
if (!current) return { status: 'not_found' }; if (!current) return { status: 'not_found' };
await republishLog({ const next = {
replaces: current.key,
text: text !== undefined ? text : current.text, text: text !== undefined ? text : current.text,
label: label !== undefined ? label : current.label, label: label !== undefined ? label : current.label,
mode: mode || current.mode, mode: mode || current.mode
};
await republishLog({
replaces: current.key,
...next,
createdAt: current.createdAt createdAt: current.createdAt
}); });
return { status: 'ok' }; return { status: 'ok' };

View file

@ -66,8 +66,8 @@ const configure = (...customOptions) =>
Object.assign({}, defaultOptions, ...customOptions); Object.assign({}, defaultOptions, ...customOptions);
// PEERS // PEERS
const ebtDir = path.join(os.homedir(), '.ssb', 'ebt'); const ebtDir = path.join(require('../server/ssb_config').path, 'ebt');
const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json'); const unfollowedPath = path.join(require('../server/ssb_config').path, 'gossip_unfollowed.json');
async function loadPeersFromEbt() { async function loadPeersFromEbt() {
let result = []; let result = [];
@ -501,6 +501,31 @@ async function checkLocalBlob(blobId) {
} }
models.blob = { models.blob = {
getCached: async ({ blobId }) => {
const local = await checkLocalBlob(blobId);
if (local) return local;
const ssb = await cooler.open();
const has = await new Promise((resolve) => ssb.blobs.has(blobId, (err, v) => resolve(!err && !!v)));
if (!has) {
try { ssb.blobs.want(blobId, () => {}); } catch (_) {}
return null;
}
return new Promise((resolve) => {
pull(
ssb.blobs.get(blobId),
pull.collect(async (err, bufs) => {
if (err || !bufs || !bufs.length) return resolve(null);
const buffer = Buffer.concat(bufs);
try {
const filePath = blobIdToHexPath(blobId);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, buffer);
} catch (e) { /* ignore */ }
resolve(buffer);
})
);
});
},
getResolved: async ({ blobId, timeout = 30000 }) => { getResolved: async ({ blobId, timeout = 30000 }) => {
let buf = await checkLocalBlob(blobId); let buf = await checkLocalBlob(blobId);
if (buf) return buf; if (buf) return buf;
@ -764,7 +789,7 @@ models.meta = {
discovered: async () => { discovered: async () => {
const ssb = await cooler.open(); const ssb = await cooler.open();
const snapshot = await ssb.conn.dbPeers(); const snapshot = await ssb.conn.dbPeers();
const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json'); const gossipPath = path.join(require('../server/ssb_config').path, 'gossip.json');
let gossipMap = new Map(); let gossipMap = new Map();
try { try {
const gossipData = JSON.parse(await fs.readFile(gossipPath, 'utf8')); const gossipData = JSON.parse(await fs.readFile(gossipPath, 'utf8'));

View file

@ -864,7 +864,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
let mapKey = null; let mapKey = null;
if (tribeCrypto && typeof matchedInvite === "object") { if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) { if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt); const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3);
if (Array.isArray(chain) && chain.length) { if (Array.isArray(chain) && chain.length) {
for (const entry of chain) { for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) { if (Array.isArray(entry.keys) && entry.keys.length) {

View file

@ -1,6 +1,7 @@
const pull = require("../server/node_modules/pull-stream") const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js") const { getConfig } = require("../configs/config-manager.js")
const categories = require("../backend/opinion_categories")
const { buildValidatedTombstoneSet } = require('./tombstone_validator') const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const { dedupeByPreferring, norm } = require('../backend/dedupe') const { dedupeByPreferring, norm } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000 const logLimit = getConfig().ssbLogStream?.limit || 1000
@ -73,12 +74,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
const nodes = new Map() const nodes = new Map()
const bids = [] const bids = []
const purchases = [] const purchases = []
const opinionMsgs = []
for (const m of messages) { for (const m of messages) {
const c = m.value && m.value.content const c = m.value && m.value.content
if (!c) continue if (!c) continue
if (c.type === "tombstone") continue if (c.type === "tombstone") continue
if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue } if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue } if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "marketOpinion" && c.target) { opinionMsgs.push({ target: c.target, author: m.value.author, category: c.category }); continue }
if (c.type !== "market") continue if (c.type !== "market") continue
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c, author: m.value.author }) nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c, author: m.value.author })
} }
@ -95,11 +98,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!cn || cn.author !== pn.author) { naivePrev.delete(child); nodes.delete(child); continue } if (!cn || cn.author !== pn.author) { naivePrev.delete(child); nodes.delete(child); continue }
strictNext.set(parent, child) strictNext.set(parent, child)
} }
return { tomb, nodes, bids, purchases, naivePrev, strictNext } return { tomb, nodes, bids, purchases, opinionMsgs, naivePrev, strictNext }
} }
const resolveGroups = (idx) => { const resolveGroups = (idx) => {
const { tomb, nodes, bids, purchases, naivePrev, strictNext } = idx const { tomb, nodes, bids, purchases, opinionMsgs, naivePrev, strictNext } = idx
const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && nodes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x } const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && nodes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x }
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x } const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
@ -108,7 +111,21 @@ module.exports = ({ cooler, tribeCrypto }) => {
const bidsByRoot = new Map() const bidsByRoot = new Map()
for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) } for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) }
const soldByRoot = new Map() const soldByRoot = new Map()
for (const pu of purchases) { if (!nodes.has(pu.target)) continue; const r = rootOf(pu.target); soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1) } const buyersByRoot = new Map()
for (const pu of purchases) {
if (!nodes.has(pu.target)) continue
const r = rootOf(pu.target)
soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1)
if (!buyersByRoot.has(r)) buyersByRoot.set(r, new Set())
buyersByRoot.get(r).add(pu.author)
}
const opinionsByRoot = new Map()
for (const op of (opinionMsgs || [])) {
if (!nodes.has(op.target)) continue
const r = rootOf(op.target)
if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, [])
opinionsByRoot.get(r).push(op)
}
const out = new Map() const out = new Map()
for (const [root, keys] of groups) { for (const [root, keys] of groups) {
@ -131,7 +148,16 @@ module.exports = ({ cooler, tribeCrypto }) => {
const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) } const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) }
for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) } for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) }
for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) } for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) }
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0 }) const opinions = {}
const voters = []
for (const op of (opinionsByRoot.get(root) || [])) {
if (!op.author || op.author === sellerId) continue
if (voters.includes(op.author)) continue
if (!categories.includes(op.category)) continue
voters.push(op.author)
opinions[op.category] = (opinions[op.category] || 0) + 1
}
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0, buyers: Array.from(buyersByRoot.get(root) || []), opinions, voters })
} }
return out return out
} }
@ -228,9 +254,13 @@ module.exports = ({ cooler, tribeCrypto }) => {
normalized.price = p.toFixed(6) normalized.price = p.toFixed(6)
} }
const currentItem = await new Promise((resolve) => ssbClient.get(tipId, (e, m) => resolve(e ? null : (m && m.content) || null)))
if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") { if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
const dl = moment(normalized.deadline, moment.ISO_8601, true) const dl = moment(normalized.deadline, moment.ISO_8601, true)
if (!dl.isValid()) throw new Error("Invalid deadline") if (!dl.isValid()) throw new Error("Invalid deadline")
const changed = !currentItem || !currentItem.deadline || !moment(currentItem.deadline).isSame(dl)
if (changed && dl.isBefore(moment(), "minute")) throw new Error("The deadline cannot be in the past")
normalized.deadline = dl.toISOString() normalized.deadline = dl.toISOString()
} }
@ -298,7 +328,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const items = [] const items = []
const now = moment() const now = moment()
for (const { tip, rootId, best, statusN, poll, soldCount } of resolveGroups(buildMarketIndex(messages)).values()) { for (const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } of resolveGroups(buildMarketIndex(messages)).values()) {
const leaf = tip const leaf = tip
const c = best.c const c = best.c
let status = D(statusN) let status = D(statusN)
@ -346,7 +376,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
shopProductId: c.shopProductId || "", shopProductId: c.shopProductId || "",
shopId: c.shopId || "", shopId: c.shopId || "",
shopTitle: c.shopTitle || "", shopTitle: c.shopTitle || "",
industry: c.industry || "" industry: c.industry || "",
opinions: opinions || {},
opinions_inhabitants: voters || [],
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
}) })
} }
@ -401,7 +435,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x } const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
const grp = resolveGroups(idx).get(rootOf(itemId)) const grp = resolveGroups(idx).get(rootOf(itemId))
if (!grp) return null if (!grp) return null
const { tip, rootId, best, statusN, poll, soldCount } = grp const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } = grp
const c = best.c const c = best.c
let status = D(statusN) let status = D(statusN)
@ -448,7 +482,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
shopProductId: c.shopProductId || "", shopProductId: c.shopProductId || "",
shopId: c.shopId || "", shopId: c.shopId || "",
shopTitle: c.shopTitle || "", shopTitle: c.shopTitle || "",
industry: c.industry || "" industry: c.industry || "",
opinions: opinions || {},
opinions_inhabitants: voters || [],
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
} }
}, },
@ -489,6 +527,24 @@ module.exports = ({ cooler, tribeCrypto }) => {
return items.find((i) => i.shopProductId === shopProductId) || null return items.find((i) => i.shopProductId === shopProductId) || null
}, },
async createOpinion(itemId, category) {
if (!categories.includes(category)) throw new Error("Invalid category")
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const groups = resolveGroups(buildMarketIndex(messages))
let group = null
for (const g of groups.values()) {
if (g.rootId === itemId || g.tip === itemId) { group = g; break }
}
if (!group) throw new Error("Item not found")
if (group.best.author === userId) throw new Error("You cannot rate your own item")
if ((group.voters || []).includes(userId)) throw new Error("Already voted")
if (!(group.buyers || []).includes(userId)) throw new Error("You can rate only an item you have bought")
const content = { type: "marketOpinion", target: group.rootId, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async setItemAsSold(itemId) { async setItemAsSold(itemId) {
const tipId = await this.resolveCurrentId(itemId) const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb() const ssbClient = await openSsb()

View file

@ -0,0 +1,42 @@
const MAX_IMAGES = 8
const MEDIA_MARKDOWN = /\[(image|video|audio):[^\]]*\]\(/
const blobOf = (value) => {
let blobId = value || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
return blobId || null
}
const keepMarkdown = (value, id) =>
MEDIA_MARKDOWN.test(value) || value.startsWith("![") ? value : id
const normalizeImages = (raw, max = MAX_IMAGES) => {
const list = Array.isArray(raw) ? raw : (raw ? [raw] : [])
const out = []
const seen = new Set()
for (const entry of list) {
const value = String(entry || "").trim()
const id = blobOf(value)
if (!id || seen.has(id)) continue
seen.add(id)
out.push(keepMarkdown(value, id))
if (out.length >= max) break
}
return out
}
const normalizeVideo = (raw) => {
const value = String(Array.isArray(raw) ? raw[0] || "" : raw || "").trim()
const id = blobOf(value)
if (!id) return ""
return keepMarkdown(value, id)
}
const mergeGallery = (current, uploaded, removeIndex = -1, max = MAX_IMAGES) => {
let list = normalizeImages(current, max)
if (removeIndex >= 0) list = list.filter((_, i) => i !== removeIndex)
return normalizeImages([...list, ...normalizeImages(uploaded, max)], max)
}
module.exports = { MAX_IMAGES, blobOf, normalizeImages, normalizeVideo, mergeGallery }

Some files were not shown because too many files have changed in this diff Show more