Compare commits
143 commits
0.8.7_UX_P
...
PRUEBAS
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ca8f53825 | ||
|
|
1a52ab60a3 | ||
|
|
2d79e6779c | ||
|
|
e4bbbc23f2 | ||
|
|
a85aa24b82 | ||
|
|
894f7b24a2 | ||
|
|
6f0e45ed1d | ||
|
|
389c84b93d | ||
|
|
b9b3e274a7 | ||
|
|
6c1eca7ad0 | ||
|
|
6990ee5675 | ||
|
|
83f26ba19e | ||
|
|
ff91ef938c | ||
|
|
438ecba47b | ||
|
|
ae2c235436 | ||
|
|
b84a8856dc | ||
|
|
b277f8e1cf | ||
|
|
f75fbf7c4a | ||
|
|
188bd74195 | ||
|
|
150f6faf82 | ||
|
|
4a6c99ee59 | ||
|
|
30ef2bd270 | ||
|
|
e861b05946 | ||
|
|
18098dd38e | ||
|
|
e3fcf2455d | ||
|
|
d267d2befe | ||
|
|
92a8dc3853 | ||
|
|
3238520891 | ||
|
|
05027031ef | ||
|
|
d3e799e592 | ||
|
|
9f8317baa3 | ||
|
|
9e2716f69b | ||
|
|
3f8839d9a8 | ||
|
|
0a8935fe16 | ||
|
|
1beb53568c | ||
|
|
062d49c367 | ||
|
|
382d903bc5 | ||
|
|
6eafe5ee18 | ||
|
|
b65dde1263 | ||
|
|
6bacafea64 | ||
|
|
34265c2ed0 | ||
|
|
10e47f9bc1 | ||
|
|
e6ea3f4b58 | ||
|
|
d4e6b6c8e8 | ||
|
|
096a7d110b | ||
|
|
3937ddab1e | ||
|
|
ea35ca7ca1 | ||
|
|
1148e6a472 | ||
|
|
e7892c4bf9 | ||
|
|
35fef607d7 | ||
|
|
5bce7d38ed | ||
|
|
abae8132a4 | ||
|
|
087b86a184 | ||
|
|
d04d99ece2 | ||
|
|
9b391cfc3d | ||
|
|
f29540e17e | ||
|
|
c1f4ba75de | ||
|
|
4dd573662c | ||
|
|
2e3ff1e79f | ||
|
|
0baf85faa9 | ||
|
|
ac921c9d56 | ||
|
|
9a4ca6b2b3 | ||
|
|
b64dcf9b7a | ||
|
|
ed77a13c85 | ||
|
|
b3172ed1c3 | ||
|
|
99e5d766e9 | ||
|
|
ba057f0092 | ||
|
|
de5e8fba20 | ||
|
|
101bdfe4e8 | ||
|
|
7d281902fb | ||
|
|
cfe0b6cca1 | ||
|
|
5f5c1d6d0a | ||
|
|
948b6460d9 | ||
|
|
bc64565e9c | ||
|
|
ab9bfab09a | ||
|
|
e01f718c1c | ||
|
|
5a04113330 | ||
|
|
a11a730c26 | ||
|
|
c332afd6ea | ||
|
|
8d838dd8b1 | ||
|
|
923bb0ef9d | ||
|
|
da2ad8e1c6 | ||
|
|
5c769a5a10 | ||
|
|
e0ff70c138 | ||
|
|
728f10beb0 | ||
|
|
161d636f17 | ||
|
|
e84a9f84da | ||
|
|
d843d47374 | ||
|
|
f465fb2288 | ||
|
|
a22e4a49d4 | ||
|
|
37937e8363 | ||
|
|
48cafbbe59 | ||
|
|
5bfbe2d6c8 | ||
|
|
cb892579f5 | ||
|
|
2203b54917 | ||
|
|
0bf4a8fcb9 | ||
|
|
f354069af7 | ||
|
|
f80b2e04f0 | ||
|
|
f3330e3d0e | ||
|
|
736b528b99 | ||
|
|
841acf2a7a | ||
|
|
c3a1c841e6 | ||
|
|
7459644a73 | ||
|
|
32f6d2269c | ||
|
|
ace175cca3 | ||
|
|
ad2d5c8f62 | ||
|
|
7b90fa8211 | ||
|
|
7eeac8b004 | ||
|
|
373fba5d96 | ||
|
|
c662e37668 | ||
|
|
82b6c3ae05 | ||
|
|
f964e61a1c | ||
|
|
b3986b12a0 | ||
|
|
e5e8e53374 | ||
|
|
9321718d90 | ||
|
|
3fe71a0ec2 | ||
|
|
61273dfeec | ||
|
|
6020c7f8df | ||
|
|
2c2c8f241d | ||
|
|
af160dbd26 | ||
|
|
813bf16958 | ||
|
|
e2598ab747 | ||
|
|
423e973f02 | ||
|
|
634625d134 | ||
|
|
b31b8cbfbd | ||
|
|
81be3e6c21 | ||
|
|
e938c8bb42 | ||
|
|
745c340917 | ||
|
|
ed9f01e8e6 | ||
|
|
2b2c65fc7a | ||
|
|
7f85389a88 | ||
|
|
d5dac39bfd | ||
|
|
c088d176c1 | ||
|
|
871cccbe17 | ||
|
|
0d26562450 | ||
|
|
4085f35a41 | ||
|
|
1f7d51e6b1 | ||
|
|
8ab1cf6d18 | ||
|
|
230d11ba7a | ||
|
|
8d21216362 | ||
|
|
de36b38851 | ||
|
|
9ff72a17a0 | ||
|
|
89a11fd789 |
6
.gitignore
vendored
|
|
@ -1 +1,7 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# pruebas en local: el node_modules suele ser un enlace y el estado de la llamada lo escribe el movil
|
||||||
|
src/server/node_modules
|
||||||
|
.phone_state.json
|
||||||
|
android/.tools/
|
||||||
|
src/server/.update_required
|
||||||
|
|
|
||||||
385
GUIA_DE_DESARROLLO.md
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
# Guía de desarrollo
|
||||||
|
|
||||||
|
Notas para quien vaya a meter mano en la rama del móvil. Leedlas antes de empezar,
|
||||||
|
que nos ahorramos disgustos.
|
||||||
|
|
||||||
|
Esta rama va encima de Oasis, que es de epsylon. Él saca versión casi cada día
|
||||||
|
(39 entre la 1.0.1 y la 1.3.3, en siete semanas) y cada versión llega en un solo
|
||||||
|
commit. Si metemos cosas en sus ficheros, en cada integración hay que volver a
|
||||||
|
pelearse con ellas. Por eso la idea es sencilla: lo de Oasis lo cambia él; nosotros
|
||||||
|
cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.
|
||||||
|
|
||||||
|
Los números y de dónde salen están en `docs/FRONTERA_UPSTREAM.md`.
|
||||||
|
|
||||||
|
|
||||||
|
## La estrategia: una capa encima
|
||||||
|
|
||||||
|
Lo del dev entra tal cual y lo nuestro va encima, en ficheros nuestros: hojas de
|
||||||
|
estilo, vistas y un router propio que va delante del suyo. Se eligió así frente a
|
||||||
|
las otras salidas:
|
||||||
|
|
||||||
|
- Un fork que copie sus vistas y siga por libre se queda viejo en semanas (39
|
||||||
|
versiones en siete semanas).
|
||||||
|
- Una app propia que hable con su backend obliga a rehacer toda la interfaz, y Oasis
|
||||||
|
no tiene API: devuelve HTML.
|
||||||
|
- Reescribir su HTML o sus respuestas al vuelo se rompe sin avisar con cualquier
|
||||||
|
cambio suyo. Se probó y se quitó el 20/10/2026.
|
||||||
|
|
||||||
|
Las reglas de la capa:
|
||||||
|
|
||||||
|
1. Lo del dev no se cambia: ni su código ni lo que contesta (HTML, redirecciones,
|
||||||
|
cabeceras). Nuestro código no mira sus respuestas.
|
||||||
|
2. El aspecto, con CSS en nuestras hojas.
|
||||||
|
3. Otro HTML en una pantalla suya: función en `mobile_ui.js` y una línea en su vista.
|
||||||
|
4. Una pantalla o una ruta nueva: en `fork_routes.js`; lo que no es nuestro pasa al
|
||||||
|
dev con `next()`.
|
||||||
|
5. Si una pantalla del dev ya hace algo (editar el perfil, escribir, guardar los
|
||||||
|
módulos), se enlaza a ella o se envía a su ruta y se acepta a dónde vuelve. No se
|
||||||
|
copia su lógica.
|
||||||
|
6. Se pueden usar sus funciones (`getConfig`, `saveConfig`, leer de sus modelos).
|
||||||
|
Reescribirlas, no.
|
||||||
|
7. Si algo no se puede hacer con estas reglas, se le pide al dev.
|
||||||
|
|
||||||
|
Dónde va cada cosa:
|
||||||
|
|
||||||
|
| Quiero... | Va en |
|
||||||
|
|---|---|
|
||||||
|
| Cambiar colores, tamaños, márgenes | `styles/mobile_ux.css` (piezas) o `styles/activista.css` (lanzador y barras) |
|
||||||
|
| Cambiar los colores de serie o las paletas | Variables `--a26-*` de `themes/Activista.css`; las paletas, en `mobile_settings.js` |
|
||||||
|
| La barra de unirse a un pub | `views/mobile_pubbar.js` |
|
||||||
|
| Otro HTML en una pantalla del dev | Función en `views/mobile_ui.js` + una línea en su vista |
|
||||||
|
| Una pantalla nueva | Vista propia en `views/mobile_*.js` + ruta en `fork_routes.js` |
|
||||||
|
| Algo en Ajustes del móvil | `views/mobile_settings.js` |
|
||||||
|
| Un texto nuevo | `translations/i18n_fork.js`, en los 13 idiomas |
|
||||||
|
| Un icono | `views/mobile_icons.js` |
|
||||||
|
| Un workflow (perfil) o la barra de abajo de un perfil | `models/fork_workflows.js` (`FORK_WORKFLOWS` y `BARS`) |
|
||||||
|
| Algo de la APK | `android/scripts/sobre_oficial.sh` o `android/icono/` |
|
||||||
|
| Algo que la capa no deja | Pedírselo al dev |
|
||||||
|
|
||||||
|
Cómo seguir modificando, paso a paso:
|
||||||
|
|
||||||
|
1. Arrancar en local (ver «Probar en local») y localizar en el inspector la clase o
|
||||||
|
la pantalla.
|
||||||
|
2. Decidir con la tabla dónde va. Si la respuesta es un fichero del dev, parar y
|
||||||
|
releer las reglas.
|
||||||
|
3. Hacer el cambio.
|
||||||
|
4. Pasar «Antes de hacer commit».
|
||||||
|
5. Commit en castellano y subir la rama `PRUEBAS` a forgejo.
|
||||||
|
6. Montar la APK con `android/scripts/sobre_oficial.sh` y probarla en el emulador
|
||||||
|
`Oasis_Pruebas`.
|
||||||
|
7. Si entra un fichero o un mecanismo nuevo, apuntarlo en esta guía en el mismo commit.
|
||||||
|
|
||||||
|
|
||||||
|
## Lo que no se toca
|
||||||
|
|
||||||
|
- `src/backend/backend.js`. Cambia en 34 de cada 39 versiones. Ni rutas nuevas, ni
|
||||||
|
middleware, ni envolver funciones suyas como `checkMod`.
|
||||||
|
- La cadena de middleware del final de `backend.js`. Crece en cada versión.
|
||||||
|
- Los modelos de `src/models/`, salvo el nuestro (`fork_workflows.js`). Ahí se decide qué se publica en la red y cómo se cuenta.
|
||||||
|
Si lo cambiamos, nuestro móvil cuenta distinto que el escritorio de al lado. Un
|
||||||
|
ejemplo real: quien crea una votación no puede votar en ella. No nos gusta, pero
|
||||||
|
es una regla del modelo, así que se explica en pantalla y punto.
|
||||||
|
- `src/server/` (arranque de SSB, `ssb_config.js`, `package.json`).
|
||||||
|
- `src/backend/request_guards.js`, que lleva la CSP. Nada de `style=""` ni de
|
||||||
|
`<script>` en la página. Desde la 1.3.5 es `script-src 'none'`: tampoco valen
|
||||||
|
ficheros `.js` propios. Lo que haga falta, con enlaces, formularios y CSS.
|
||||||
|
- Las traducciones del dev, `translations/oasis_*.js`. Se reescriben casi en cada
|
||||||
|
versión.
|
||||||
|
- `styles/style.css` y `styles/mobile.css`.
|
||||||
|
- Los temas del dev: Dark, Clear, Purple, Matrix y también `OasisMobile.css`, que
|
||||||
|
aunque parezca nuestro es suyo (lo toca en 21 de 39 versiones).
|
||||||
|
|
||||||
|
|
||||||
|
## Las vistas del dev: una línea y ya
|
||||||
|
|
||||||
|
`src/views/*_view.js` y `main_views.js` son del dev y las reescribe a menudo
|
||||||
|
(`tribes_view.js` en 23 versiones, `main_views.js` en 31). Si en una pantalla suya
|
||||||
|
hace falta otro HTML en el móvil, el código va en `src/views/mobile_ui.js` y en la
|
||||||
|
vista solo queda la llamada:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const mobileUi = require('./mobile_ui');
|
||||||
|
// ...
|
||||||
|
if (mobileUi.isMobile()) return mobileUi.tribeTabs(tribe, section, sections);
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuando el dev reescribe la vista, se vuelve a poner esa línea y listo. En escritorio
|
||||||
|
no cambia nada porque la bandera `OASIS_MOBILE` solo la pone `main.js` en el móvil.
|
||||||
|
|
||||||
|
Ahora mismo hay enganches en `main_views.js`, `tribes_view.js`, `vote_view.js`,
|
||||||
|
`settings_view.js`, `welcome_view.js` y `activity_view.js` (el botón Filtrar, que
|
||||||
|
pliega las fichas de tipo de actividad). Para verlos: `grep -n mobileUi src/views/*.js`.
|
||||||
|
|
||||||
|
|
||||||
|
## Llamadas: las del dev
|
||||||
|
|
||||||
|
La rama tuvo un módulo de salas y llamadas propio (Karvan). Se quitó el 19/10/2026
|
||||||
|
porque el dev ya trae Teléfono y Salas. En Android el sonido y la cámara pasan por un
|
||||||
|
puente nativo de la app del dev (`.audio_bridge`; los ficheros
|
||||||
|
`src/server/phone_*_android.js` también son suyos). Por eso la APK se monta sobre la
|
||||||
|
oficial con `android/scripts/sobre_oficial.sh`: su app tal cual, nuestro backend
|
||||||
|
dentro y paquete propio. Si falta algo en las llamadas, se le pide al dev, no se monta
|
||||||
|
otro módulo.
|
||||||
|
|
||||||
|
Ojo al comparar con el dev: para el móvil la referencia es el `nodejs-project` de su
|
||||||
|
APK oficial, no su repositorio git. Hay ficheros, como `SSB_server.js` o
|
||||||
|
`phone_module.js`, cuya versión de móvil solo está en la APK.
|
||||||
|
|
||||||
|
Lo mismo vale para cualquier módulo: antes de hacer uno propio, mirad si el dev ya lo
|
||||||
|
tiene o lo está haciendo.
|
||||||
|
|
||||||
|
|
||||||
|
## Navegación
|
||||||
|
|
||||||
|
Al abrir se ve la actividad. Los módulos están en la quinta pestaña de abajo, Menú,
|
||||||
|
que abre una hoja con el lanzador (`hive_views.js`). La abre el ancla `#oasis-menu`
|
||||||
|
y la pinta `:target`: abrirla deja una entrada en el historial, así que el gesto atrás
|
||||||
|
la cierra sin cambiar de pantalla. La X, tocar fuera o volver a tocar Menú van a
|
||||||
|
`#menu-cerrado`, un ancla que no existe, para que la página no salte arriba. La barra tiene cuatro atajos más Menú. Cada perfil trae los suyos (`BARS` en
|
||||||
|
`fork_workflows.js`) y quien la cambia desde «Tu barra» la cambia solo para ese perfil
|
||||||
|
(`bottomBarBySkin` en la config; sin perfil reconocido, `bottomBarPins`). Por qué
|
||||||
|
así: `docs/ESTUDIO_NAVEGACION.md`. Si se cambia, que sea con una prueba que lo
|
||||||
|
justifique, no por gusto.
|
||||||
|
|
||||||
|
|
||||||
|
## Lo que es nuestro
|
||||||
|
|
||||||
|
Aquí se puede hacer lo que haga falta.
|
||||||
|
|
||||||
|
- `src/client/assets/styles/mobile_base.css`: la maqueta del móvil (lanzador, barras,
|
||||||
|
tarjetas, formularios). Se carga siempre en el móvil, antes del skin.
|
||||||
|
- `src/client/assets/styles/mobile_ux.css`: márgenes, letra, botones y todas las
|
||||||
|
piezas nuevas. Vale para los dos skins de móvil.
|
||||||
|
- `src/client/assets/themes/Activista.css` y `styles/activista.css`: el skin
|
||||||
|
Activista, el único del móvil (Acampada y el OasisMobile del dev se quitaron del
|
||||||
|
selector el 20/10/2026). Lo más simple posible: letra del sistema, bordes finos y
|
||||||
|
sin sombras. El skin quita lo que sobra; los colores los pone cada cual.
|
||||||
|
- `src/views/mobile_pubbar.js`: la barra fija de unirse a un pub (y de restaurar una
|
||||||
|
copia), encima de la barra de abajo. Solo sale si hay algo que contar y se pone al
|
||||||
|
día al cambiar de pantalla (sin JS); el estado sale de
|
||||||
|
`backupModel.restoreStatus()`, que `backend.js` le pasa a `fork_routes.js` en la
|
||||||
|
línea que lo monta.
|
||||||
|
- `src/views/mobile_ui.js`: lo que se enchufa en vistas del dev.
|
||||||
|
- `src/views/mobile_settings.js`: los seis accesos de arriba de Ajustes (identidad,
|
||||||
|
perfil, portada, colores, comunidad y saludo) y los colores. Los colores se guardan
|
||||||
|
en la config (`mobileColors`) y salen en `/mobile-colors.css`, la última hoja; son
|
||||||
|
las variables `--a26-*` del skin Activista. La paleta de serie, Estándar, son los
|
||||||
|
colores del dev (negro y oro de su OasisMobile); las demás llevan nombre propio
|
||||||
|
(Plaza, Madrugada, Huerto, Violeta, Hoguera, Fanzine, Pancarta).
|
||||||
|
- `src/views/mobile_modules_view.js`, `identities_view.js`: pantallas propias.
|
||||||
|
- `src/views/hive_views.js`: lanzador y barras de arriba y abajo.
|
||||||
|
- `src/views/mobile_icons.js`: los iconos.
|
||||||
|
- `src/backend/fork_routes.js`: nuestro router. Va justo delante del del dev.
|
||||||
|
- `src/backend/mobile_layer.js`: lo que nuestra interfaz necesita del backend
|
||||||
|
(avatar, filtro y lanzador de la barra de arriba). La monta `fork_routes.js`. También
|
||||||
|
deja abiertos los comentarios de las fichas con la opción del dev
|
||||||
|
(`comments_view.setCommentsOpen`), salvo en el parlamento.
|
||||||
|
- `src/models/fork_workflows.js`: nuestros workflows, que en el móvil hacen de skins:
|
||||||
|
cada uno enciende unos módulos y trae su barra de abajo. Activista (acción directa,
|
||||||
|
6 módulos) es el de serie, después Acampada (16) y los del dev; en Ajustes y en la
|
||||||
|
bienvenida salen de menos a más módulos, con el número y los iconos de su barra.
|
||||||
|
Ojo: `backend.js` apaga a la fuerza en el móvil `ai`, `aiNav`, `dev`, `games`, `logs`,
|
||||||
|
`maps` y `pixelia`. Un perfil que encienda alguno de esos no se reconoce nunca como
|
||||||
|
puesto (le pasa a Movimiento, el `activists` del dev, y a Por defecto).
|
||||||
|
- `src/client/assets/translations/i18n_fork.js`: nuestros textos. Las claves
|
||||||
|
`bbShort_<modulo>` son el nombre corto de la barra de abajo cuando el del dev no cabe
|
||||||
|
en una de las cinco pestañas (medido a 412 y 360 px en los 13 idiomas); sin clave se
|
||||||
|
usa el nombre entero, que queda siempre como nombre accesible. Si un perfil nuevo
|
||||||
|
mete otro módulo en la barra, medirlo.
|
||||||
|
- Los avisos del dev (versión nueva y sugerencia, `.apk-banners .update-banner`) van
|
||||||
|
fijos abajo, encima de la barra, como la del pub: `mobile_ux.css`, sin tocar su HTML.
|
||||||
|
Se esconden con el teclado, con el menú y con la barra del pub; en el WebView viejo
|
||||||
|
se quedan arriba.
|
||||||
|
- `src/client/assets/images/ux-*.svg`: nuestras imágenes.
|
||||||
|
- `src/client/assets/images/mobile-*.svg`: iconos de línea que `mobile_ux.css` pinta
|
||||||
|
con `mask` en sitios del dev que llevan emoji o un carácter (avisos de arriba, fichas
|
||||||
|
de visibilidad, Compartir, estado del teléfono, responder y enviar en chats). Se
|
||||||
|
esconde el carácter con `font-size: 0` y el icono toma el color del texto. Mismo
|
||||||
|
trazo para todos: 24x24, línea de 2, puntas redondas. Los del menú y la barra de
|
||||||
|
abajo están en `views/mobile_icons.js` y no se tocan desde aquí. Nada de emoji ni de
|
||||||
|
logotipos (tampoco el robot de las sugerencias del dev).
|
||||||
|
- `android/scripts/sobre_oficial.sh`: monta la APK sobre la oficial del dev. Es lo
|
||||||
|
único de Android que hay en la rama; el proyecto Gradle propio se quitó.
|
||||||
|
- `docs/botones/descargar.svg`: el botón de descarga de arriba del README. Enlaza a la
|
||||||
|
APK de la versión publicada `apk-<versión>` en gitea (forgejo no la admite: su nginx
|
||||||
|
da 413 con ficheros de 60 MB). Con APK nueva, se publica otra versión y se cambian el
|
||||||
|
número del botón y el enlace.
|
||||||
|
- `android/icono/`: el icono adaptativo (la isla del logo de Oasis sobre su fondo).
|
||||||
|
`sobre_oficial.sh` lo copia encima del de la APK oficial, que solo trae un PNG
|
||||||
|
cuadrado y el lanzador lo encoge dentro de un círculo blanco.
|
||||||
|
|
||||||
|
|
||||||
|
## Cómo hacer cada cosa
|
||||||
|
|
||||||
|
Desde la 1.3.5 el dev monta en el móvil una cabecera por módulo (`apkModbar` en
|
||||||
|
`main_views.js`): se lleva a su barra el título, la primera caja de fichas, el buscador y
|
||||||
|
los contadores, y pone los avisos en `.apk-banners`. Su CSS está en `OasisMobile.css`, que
|
||||||
|
con el skin Activista no se carga, así que su estilo va en `mobile_ux.css` (bloque
|
||||||
|
«cabecera de modulo de la 1.3.5»). Hasta tres fichas, en rejilla de tres columnas. Con
|
||||||
|
cuatro o más van en una fila con una flecha a cada lado (bloque «filas de fichas que no
|
||||||
|
caben»): un carrusel de CSS puro con `::scroll-button` y anclas (`anchor-name`,
|
||||||
|
`anchor-scope`), sin JS y sin tocar su HTML. Lo mismo para las fichas de Gobierno de la
|
||||||
|
tribu. Un WebView anterior al 135 no lo entiende y deja la rejilla. Otra fila de fichas
|
||||||
|
que no quepa: añadir su selector a ese bloque. Filtrar de Actividad es una casilla con su etiqueta dentro de la propia
|
||||||
|
caja de fichas, para que viaje con ella a la cabecera.
|
||||||
|
|
||||||
|
Los colores de serie (paleta Estándar) son los de la APK oficial, medidos con el inspector
|
||||||
|
sobre su backend: si el dev los cambia, se vuelven a medir y se ponen en `PALETTES`
|
||||||
|
(`mobile_settings.js`). Los botones son los nuestros.
|
||||||
|
|
||||||
|
Cambiar cómo se ve algo. Se busca la clase en el HTML (servidor local e inspector) y
|
||||||
|
se escribe la regla en `mobile_ux.css`. Para los colores usad las variables `--ux-*`
|
||||||
|
(`--ux-bg`, `--ux-fg`, `--ux-accent`, `--ux-muted`, `--ux-line`, `--ux-danger`), que
|
||||||
|
tiran de las `--a26-*` del skin. Si la regla no gana,
|
||||||
|
es que `mobile.css` o `Activista.css` mandan con `!important` y selectores largos:
|
||||||
|
hay que subir la especificidad y poner `!important` también. Ojo con una trampa:
|
||||||
|
`Activista.css` quita fondo, borde y sombra a todos los `div`, así que para ponérselo a uno hace
|
||||||
|
falta `.main-column div.la-clase`.
|
||||||
|
|
||||||
|
Huecos de las barras del sistema: nada de `env(safe-area-inset-*)`. El contenedor de
|
||||||
|
la app del dev ya se aparta de la barra de estado y de la de gestos, y si se suma en
|
||||||
|
el CSS el hueco sale dos veces. Se usan `var(--inset-top)` y `var(--inset-bottom)`,
|
||||||
|
que valen 0 y están en `mobile_base.css`.
|
||||||
|
|
||||||
|
Botones de solo icono: debajo llevan su nombre en letra de 12 px, sacado de su `title` con
|
||||||
|
`attr(title)` en CSS (en el móvil no hay hover). El texto es el del dev, ya traducido. Si
|
||||||
|
un botón nuevo del dev no se entiende y no trae `title`, se le pide al dev.
|
||||||
|
|
||||||
|
Zonas táctiles: 44 px como mínimo para botones, fichas y casillas (con su etiqueta);
|
||||||
|
24 px para los enlaces que van dentro de una línea de texto (nombres, etiquetas).
|
||||||
|
|
||||||
|
Letra: Anton para títulos y para el botón negro principal de cada pantalla
|
||||||
|
(`.create-button`). Lo demás, letra del sistema en negrita.
|
||||||
|
|
||||||
|
Márgenes: 14 px a cada lado y ya. Los contenedores de en medio sin relleno, que
|
||||||
|
antes se iban sumando y una ficha perdía 74 px por lado.
|
||||||
|
|
||||||
|
Cambiar el HTML de una pantalla del dev. Función en `mobile_ui.js` y una línea en la
|
||||||
|
vista, como arriba.
|
||||||
|
|
||||||
|
Una pantalla o una ruta nueva. En `fork_routes.js`. Lo que no atiende nuestro
|
||||||
|
router pasa al del dev con `next()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
.get('/modules', async (ctx, next) => {
|
||||||
|
if (!isMobile()) return next(); // escritorio: la del dev
|
||||||
|
ctx.body = mobileModulesView({ ... });
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Retocar lo que contesta el dev: no se hace (regla 1). Antes había un `wrapUpstream`
|
||||||
|
que miraba sus redirecciones para el aviso de sección apagada, `?saved=1` al guardar
|
||||||
|
módulos y la vuelta a Ajustes tras guardar el perfil; se quitó el 20/10/2026 porque
|
||||||
|
cualquier cambio suyo lo rompía sin avisar. Ahora Ajustes enlaza a `/profile/edit` y
|
||||||
|
`/feed/create`, y guardar los módulos vuelve a donde decide el dev.
|
||||||
|
|
||||||
|
Textos. En `i18n_fork.js` y en los 13 idiomas (en, es, ca, gl, fr, eu, de, it, pt, zh,
|
||||||
|
ar, hi, ru). Un idioma que falte en `i18n_fork.js` coge el bloque en inglés entero y pisa
|
||||||
|
las claves que el dev sí tenía traducidas (pasó con catalán y gallego hasta el 22/10). Antes, mirad que la clave no exista ya en `oasis_en.js`, porque si existe la
|
||||||
|
estaríais cambiando también en escritorio. En el código siempre con texto de reserva:
|
||||||
|
`i18n.miClave || 'My text'`.
|
||||||
|
|
||||||
|
Un workflow. En `fork_workflows.js`, que lo mete en las listas que exporta el modelo
|
||||||
|
del dev. No se toca `workflows_model.js`.
|
||||||
|
|
||||||
|
Un icono. En `mobile_icons.js`, trazo de 2 px sobre rejilla de 24 y `currentColor`. El
|
||||||
|
nombre es el de la ruta: `/tribes` es `tribes`.
|
||||||
|
|
||||||
|
|
||||||
|
## Antes de hacer commit
|
||||||
|
|
||||||
|
- Que `git diff --stat` no toque nada de la lista de lo que no se toca. En las vistas
|
||||||
|
del dev, solo líneas de enganche.
|
||||||
|
- `node --check` en los `.js` que hayáis cambiado.
|
||||||
|
- Arrancar en local y ver que `/activity`, `/tribes`, `/modules`, `/settings`,
|
||||||
|
`/votes`, `/phone`, `/rooms` e `/inbox` cargan.
|
||||||
|
- Mirarlo a 412 px de ancho, y a 360 si es algo ancho.
|
||||||
|
- Si es CSS nuevo, mirarlo también en un WebView viejo (Android 9 trae el 69): no
|
||||||
|
hay `:has`, ni `gap` en flex, ni `inset`, ni `dvh`. Lo que dependa de eso va con
|
||||||
|
alternativa; hay un bloque `@supports not (inset: 0)` al final de `mobile_ux.css`.
|
||||||
|
En emulador x86 la APK no arranca por debajo de Android 12 (el traductor ARM no
|
||||||
|
sabe ejecutar el node del dev), así que el viejo se prueba con el servidor local y
|
||||||
|
el navegador de pruebas del WebView.
|
||||||
|
- Los textos nuevos, en los 13 idiomas, y la pantalla mirada al menos en uno largo
|
||||||
|
(de o ru) y uno de derecha a izquierda (ar): sin scroll de lado ni textos cortados.
|
||||||
|
- Nada en línea, que la CSP lo bloquea.
|
||||||
|
- Commit en castellano, sin coautorías de herramientas.
|
||||||
|
- Sin claves, contraseñas ni nada de `.ssb`.
|
||||||
|
|
||||||
|
|
||||||
|
## Cuando sale versión nueva del dev
|
||||||
|
|
||||||
|
1. Lo suyo se coge tal cual, y para los ficheros con versión de móvil, la de su APK
|
||||||
|
oficial (`assets/nodejs-project.zip`), no la de su git.
|
||||||
|
2. En sus vistas se vuelven a poner los enganches. El `grep -n mobileUi` sobre la
|
||||||
|
versión anterior de la rama dice dónde iban.
|
||||||
|
3. Se repite la medición de `docs/FRONTERA_UPSTREAM.md`: qué ha tocado y si ha
|
||||||
|
desaparecido alguna clase que usen nuestras hojas.
|
||||||
|
4. Se repasan a 412 px las pantallas de la lista de arriba.
|
||||||
|
5. Se monta la APK sobre la oficial de esa misma versión:
|
||||||
|
`OASIS_VERSION=x.y.z android/scripts/sobre_oficial.sh`.
|
||||||
|
6. Con REA se mira otra vez cómo extrae y cómo usa la WebView su APK
|
||||||
|
(`copyNodeProjectIfNeeded`, `configureWebView` y el manifiesto). Si cambia,
|
||||||
|
`sobre_oficial.sh` puede tener que cambiar. Los comandos, en
|
||||||
|
`docs/ARQUITECTURA_APK.md`.
|
||||||
|
|
||||||
|
|
||||||
|
## Probar en local
|
||||||
|
|
||||||
|
```sh
|
||||||
|
HOME=/tmp/oasis-prueba OASIS_NETWORK_PAUSED=1 node main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Y en el navegador `http://localhost:3000` con el inspector en modo móvil a 412x915.
|
||||||
|
Va con una identidad de usar y tirar y con la red parada.
|
||||||
|
|
||||||
|
Cosas que pasan:
|
||||||
|
- `src/server/node_modules` no está en git. Sirve el de la APK base.
|
||||||
|
- Si al reiniciar sale "Another Oasis instance is already running", el anterior sigue
|
||||||
|
vivo y estáis viendo código viejo. Matadlo primero.
|
||||||
|
- Al arrancar desaparecen `src/configs/banking-eco-history.json` y
|
||||||
|
`fediverse-accounts.json`. Antes del commit, `git checkout -- src/configs/`.
|
||||||
|
|
||||||
|
En el emulador, la APK de depuración deja inspeccionar la WebView desde
|
||||||
|
`chrome://inspect`. El `Pixel_9_API_36` de Android Studio está casi lleno; para
|
||||||
|
instalar la APK va mejor `Oasis_Pruebas` (12 GB, puerto 5556).
|
||||||
|
|
||||||
|
|
||||||
|
## La APK y la extracción del backend
|
||||||
|
|
||||||
|
La app del dev lleva nuestro código dentro de `assets/nodejs-project.zip` y al abrirse
|
||||||
|
lo descomprime en su carpeta de datos. Solo lo vuelve a descomprimir si cambia
|
||||||
|
`versionName.versionCode` (lo guarda en `nodejs-project/.version`). Si no cambia,
|
||||||
|
arranca con el código de la vez anterior aunque la APK sea nueva, y parece que se han
|
||||||
|
perdido los cambios (el CSS, por ejemplo). Por eso `sobre_oficial.sh` pone en el
|
||||||
|
`versionName` una huella del código (`1.3.3-hacklab.<huella>`): cada APK distinta se
|
||||||
|
descomprime de nuevo. La config de cada cual (`src/configs`) se guarda aparte y se
|
||||||
|
conserva.
|
||||||
|
|
||||||
|
Cómo está hecha la APK del dev por dentro (sacado con REA): `docs/ARQUITECTURA_APK.md`.
|
||||||
|
|
||||||
|
|
||||||
|
## Lo que todavía está mal
|
||||||
|
|
||||||
|
Para el móvil, la referencia es el `nodejs-project` de la APK oficial del dev, no su
|
||||||
|
git: él mete cambios de móvil (en `backend.js`, `SSB_server.js`, `phone_module.js`...)
|
||||||
|
que solo están en la APK. Comparado con eso, la rama cambia 15 ficheros suyos, 516
|
||||||
|
líneas (20/10/2026, contra el `nodejs-project` de la APK 1.3.3). Por tamaño:
|
||||||
|
|
||||||
|
- `backend/blobHandler.js` (190): limpiar los metadatos de las fotos también en
|
||||||
|
Android. Es una función nuestra metida en su fichero; habría que proponérsela al dev.
|
||||||
|
- `configs/oasis-config.json` (87) y `configs/config-manager.js` (18): la config de
|
||||||
|
serie del móvil (skin Activista, perfil Activismo, barra de abajo).
|
||||||
|
- `views/main_views.js` (64), `settings_view.js` (33), `welcome_view.js` (20),
|
||||||
|
`tribes_view.js` (9), `vote_view.js` (7): enganches a `mobile_ui.js` y el orden de
|
||||||
|
carga de las hojas. Es lo que la guía permite, pero hay que revisarlo en cada versión.
|
||||||
|
- `main.js` (45): DNS del sistema primero, para respetar la VPN.
|
||||||
|
- `server/ssb_config.js` (30): varias identidades en el mismo móvil.
|
||||||
|
- `models/workflows_model.js` (17): la lógica de skins del móvil (`themeOf`,
|
||||||
|
`sameTheme`). `settings_view.js` coge `currentWorkflow` al cargar y no se puede
|
||||||
|
envolver desde fuera.
|
||||||
|
- `styles/mobile.css` (14), `bookmark_view.js` (13), `forum_view.js` (7),
|
||||||
|
`translations/i18n.js` (5, la mezcla con `i18n_fork.js`): arreglos sueltos de antes.
|
||||||
|
- `backend/backend.js` (2): la línea que monta nuestro router.
|
||||||
|
|
||||||
|
Cuando el dev saque versión, se repite esta comparación con su APK nueva.
|
||||||
516
README.md
|
|
@ -1,328 +1,272 @@
|
||||||
# Oasis
|
# OASIS_MOBILE — rama de desarrollo experimental
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://gitea.laenre.net/hacklab/OASIS_MOBILE/releases/download/apk-1.3.7/oasis-1.3.7.apk"><img src="docs/botones/descargar.svg" alt="Descargar la APK 1.3.7" width="240"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Android 7 o superior, 64 bits. Se instala junto al Oasis oficial sin pisarlo.
|
||||||
|
|
||||||
|
> ## ⚠ ESTO NO ES OASIS OFICIAL
|
||||||
|
>
|
||||||
|
> 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.**
|
||||||
|
|
||||||
|
Si vas a desarrollar, lee antes la [guía de desarrollo](GUIA_DE_DESARROLLO.md). Ahí
|
||||||
|
está qué se puede tocar y qué no. En corto: el backend, el middleware, los modelos y
|
||||||
|
las traducciones son de epsylon y no se tocan; lo nuestro va en hojas, vistas y rutas
|
||||||
|
propias.
|
||||||
|
|
||||||
|
Versión base: Oasis 1.3.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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Novedades de esta versión
|
||||||
|
|
||||||
|
- Al día con Oasis 1.3.5: cabecera por módulo, buscador con más opciones, avisos de
|
||||||
|
versión y sugerencias, y su CSP sin JavaScript en la página.
|
||||||
|
- Arranca con el perfil Mínimo: chats, teléfono, eventos, emergencias, invitaciones y
|
||||||
|
copia de seguridad. Encima van Acampada (16 módulos), Activista (24) y los del dev,
|
||||||
|
de menos a más, cada uno con su barra de abajo. Se cambia en *Ajustes → Perfil*.
|
||||||
|
- Un solo skin, Activista: letra del sistema, bordes finos y sin sombras. Los colores
|
||||||
|
de serie son los de la APK oficial (negro y oro) y se cambian en *Ajustes → Colores*.
|
||||||
|
- Menú sin JavaScript: la hoja se abre con un ancla y el gesto atrás la cierra sin
|
||||||
|
cambiar de pantalla.
|
||||||
|
- Tribus: las pestañas se quedan a la vista al cambiar de sección, todas en el mismo
|
||||||
|
estilo, y los botones de la columna (unirse, invitar, suscribirse, etiquetas) de
|
||||||
|
44 px.
|
||||||
|
- Responder en los chats es un botón con su nombre, no una flecha de 27 px junto a la
|
||||||
|
hora; Enviar ocupa el ancho. Los comentarios salen abiertos, con la caja a la vista.
|
||||||
|
- Iconos de línea propios en vez de los emoji del dev en los avisos, las fichas de
|
||||||
|
visibilidad (pública, mutuos, cifrada, licencia, tribu) y Compartir.
|
||||||
|
- Publicar lleva al primer sitio de escribir que esté encendido (blog, feed, foro o
|
||||||
|
wiki); antes, sin blogs, acababa en Módulos.
|
||||||
|
|
||||||
|
### Lo que ya traía la rama
|
||||||
|
|
||||||
|
- La APK se monta sobre la oficial: se coge la del dev, se le cambia el backend por el
|
||||||
|
de la rama y se le da otro nombre de paquete. Así trae todo lo nativo del dev (el
|
||||||
|
puente de audio y cámara, las notificaciones de llamada) y convive con la oficial.
|
||||||
|
- Al abrir se ve la actividad, no un menú. Los módulos están en la quinta pestaña de
|
||||||
|
abajo, Menú, que abre una hoja desde abajo en cualquier pantalla.
|
||||||
|
- Votaciones como en asamblea: al crear una se elige Sí/No, semáforo (verde, amarillo,
|
||||||
|
rojo) o termómetro (del 1 al 5). Los resultados salen en barras.
|
||||||
|
- Borrar pide confirmación. Antes la X de una tribu la borraba de un toque.
|
||||||
|
- Iconos que se entienden en el lanzador y en la barra, en vez de letras del alfabeto
|
||||||
|
vai. La barra marca dónde estás y se esconde al escribir.
|
||||||
|
- Tribus en filas: doce comisiones caben en tres pantallas, no en diez. Dentro de una
|
||||||
|
tribu, las secciones van en una tira de fichas.
|
||||||
|
- Módulos con interruptores, agrupados como en el lanzador.
|
||||||
|
- Pantallas más aprovechadas: un margen de 14 px en vez de los 74 que se sumaban, una
|
||||||
|
sola letra para botones, buscador en una línea y el buzón en pocas líneas por mensaje.
|
||||||
|
- Fotos más seguras: si una foto no se puede limpiar de metadatos (ubicación,
|
||||||
|
cámara…), no se sube y se avisa.
|
||||||
|
- DNS: primero el resolutor del sistema, que respeta la VPN y el DNS privado de
|
||||||
|
Android; los públicos quedan de respaldo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lo que añade
|
||||||
|
|
||||||
|
| | Oasis oficial | Esta rama |
|
||||||
|
|---|---|---|
|
||||||
|
| Llamadas y salas | Teléfono y Salas, con micrófono y cámara | Los mismos, tal cual |
|
||||||
|
| Interfaz de móvil | El menú de escritorio en una pantalla pequeña | Skin Activista, actividad al abrir, hoja de Menú con iconos, barra de cuatro atajos |
|
||||||
|
| Primer arranque | Casi sesenta módulos encendidos | Perfil Mínimo (6 módulos) y una escalera de perfiles hasta los del dev |
|
||||||
|
| Votaciones | Seis opciones fijas en una tabla | Sí/No, semáforo o termómetro, con barras |
|
||||||
|
| Varias identidades | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave |
|
||||||
|
| App Android | La del dev | La del dev, con el backend de la rama y paquete propio (`net.laenre.oasis`) |
|
||||||
|
| Metadatos de las fotos | Se quitan solo si está `sharp`; en Android nunca | Se quitan siempre: GPS, marca, modelo, número de serie |
|
||||||
|
|
||||||
|
Los nombres de las categorías, los módulos y el resto de la aplicación son los de
|
||||||
|
epsylon, sin tocar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qué es Oasis, en cuatro dibujos
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 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).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||

|
## Llamadas y salas
|
||||||
|
|
||||||
Oasis redefines what it means to be connected in the modern world, giving people
|
El Teléfono (llamadas de uno a uno, con buzón de voz) y las Salas (hasta cincuenta
|
||||||
the ability to control their online presence and interactions without the need for centralized institutions.
|
personas, con vídeo, levantar la mano, grabación y emisión) son de Oasis. En Android
|
||||||
|
el sonido y la cámara no los maneja Node: el servidor se conecta a un puente nativo de
|
||||||
----------
|
la app (`.audio_bridge`), que graba, reproduce, saca fotogramas de la cámara y emite
|
||||||
|
ficheros.
|
||||||
## 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.
|
|
||||||
|
|
||||||

|
Ese puente está en la app Android del dev, que no tiene el código publicado. Por eso la
|
||||||
|
APK de esta rama se monta sobre la oficial: se queda con su app tal cual y solo cambia
|
||||||
|
lo que corre dentro (el backend con nuestra capa) y el nombre del paquete. Probado en
|
||||||
|
el emulador: se entra en una sala y el micrófono graba.
|
||||||
|
|
||||||
But it has others features that are also really interesting, for example:
|
En la barra de arriba, junto al avatar, está el botón de *No molestar* del Teléfono.
|
||||||
|
|
||||||
+ Support for multiple languages.
|
---
|
||||||
|
|
||||||

|
## La interfaz
|
||||||
|
|
||||||
+ Modularity to set your own environment.
|
<p align="center">
|
||||||
|
<img src="docs/capturas/01-actividad.png" width="200">
|
||||||
|
<img src="docs/capturas/02-menu.png" width="200">
|
||||||
|
<img src="docs/capturas/03-menu-activista.png" width="200">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/capturas/04-tribu.png" width="200">
|
||||||
|
<img src="docs/capturas/05-blog.png" width="200">
|
||||||
|
<img src="docs/capturas/06-ajustes.png" width="200">
|
||||||
|
</p>
|
||||||
|
|
||||||

|
Lo que sale al instalarla: la actividad al abrir y la hoja de Menú con el perfil
|
||||||
|
Mínimo. Después, con el perfil Activista: el menú completo, una tribu, un blog y
|
||||||
|
Ajustes. Capturas a 412 px, en español, con los colores de serie.
|
||||||
|
|
||||||
+ Support for multiple themes (including Mobile Theme).
|
Casi sesenta módulos no caben en la pantalla de un teléfono. Al abrir la app se ve la
|
||||||
|
actividad, lo que ha pasado en la red. Abajo hay cuatro atajos que eliges tú y una
|
||||||
|
quinta pestaña fija, Menú, que abre desde abajo una hoja con todos los módulos por
|
||||||
|
secciones, como el cajón de apps del móvil. Está en todas las pantallas. Por qué así,
|
||||||
|
y no un menú arriba o una hamburguesa: [docs/ESTUDIO_NAVEGACION.md](docs/ESTUDIO_NAVEGACION.md).
|
||||||
|
|
||||||

|
Solo aparecen los módulos activos. En *Ajustes → Perfil* se eligen de golpe, de
|
||||||

|
menos a más: Mínimo, Acampada, Activista y los del dev. Un perfil cambia los módulos y
|
||||||

|
la barra de abajo, no el aspecto: los colores van en *Ajustes → Colores*.
|
||||||
|
|
||||||
And some other nice modules, like for example:
|
---
|
||||||
|
|
||||||
+ A complex Reddit-styled forum system.
|
## Cómo está hecha la rama
|
||||||
|
|
||||||

|
Oasis lo desarrolla epsylon y saca versión casi cada día. Para no tener que rehacer la
|
||||||
|
rama en cada una, lo nuestro va en ficheros propios: hojas de estilo, unas pocas
|
||||||
|
vistas, un router que va delante del suyo y el envoltorio de Android. El backend, el
|
||||||
|
middleware, los modelos y las traducciones son suyos y no se tocan. Las normas están
|
||||||
|
en la [guía de desarrollo](GUIA_DE_DESARROLLO.md) y las cifras en
|
||||||
|
[docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md).
|
||||||
|
|
||||||
+ Or a collaborative -offline- maps system.
|
---
|
||||||
|
|
||||||

|
## Empezar desde cero
|
||||||
|
|
||||||
And much more, that we invite you to discover by yourself ;-)
|

|
||||||
|
|
||||||

|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [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 |
|
||||||
|
|
||||||
----------
|
---
|
||||||
|
|
||||||
## Modules:
|
## Instalar
|
||||||
|
|
||||||
Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
|
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.
|
||||||
|
|
||||||
+ Agenda: Module to manage all your assigned items.
|
Necesita unos 350 MB libres. El primer arranque tarda entre 15 y 60 segundos porque
|
||||||
+ AI: Module to talk with a LLM called '42'.
|
descomprime el servidor.
|
||||||
+ 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.
|
> Verifica la huella de la firma antes de instalar una APK bajada de un servidor
|
||||||
|
> propio: `apksigner verify --print-certs`
|
||||||
|
|
||||||
----------
|
### Compilar
|
||||||
|
|
||||||
## C-AI (collective artificial intelligence)
|
La APK se monta sobre la oficial de la misma versión. Hace falta Java 17 o más y las
|
||||||
|
build-tools de Android (zipalign y apksigner); apktool se baja solo.
|
||||||
|
|
||||||
Oasis contains its own AI model called "42".
|
```sh
|
||||||
|
cd android
|
||||||
|
./scripts/sobre_oficial.sh # baja la oficial, le pone el backend de la rama, la firma
|
||||||
|
# para publicar, con la clave del hacklab:
|
||||||
|
OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
Sale en `android/build/sobre-oficial/oasis-<versión>-hacklab.apk`. Sin clave se firma
|
||||||
|
con la de depuración y queda depurable, para poder inspeccionarla desde
|
||||||
|
`chrome://inspect`.
|
||||||
|
|
||||||

|
Qué hay dentro de la APK:
|
||||||
|
|
||||||
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.
|
| Pieza | De quién | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| La app Android (ventana, servicio, notificaciones, puente `.audio_bridge`) | Del dev, tal cual | Abre un WebView en `127.0.0.1:3000`, mantiene vivo el servidor y da micrófono y cámara |
|
||||||
|
| `node_modules` | Del dev, de su APK oficial | Las dependencias, podadas para el móvil |
|
||||||
|
| El servidor (`assets/nodejs-project.zip`) | Oasis 1.3.5 con nuestra capa | Se descomprime al primer arranque y cada vez que cambia la versión |
|
||||||
|
| El paquete | `net.laenre.oasis` | Para convivir con la oficial (`com.solarnethub.oasis`) |
|
||||||
|
|
||||||
+ https://wiki.solarnethub.com/socialnet/ai
|
Detalle, sacado de la APK oficial: [docs/ARQUITECTURA_APK.md](docs/ARQUITECTURA_APK.md).
|
||||||
|
|
||||||
----------
|
---
|
||||||
|
|
||||||
## Parliament
|
## Documentación
|
||||||
|
|
||||||
Oasis contains its own Parliament (Government system).
|
Para desarrollar, en el propio repositorio:
|
||||||
|
|
||||||

|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [GUIA_DE_DESARROLLO.md](GUIA_DE_DESARROLLO.md) | Qué se toca, qué no y cómo hacer cada cosa |
|
||||||
|
| [docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md) | Cuánto cambia el dev en cada versión, medido |
|
||||||
|
| [docs/ARQUITECTURA_APK.md](docs/ARQUITECTURA_APK.md) | Cómo está hecha la APK del dev por dentro |
|
||||||
|
| [docs/PENDIENTE.md](docs/PENDIENTE.md) | Lo que queda por hacer y lo que hay que pedir al dev |
|
||||||
|
|
||||||
## Courts
|
Todo lo de arriba, en detalle, está en la
|
||||||
|
[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki):
|
||||||
|
|
||||||
Oasis contains its own Courts (Justice system).
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [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 |
|
||||||
|
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Teléfono y Salas en el móvil |
|
||||||
|
| [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 |
|
||||||
|
|
||||||

|
---
|
||||||
|
|
||||||
## ECOin
|
## El proyecto original
|
||||||
|
|
||||||
Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace.
|
Esta rama existe gracias a y por encima de el trabajo de epsylon. El código, la
|
||||||
|
arquitectura, los módulos y la red son suyos.
|
||||||
|
|
||||||

|
- Código: https://github.com/epsylon/oasis
|
||||||
|
- Proyecto: https://solarnethub.com
|
||||||
|
- Wiki: https://wiki.solarnethub.com/socialnet/overview
|
||||||
|
|
||||||
You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
|
## Licencia
|
||||||
|
|
||||||
+ https://ecoin.03c8.net
|
AGPL-3.0, la misma que el proyecto original.
|
||||||
|
|
||||||
## Banking
|
|
||||||
|
|
||||||
Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
----------
|
|
||||||
|
|
||||||
## Carbon Footprinting
|
|
||||||
|
|
||||||
Oasis contains its own carbon footprint meter.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
All transmissions, stored files, and interactions are measured to understand the environmental impact of the network.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
And also of each inhabitant.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
----------
|
|
||||||
|
|
||||||
## L.A.R.P.
|
|
||||||
|
|
||||||
Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics.
|
|
||||||
|
|
||||||
+ https://wiki.solarnethub.com/socialnet/roleplaying#how_to_play
|
|
||||||
|
|
||||||
Check "The Houses" to review which one fit better with your ambitions:
|
|
||||||
|
|
||||||
+ https://wiki.solarnethub.com/socialnet/roleplaying#the_houses
|
|
||||||
|
|
||||||
----------
|
|
||||||
|
|
||||||
## Fediverse
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Connect your account from **Settings → Fediverse**, then open it from the **Fediverse** menu. Each network lives in its own space.
|
|
||||||
|
|
||||||
+ Mastodon connect guide: [docs/FEDIVERSE/MASTODON/connect.md](docs/FEDIVERSE/MASTODON/connect.md)
|
|
||||||
|
|
||||||
----------
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
+ https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants
|
|
||||||
|
|
||||||
----------
|
|
||||||
|
|
||||||
## Architecture:
|
|
||||||
|
|
||||||
Oasis uses a gossip protocol or epidemic protocol which is a procedure or process of computer peer-to peer communication
|
|
||||||
that is based on the way epidemics spread.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
This means that information is able to distribute across multiple machines, without requiring direct connections between them.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Even though Alice and Dan lack a direct connection, they can still exchange feeds:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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))).
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Just visit: https://pub.solarnethub.com/
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
And also you can visit periodically the public statistic of the SNH-PUB:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
3
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# lo que monta y baja scripts/sobre_oficial.sh
|
||||||
|
build/
|
||||||
|
.tools/
|
||||||
21
android/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# APK
|
||||||
|
|
||||||
|
La APK se monta sobre la oficial del dev con `scripts/sobre_oficial.sh`: se queda con
|
||||||
|
su app Android tal cual (ventana, servicio, notificaciones y el puente nativo de audio y
|
||||||
|
cámara, `.audio_bridge`), le cambia el backend por el de la rama y le pone paquete
|
||||||
|
propio, `net.laenre.oasis`, para convivir con la oficial.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/sobre_oficial.sh # baja la oficial de la versión y la monta
|
||||||
|
OASIS_VERSION=x.y.z ./scripts/sobre_oficial.sh # otra versión del dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Hace falta Java 17 o más y las build-tools de Android (zipalign y apksigner); apktool
|
||||||
|
se baja solo. Sin clave se firma con la de depuración. Para repartirla, con la clave
|
||||||
|
del hacklab por entorno (nunca al repositorio): `OASIS_KEYSTORE`, `OASIS_KEYSTORE_PASS`
|
||||||
|
y `OASIS_KEY_ALIAS`.
|
||||||
|
|
||||||
|
`icono/` es el icono adaptativo; `sobre_oficial.sh` lo copia encima del de la oficial.
|
||||||
|
|
||||||
|
Hasta el 19/10/2026 hubo aquí un proyecto Gradle propio. Se quitó: no tenía el puente
|
||||||
|
de audio del dev y en él no había llamadas. Está en la historia de git.
|
||||||
5
android/icono/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- fondo del icono adaptativo: el marron casi negro del logo de Oasis -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="#1A1300"/>
|
||||||
|
</shape>
|
||||||
7
android/icono/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- el logo de Oasis (la isla), el mismo que la app oficial; el fondo llena la forma
|
||||||
|
que ponga el lanzador (circulo, gota...) en vez de dejar un cuadrado dentro -->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
android/icono/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
android/icono/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/icono/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
android/icono/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
android/icono/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
143
android/scripts/sobre_oficial.sh
Executable file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Construye la APK de la rama encima de la APK oficial del dev.
|
||||||
|
#
|
||||||
|
# Desde la 1.3.x la app Android de epsylon trae cosas que la nuestra no tiene: el
|
||||||
|
# puente nativo de audio y camara (.audio_bridge) que usan el Telefono y las Salas,
|
||||||
|
# las notificaciones de llamada y sus permisos. Su codigo no esta publicado, asi que
|
||||||
|
# en vez de imitarlo se parte de su APK:
|
||||||
|
#
|
||||||
|
# 1. se baja la APK oficial de la misma version (github, epsylon/oasis)
|
||||||
|
# 2. se desmonta con apktool
|
||||||
|
# 3. su backend (assets/nodejs-project.zip) se cambia por el de la rama, conservando
|
||||||
|
# su node_modules, que es el podado para el movil
|
||||||
|
# 4. se le cambia el paquete a net.laenre.oasis (y lo que depende de el) para que
|
||||||
|
# conviva con la oficial
|
||||||
|
# 5. se vuelve a montar, se alinea y se firma
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# ./scripts/sobre_oficial.sh # firma con la clave de depuracion
|
||||||
|
# OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
|
||||||
|
#
|
||||||
|
# Variables:
|
||||||
|
# OASIS_VERSION version del dev (por defecto la de package.json)
|
||||||
|
# OASIS_APK_OFICIAL ruta a la APK oficial, si ya se tiene (si no, se baja)
|
||||||
|
# ANDROID_HOME por defecto ~/Android/Sdk
|
||||||
|
# JAVA_HOME un JDK 17 o superior
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
REPO="$(cd "$HERE/.." && pwd)"
|
||||||
|
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||||
|
JAVA="${JAVA_HOME:+$JAVA_HOME/bin/}java"
|
||||||
|
TOOLS="$HERE/.tools"
|
||||||
|
OUT="$HERE/build/sobre-oficial"
|
||||||
|
PKG_DEV="com.solarnethub.oasis"
|
||||||
|
PKG="net.laenre.oasis"
|
||||||
|
APKTOOL_VER="3.0.3"
|
||||||
|
APKTOOL_SHA="dbf930b076c6b9be08d57c449cacefc3bdd6b71ebd59b3066fc0e1f5b14f9423"
|
||||||
|
|
||||||
|
say() { printf '\n== %s\n' "$*"; }
|
||||||
|
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
VERSION="${OASIS_VERSION:-$(node -p "require('$REPO/package.json').version")}"
|
||||||
|
BT="$(ls -d "$SDK"/build-tools/*/ | sort -V | tail -1)"
|
||||||
|
[ -x "$BT/zipalign" ] || die "no encuentro zipalign en $BT"
|
||||||
|
mkdir -p "$TOOLS" "$OUT"
|
||||||
|
|
||||||
|
# --- apktool, version fija y comprobada ---------------------------------------
|
||||||
|
APKTOOL="$TOOLS/apktool_$APKTOOL_VER.jar"
|
||||||
|
if [ ! -f "$APKTOOL" ]; then
|
||||||
|
say "bajando apktool $APKTOOL_VER"
|
||||||
|
curl -fsSL -o "$APKTOOL" "https://github.com/iBotPeaches/Apktool/releases/download/v$APKTOOL_VER/apktool_$APKTOOL_VER.jar"
|
||||||
|
fi
|
||||||
|
echo "$APKTOOL_SHA $APKTOOL" | sha256sum -c --quiet - || die "apktool no coincide con la huella esperada"
|
||||||
|
|
||||||
|
# --- la APK oficial -------------------------------------------------------------
|
||||||
|
OFICIAL="${OASIS_APK_OFICIAL:-$TOOLS/oasis-v$VERSION.apk}"
|
||||||
|
if [ ! -f "$OFICIAL" ]; then
|
||||||
|
say "bajando la APK oficial $VERSION"
|
||||||
|
curl -fsSL -o "$OFICIAL" "https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk"
|
||||||
|
fi
|
||||||
|
say "APK oficial: $OFICIAL"
|
||||||
|
"$BT/apksigner" verify --print-certs "$OFICIAL" | grep -m2 -E "certificate DN|SHA-256" || die "la APK oficial no verifica"
|
||||||
|
|
||||||
|
# --- desmontar ------------------------------------------------------------------
|
||||||
|
say "desmontando"
|
||||||
|
rm -rf "$OUT/dec"
|
||||||
|
"$JAVA" -jar "$APKTOOL" d -q -f -o "$OUT/dec" "$OFICIAL"
|
||||||
|
DEC="$OUT/dec"
|
||||||
|
|
||||||
|
# --- backend de la rama, con el node_modules del dev ---------------------------
|
||||||
|
say "cambiando el backend por el de la rama"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
( cd "$work" && unzip -q "$DEC/assets/nodejs-project.zip" )
|
||||||
|
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
|
||||||
|
rm -rf "$work/nodejs-project/src"
|
||||||
|
cp -r "$REPO/src" "$work/nodejs-project/src"
|
||||||
|
rm -rf "$work/nodejs-project/src/server/node_modules"
|
||||||
|
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
|
||||||
|
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/"
|
||||||
|
rm -f "$work/nodejs-project/.phone_state.json"
|
||||||
|
rm -f "$DEC/assets/nodejs-project.zip"
|
||||||
|
( cd "$work" && zip -q -r "$DEC/assets/nodejs-project.zip" nodejs-project )
|
||||||
|
rm -rf "$work"
|
||||||
|
|
||||||
|
# --- paquete propio, para convivir con la oficial ------------------------------
|
||||||
|
# Las clases se quedan como estan (com.solarnethub.oasis.OasisActivity...); cambia
|
||||||
|
# el paquete, los authorities, el permiso propio y los nombres de las acciones de
|
||||||
|
# llamada, que si no se cruzarian con los de la app oficial.
|
||||||
|
say "paquete $PKG"
|
||||||
|
sed -i \
|
||||||
|
-e "s|package=\"$PKG_DEV\"|package=\"$PKG\"|" \
|
||||||
|
-e "s|$PKG_DEV.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|$PKG.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|g" \
|
||||||
|
-e "s|android:authorities=\"$PKG_DEV.|android:authorities=\"$PKG.|g" \
|
||||||
|
"$DEC/AndroidManifest.xml"
|
||||||
|
grep -rl "\"$PKG_DEV\.[A-Z_]*\"" "$DEC"/smali* | xargs -r sed -i -E "s|\"$PKG_DEV\.([A-Z_]+)\"|\"$PKG.\1\"|g"
|
||||||
|
|
||||||
|
# icono: el logo de la oficial, pero adaptativo. La oficial solo trae el PNG cuadrado
|
||||||
|
# y en Android 8+ el lanzador lo encoge dentro de un circulo blanco; con la capa de
|
||||||
|
# fondo y la de delante (android/icono) la isla llena la forma del lanzador
|
||||||
|
say "icono adaptativo"
|
||||||
|
RES="$HERE/icono"
|
||||||
|
mkdir -p "$DEC/res/mipmap-anydpi-v26" "$DEC/res/drawable"
|
||||||
|
cp "$RES/mipmap-anydpi-v26/ic_launcher.xml" "$DEC/res/mipmap-anydpi-v26/"
|
||||||
|
cp "$RES/drawable/ic_launcher_background.xml" "$DEC/res/drawable/"
|
||||||
|
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
|
||||||
|
cp "$RES/mipmap-$d/ic_launcher_foreground.png" "$DEC/res/mipmap-$d/"
|
||||||
|
done
|
||||||
|
|
||||||
|
# las de prueba (firmadas con la clave de depuracion) se marcan depurables: asi se
|
||||||
|
# puede inspeccionar la WebView desde chrome://inspect. Las de publicar, nunca
|
||||||
|
if [ -z "${OASIS_KEYSTORE:-}" ]; then
|
||||||
|
sed -i 's|<application |<application android:debuggable="true" |' "$DEC/AndroidManifest.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# version: la del dev por mil mas los commits de la rama, para que suba siempre
|
||||||
|
CODE_DEV="$(sed -n 's/^ versionCode: //p' "$DEC/apktool.yml")"
|
||||||
|
CODE="$(( CODE_DEV * 1000 + $(git -C "$REPO" rev-list --count HEAD) % 1000 ))"
|
||||||
|
# la app oficial solo vuelve a extraer nodejs-project.zip si cambia versionName.versionCode;
|
||||||
|
# con el mismo commit (o cambios sin commitear) se quedaba con el backend anterior. La
|
||||||
|
# huella del codigo en el nombre hace que cada APK distinta se extraiga de nuevo
|
||||||
|
HUELLA="$(cd "$REPO" && find src main.js package.json -type f -not -path '*/node_modules/*' -print0 | sort -z | xargs -0 sha1sum | sha1sum | cut -c1-8)"
|
||||||
|
NAME="$VERSION-hacklab.$HUELLA"
|
||||||
|
sed -i -e "s/^ versionCode: .*/ versionCode: $CODE/" -e "s/^ versionName: .*/ versionName: $NAME/" "$DEC/apktool.yml"
|
||||||
|
say "version $NAME (codigo $CODE)"
|
||||||
|
|
||||||
|
# --- montar, alinear y firmar --------------------------------------------------
|
||||||
|
say "montando"
|
||||||
|
"$JAVA" -jar "$APKTOOL" b -q -o "$OUT/sin-firmar.apk" "$DEC"
|
||||||
|
"$BT/zipalign" -f -p 4 "$OUT/sin-firmar.apk" "$OUT/alineada.apk"
|
||||||
|
FINAL="$OUT/oasis-$VERSION-hacklab.apk"
|
||||||
|
if [ -n "${OASIS_KEYSTORE:-}" ]; then
|
||||||
|
"$BT/apksigner" sign --ks "$OASIS_KEYSTORE" --ks-pass "env:OASIS_KEYSTORE_PASS" \
|
||||||
|
--ks-key-alias "${OASIS_KEY_ALIAS:-oasis}" --out "$FINAL" "$OUT/alineada.apk"
|
||||||
|
else
|
||||||
|
"$BT/apksigner" sign --ks "$HOME/.android/debug.keystore" --ks-pass pass:android \
|
||||||
|
--ks-key-alias androiddebugkey --out "$FINAL" "$OUT/alineada.apk"
|
||||||
|
fi
|
||||||
|
rm -f "$OUT/sin-firmar.apk" "$OUT/alineada.apk" "$FINAL.idsig"
|
||||||
|
"$BT/apksigner" verify "$FINAL" || die "la APK firmada no verifica"
|
||||||
|
|
||||||
|
say "APK: $FINAL ($(du -h "$FINAL" | cut -f1))"
|
||||||
|
echo "sha256: $(sha256sum "$FINAL" | cut -d' ' -f1)"
|
||||||
159
docs/ARQUITECTURA_APK.md
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# Cómo está hecha la APK de Oasis
|
||||||
|
|
||||||
|
Análisis de la APK oficial de epsylon (`oasis-v1.3.3.apk`, sha256 `4e8d60d4…5550d`)
|
||||||
|
con REA 6.7.0 y JADX headless 0.7.1, el 20/10/2026. Lo que se cita sale del código
|
||||||
|
decompilado de `com.solarnethub.oasis.OasisActivity`; las salidas completas están
|
||||||
|
en `OASIS_APK/ANALISIS_REA/` (fuera del repo).
|
||||||
|
|
||||||
|
|
||||||
|
## Las piezas
|
||||||
|
|
||||||
|
La app es una envoltura Android pequeña (cuatro clases propias) alrededor de un
|
||||||
|
servidor Node.js que corre dentro del móvil, con nodejs-mobile.
|
||||||
|
|
||||||
|
```
|
||||||
|
APK
|
||||||
|
├── classes.dex OasisActivity pantalla: WebView, extracción, arranque de node
|
||||||
|
│ OasisService servicio en primer plano (sigue vivo con la app cerrada)
|
||||||
|
│ OasisAudioBridge, OasisStreamDecoder micro, altavoz y cámara del Teléfono
|
||||||
|
├── lib/*/libnode.so node para el móvil
|
||||||
|
└── assets/nodejs-project.zip
|
||||||
|
└── nodejs-project/ main.js, package.json, src/ (backend, vistas, CSS, node_modules)
|
||||||
|
```
|
||||||
|
|
||||||
|
La interfaz no es nativa: es HTML que sirve node en `http://127.0.0.1:3000` y que la
|
||||||
|
WebView enseña. Todo lo que vemos (vistas, CSS, textos) va dentro del zip.
|
||||||
|
|
||||||
|
|
||||||
|
## El arranque
|
||||||
|
|
||||||
|
`onCreate`:
|
||||||
|
|
||||||
|
1. `isExtractionNeeded()`: mira si hay que descomprimir.
|
||||||
|
2. `configureWebView()` y la pantalla de carga.
|
||||||
|
3. En otro hilo: `copyNodeProjectIfNeeded()` y después `startNode()`, que lanza
|
||||||
|
`node nodejs-project/main.js` y el servicio en primer plano.
|
||||||
|
4. Cuando node responde en `127.0.0.1:3000`, la WebView carga la página.
|
||||||
|
|
||||||
|
|
||||||
|
## Cuándo se descomprime el zip
|
||||||
|
|
||||||
|
```java
|
||||||
|
// copyNodeProjectIfNeeded()
|
||||||
|
File file2 = new File(file, ".version");
|
||||||
|
String appVersion = getAppVersion(); // versionName + "." + versionCode
|
||||||
|
String strTrim = file2.exists() ? readFile(file2).trim() : null;
|
||||||
|
if (file.exists() && appVersion.equals(strTrim)) {
|
||||||
|
Log.i("OASIS", "Node project already up to date (v" + appVersion + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
...
|
||||||
|
stashLegacyState(file3, file4); // copia src/configs a .configs-stash
|
||||||
|
deleteRecursive(file);
|
||||||
|
extractZipAsset(ASSET_ZIP, ...);
|
||||||
|
restoreLegacyState(file4, file3); // vuelve a poner los configs de antes
|
||||||
|
writeFile(file2, appVersion);
|
||||||
|
```
|
||||||
|
|
||||||
|
Solo se descomprime si cambia `versionName.versionCode`. Una APK nueva con el mismo
|
||||||
|
nombre y número de versión arranca con el código de la vez anterior, aunque dentro
|
||||||
|
lleve otro. Eso es lo que pasaba con nuestras APK de prueba: el `versionCode` solo
|
||||||
|
subía con cada commit, y al instalar una APK hecha sobre el mismo commit (o con
|
||||||
|
cambios sin commitear) se veía el CSS y las vistas de antes. Desde el 20/10/2026
|
||||||
|
`sobre_oficial.sh` pone una huella del código en el `versionName`.
|
||||||
|
|
||||||
|
Al descomprimir se conservan los ficheros de `src/configs` (la config de cada cual:
|
||||||
|
tema, idioma, workflow, colores). Consecuencia: si cambiamos la config de serie en
|
||||||
|
`configs/oasis-config.json`, solo la ve quien instala de cero; quien actualiza se
|
||||||
|
queda con la suya.
|
||||||
|
|
||||||
|
|
||||||
|
## La WebView no guarda caché
|
||||||
|
|
||||||
|
```java
|
||||||
|
// configureWebView()
|
||||||
|
settings.setCacheMode(2); // LOAD_NO_CACHE
|
||||||
|
this.webView.clearCache(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
Cada página y cada hoja de estilo se piden de nuevo al node del móvil. Si después de
|
||||||
|
instalar se ve CSS viejo, no es la caché: es que node está sirviendo el código viejo
|
||||||
|
(ver el punto anterior).
|
||||||
|
|
||||||
|
La WebView solo navega dentro de `127.0.0.1:3000` y `localhost:3000`. Los enlaces
|
||||||
|
a una `.apk` los descarga ella misma y el resto de enlaces http(s) los abre en otra
|
||||||
|
app (el navegador). Hay rutas que intercepta para el Teléfono (`/phone/speaker`,
|
||||||
|
`/phone/accept-speaker`).
|
||||||
|
|
||||||
|
|
||||||
|
## El backend por dentro
|
||||||
|
|
||||||
|
`rea analyze-javascript-application` sobre el `nodejs-project` oficial, sin
|
||||||
|
`node_modules` (463 ficheros JavaScript). Del grafo de imports, contando solo
|
||||||
|
ficheros del dev:
|
||||||
|
|
||||||
|
- `backend/backend.js` lo cose todo: hace 896 de los imports. Rutas, middleware y
|
||||||
|
la llamada a cada modelo y cada vista pasan por él.
|
||||||
|
- Las capas van `backend → views` (568 imports), `backend ↔ models` (218 y 223) y
|
||||||
|
`models → configs` (180). Las vistas casi no importan nada del backend (3).
|
||||||
|
- Lo más importado, y por tanto lo que más rompe si cambia: `views/main_views.js`
|
||||||
|
(217), `models/typed_log.js` (194), `configs/config-manager.js` (152),
|
||||||
|
`views/clearnet_view.js` (116), `backend/dedupe.js` (112).
|
||||||
|
|
||||||
|
Cuadra con la lista de lo que no se toca de la guía: un cambio nuestro en
|
||||||
|
`backend.js` o en `main_views.js` tropieza con casi todo lo demás en cada versión.
|
||||||
|
Por eso lo nuestro va en `fork_routes.js`, `mobile_ui.js` y las hojas propias, que
|
||||||
|
el dev no importa.
|
||||||
|
|
||||||
|
|
||||||
|
## Barras del sistema y botón atrás
|
||||||
|
|
||||||
|
`onCreate` pone un `OnApplyWindowInsetsListener` que aparta el contenedor de la
|
||||||
|
WebView de la barra de estado, del recorte de la cámara y de la barra de gestos
|
||||||
|
(`setPadding` con esos insets), salvo en pantalla completa. Pero devuelve los insets
|
||||||
|
sin consumir, y la WebView los sigue anunciando como `safe-area-inset-*`: si el CSS
|
||||||
|
los suma, el hueco sale doble (65 px de más arriba y 24 abajo en un Pixel 9 con
|
||||||
|
Android 16). Por eso nuestras hojas usan `--inset-top` y `--inset-bottom` a 0.
|
||||||
|
|
||||||
|
El botón atrás pasa por `goBackInWebView()`, que hace `webView.goBack()` o, si no
|
||||||
|
puede, carga la página anterior de su propia lista (`visitedUrls`). Con el gesto de
|
||||||
|
atrás se llama una vez. Con la tecla (móviles con tres botones) se llama dos veces,
|
||||||
|
por `onKeyDown` y por `registerPredictiveBack`, y se retrocede dos pasos: con el menú
|
||||||
|
abierto, en vez de cerrarlo se va a la pantalla anterior. Es de su código Android;
|
||||||
|
hay que pedírselo al dev.
|
||||||
|
|
||||||
|
|
||||||
|
## Lo que hace nuestra APK encima
|
||||||
|
|
||||||
|
`android/scripts/sobre_oficial.sh` coge esta APK tal cual y cambia:
|
||||||
|
|
||||||
|
- `assets/nodejs-project.zip`: nuestro `src/`, `main.js` y `package.json`, con el
|
||||||
|
`node_modules` del dev (el podado para el móvil).
|
||||||
|
- El paquete, a `net.laenre.oasis`, para que conviva con la oficial.
|
||||||
|
- El icono, por el adaptativo de `android/icono/`.
|
||||||
|
- `versionName`, a `<versión>-hacklab.<huella del código>`.
|
||||||
|
|
||||||
|
La parte Android (Java, puente de audio, servicio) es la del dev sin tocar.
|
||||||
|
|
||||||
|
|
||||||
|
## Repetir el análisis
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install --global --prefix ~/.local rea-agents@6.7.0
|
||||||
|
# JADX headless 0.7.1, comprobando la huella que fija REA
|
||||||
|
curl -fL -o jadx-headless-mcp-0.7.1-all.jar \
|
||||||
|
https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar
|
||||||
|
echo '6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75 jadx-headless-mcp-0.7.1-all.jar' | sha256sum -c -
|
||||||
|
export REA_JADX_MCP_JAR=$PWD/jadx-headless-mcp-0.7.1-all.jar JAVA_HOME=/opt/android-studio/jbr
|
||||||
|
|
||||||
|
rea inspect-android-package oasis-v1.3.3.apk
|
||||||
|
rea inspect-android-class oasis-v1.3.3.apk com.solarnethub.oasis.OasisActivity
|
||||||
|
rea inspect-android-method oasis-v1.3.3.apk com.solarnethub.oasis.OasisActivity copyNodeProjectIfNeeded
|
||||||
|
# el backend: sacar nodejs-project del zip y quitarle src/server/node_modules
|
||||||
|
# (con ellas, 16.500 ficheros, se corta por tiempo)
|
||||||
|
rea --json analyze-javascript-application /ruta/absoluta/nodejs-project-sin-node_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
Con cada versión nueva del dev conviene mirar otra vez `copyNodeProjectIfNeeded`,
|
||||||
|
`configureWebView` y el manifiesto: si cambia la forma de extraer o la caché,
|
||||||
|
`sobre_oficial.sh` puede tener que cambiar también.
|
||||||
108
docs/ESTUDIO_NAVEGACION.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Cómo moverse por Oasis en el móvil
|
||||||
|
|
||||||
|
Estudio para decidir la navegación de la rama. 19/10/2026.
|
||||||
|
|
||||||
|
## La pregunta
|
||||||
|
|
||||||
|
Oasis tiene casi sesenta módulos. En un escritorio caben en un menú lateral; en un
|
||||||
|
móvil no. ¿Qué tiene que ver alguien al abrir la app, y cómo llega al resto?
|
||||||
|
|
||||||
|
## Lo que hay ahora
|
||||||
|
|
||||||
|
Medido en el emulador (Pixel 9, 412 px de ancho) con el workflow acampada (16 módulos):
|
||||||
|
|
||||||
|
- Al abrir, la portada es el lanzador: una rejilla de iconos por secciones que ocupa
|
||||||
|
unos 1.300 px, casi pantalla y media. La actividad, que es lo que ha pasado en la
|
||||||
|
red, empieza debajo y no se ve sin bajar.
|
||||||
|
- Abajo hay una barra de cuatro atajos que elige cada cual (Actividad, Chats,
|
||||||
|
Publicar, Buzón de serie).
|
||||||
|
- Para ir a un módulo que no está en la barra hay que volver a la portada y buscarlo
|
||||||
|
en la rejilla. Desde dentro de un módulo no hay acceso directo al resto.
|
||||||
|
|
||||||
|
O sea: la portada es un menú, no un sitio donde pase algo, y el menú solo está en la
|
||||||
|
portada.
|
||||||
|
|
||||||
|
## Cómo lo hacen otras
|
||||||
|
|
||||||
|
- Instagram, TikTok, X: el contenido es la portada. Abajo, cinco destinos fijos
|
||||||
|
(inicio, buscar, crear, vídeos, perfil). Lo demás vive en el perfil o en ajustes.
|
||||||
|
- WhatsApp, Signal, Telegram: pestañas con las tres o cuatro cosas que se usan
|
||||||
|
siempre. Lo demás, en un menú de tres puntos.
|
||||||
|
- Google Maps, Gmail y la guía de Material Design: una barra abajo con entre tres y
|
||||||
|
cinco destinos; si hay más, el último es "Más" y abre una hoja con el resto.
|
||||||
|
- El menú hamburguesa arriba a la izquierda (muy usado en 2012-2016) se ha ido
|
||||||
|
abandonando: lo que no se ve se usa menos, y arriba queda lejos del pulgar.
|
||||||
|
|
||||||
|
## Qué pedimos a la navegación
|
||||||
|
|
||||||
|
1. Al abrir, ver qué pasa (actividad), no un menú.
|
||||||
|
2. Llegar a cualquier módulo desde cualquier pantalla, en dos toques como mucho.
|
||||||
|
3. Que el sitio para hacerlo se vea sin explicarlo.
|
||||||
|
4. Que se alcance con el pulgar con el móvil en una mano.
|
||||||
|
5. No tapar contenido ni quitar sitio de pantalla.
|
||||||
|
|
||||||
|
## Opciones
|
||||||
|
|
||||||
|
| | Portada | Llegar a un módulo | Pulgar | Se ve | Comentario |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| A. Como ahora | Lanzador | Volver a portada y buscar | Medio | Sí, en portada | La actividad queda enterrada |
|
||||||
|
| B. Botón "Menú" bajo la barra de arriba | Actividad | 2 toques | Lejos (arriba) | Sí | La idea inicial; ocupa una fila arriba |
|
||||||
|
| C. "Menú" como quinta pestaña de abajo | Actividad | 2 toques | Cerca | Sí | Patrón "Más" de Android y de las apps grandes |
|
||||||
|
| D. Hamburguesa arriba | Actividad | 2 toques | Lejos | Poco | Lo que se esconde se usa menos |
|
||||||
|
|
||||||
|
## Recomendación
|
||||||
|
|
||||||
|
C: la portada pasa a ser la actividad, y la barra de abajo tiene siempre una quinta
|
||||||
|
pestaña, "Menú", que abre desde abajo una hoja con todos los módulos agrupados (el
|
||||||
|
mismo lanzador de ahora). Se cierra tocando fuera, con la X o volviendo a tocar Menú.
|
||||||
|
|
||||||
|
Es la idea de B, un botón que despliega las opciones, pero puesto donde llega el
|
||||||
|
pulgar y donde está el resto de la navegación. Funciona igual en todas las pantallas.
|
||||||
|
Está hecho con un ancla y CSS (`:target`), sin JS: la CSP de la 1.3.5 no deja ninguno.
|
||||||
|
Abrir la hoja deja una entrada en el historial y atrás la cierra sin cambiar de
|
||||||
|
página. Al volver atrás desde un módulo abierto desde la hoja, la hoja sale abierta:
|
||||||
|
se vuelve a donde se estaba.
|
||||||
|
|
||||||
|
## Lo que salió en la prueba (emulador, 19/10/2026)
|
||||||
|
|
||||||
|
- Portada a Tribus: dos toques (Menú, Tribus). Desde Foros a Votaciones: dos toques.
|
||||||
|
- En la primera versión la hoja dejaba ver 690 px y los módulos medían 1.200: Tribus
|
||||||
|
quedaba fuera. Con cinco iconos por fila y los grupos pequeños de dos en dos, los 20
|
||||||
|
módulos caben a 412 y a 360 px.
|
||||||
|
- Se cierra tocando fuera, con la X y volviendo a tocar Menú.
|
||||||
|
- Con la hoja abierta, atrás cambiaba de página. Ahora la cierra.
|
||||||
|
- Al volver atrás desde un módulo abierto desde la hoja, la portada salía con la hoja
|
||||||
|
abierta (la entrada del historial de la hoja seguía ahí). Ahora se quita antes de
|
||||||
|
ir al módulo: atrás lleva a la portada, cerrada.
|
||||||
|
|
||||||
|
## Tamaños y sistemas (19/10/2026)
|
||||||
|
|
||||||
|
| Dónde | Motor | Resultado |
|
||||||
|
|---|---|---|
|
||||||
|
| Pixel 9, Android 16, 412 dp, gestos y tres botones | WebView actual | Bien. APK real |
|
||||||
|
| 360 × 540 dp | WebView actual | La hoja se desliza; todo se alcanza |
|
||||||
|
| Tableta 800 dp | WebView actual | Contenido y hoja centrados a 720 y 640 px |
|
||||||
|
| Letra al 130 % | WebView actual | Bien |
|
||||||
|
| Pixel 3a, Android 9, 412 dp | WebView 69 | Tras los arreglos, bien (ver abajo) |
|
||||||
|
| 360 dp, Android 11 | Chrome/WebView 83 | Bien: 9 pantallas sin desbordar, 5 pestañas de 70 dp |
|
||||||
|
|
||||||
|
En Android 9 y 11 de emulador la APK no arranca: el node del dev es arm64 y el
|
||||||
|
traductor ARM de las imágenes x86 se para con una instrucción que no conoce (SIGILL).
|
||||||
|
Pasa igual con la APK oficial, así que no es de la rama. Se probó el motor con el
|
||||||
|
servidor local.
|
||||||
|
|
||||||
|
Lo que fallaba en WebView 69 y está arreglado:
|
||||||
|
|
||||||
|
- Fichas y filtros pegados unos a otros (no hay `gap` en flex): márgenes.
|
||||||
|
- La hoja del menú no se veía (no hay `inset` ni `dvh`): top/left/right/bottom y `vh`.
|
||||||
|
- Los grupos pequeños del menú se montaban (no hay `:has`): de dos en dos solo donde
|
||||||
|
hay `:has`; en el viejo, cada grupo a lo ancho.
|
||||||
|
- El botón No molestar quedaba encima de la hoja: ahora va por debajo de ella.
|
||||||
|
|
||||||
|
## Qué mirar en la prueba
|
||||||
|
|
||||||
|
- Desde la portada, cuántos toques hacen falta para llegar a Tribus, a Votaciones y a
|
||||||
|
Ajustes, y desde dentro de un foro.
|
||||||
|
- Si alguien que no conoce la app encuentra el menú sin que se lo digan.
|
||||||
|
- Si al volver atrás la hoja se queda abierta (no debería).
|
||||||
|
- Que la barra de cinco quepa a 360 px de ancho.
|
||||||
163
docs/FRONTERA_UPSTREAM.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# Frontera con upstream: que se puede tocar y que no
|
||||||
|
|
||||||
|
Las normas del dia a dia estan en [GUIA_DE_DESARROLLO.md](../GUIA_DE_DESARROLLO.md); esto
|
||||||
|
es la medicion de la que salen.
|
||||||
|
|
||||||
|
Medido el 19/10/2026 sobre el repositorio del dev (code.03c8.net/krakenslab/oasis),
|
||||||
|
de la release 1.0.1 (29/08) a la 1.3.3 (18/10), y comparado con la rama PRUEBAS.
|
||||||
|
Los scripts estan en el historial de la sesion; para repetirlo basta clonar upstream y
|
||||||
|
contar con `git log --numstat` por fichero.
|
||||||
|
|
||||||
|
## 1. Como trabaja el dev
|
||||||
|
|
||||||
|
- Una release casi cada dia: 39 entre la 1.0.1 y la 1.3.3, en siete semanas.
|
||||||
|
Cada una es un solo commit ("Oasis release X.Y.Z"), sin historia intermedia: no
|
||||||
|
hay forma de coger un cambio suelto, siempre llega todo junto.
|
||||||
|
- El backend crece sin parar: `backend.js` paso de ~9.900 a ~15.400 lineas y se
|
||||||
|
toca en 34 de 39 releases. La cadena de middleware (el array `middleware` del final)
|
||||||
|
ha ganado capas en este tiempo: contexto por peticion (`requestScope`), idioma de
|
||||||
|
primera ejecucion, CSP, etc.
|
||||||
|
- Ya piensa en el movil: tiene su propio tema `OasisMobile.css`, usa la misma
|
||||||
|
bandera `process.env.OASIS_MOBILE` que la rama y tiene workflows `activists` y
|
||||||
|
`mobile`. Va en nuestra direccion; cuanto mas nos pisemos con el, peor.
|
||||||
|
- Las clases CSS cambian, pero poco a poco: de la 1.0.1 a la 1.2.0 aparecen 335
|
||||||
|
clases y desaparecen 98; hasta la 1.3.1, +304 / -94; de la 1.3.1 a la 1.3.3, +47 / -8.
|
||||||
|
Unas 100 clases muertas cada tres semanas, sobre ~2.200: un 5 %.
|
||||||
|
|
||||||
|
Ficheros que mas toca (releases que lo tocan de 39 / lineas movidas):
|
||||||
|
|
||||||
|
| Fichero | Releases | Lineas |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/backend/backend.js` | 34 | 11.621 |
|
||||||
|
| `src/server/package.json` + lock | 34 | 22.700 |
|
||||||
|
| `src/client/assets/styles/style.css` | 32 | 2.522 |
|
||||||
|
| `translations/oasis_*.js` (los 11) | 31 cada uno | ~2.000 cada uno |
|
||||||
|
| `src/views/main_views.js` | 31 | 2.634 |
|
||||||
|
| `src/views/activity_view.js` | 27 | 1.183 |
|
||||||
|
| `src/views/tribes_view.js` | 23 | 2.675 |
|
||||||
|
| `src/client/assets/themes/OasisMobile.css` | 21 | 3.040 |
|
||||||
|
| `src/views/settings_view.js` | 17 | 849 |
|
||||||
|
| `src/views/vote_view.js` | 14 | 155 |
|
||||||
|
| `src/configs/config-manager.js` | 11 | 120 |
|
||||||
|
| `src/client/assets/styles/mobile.css` | 10 | 514 |
|
||||||
|
| `src/views/parliament_view.js` | 10 | 282 |
|
||||||
|
| `src/models/workflows_model.js` | 8 | 133 |
|
||||||
|
| `src/views/welcome_view.js` | 8 | 440 |
|
||||||
|
| `src/server/SSB_server.js` | 7 | 183 |
|
||||||
|
| `src/models/votes_model.js` | 4 | 33 |
|
||||||
|
| `src/backend/request_guards.js` (CSP) | 4 | 111 |
|
||||||
|
|
||||||
|
## 2. El mapa
|
||||||
|
|
||||||
|
### Rojo: no se toca
|
||||||
|
|
||||||
|
Cada linea nuestra aqui es un conflicto casi seguro en la siguiente release, y ademas
|
||||||
|
cambia el comportamiento de la red, no solo como se ve.
|
||||||
|
|
||||||
|
- `src/backend/backend.js`: rutas y, sobre todo, la cadena de `middleware`.
|
||||||
|
- `src/models/*` de upstream: lo que se publica en el log SSB y como se cuenta. Si dos
|
||||||
|
nodos cuentan distinto (votos, tribus, gobierno), la red se rompe. Ejemplo: "quien
|
||||||
|
crea una votacion no vota" es una regla de `votes_model.js`; cambiarla en la rama
|
||||||
|
haria que nuestro movil y un escritorio dieran resultados distintos.
|
||||||
|
- `src/server/*` (arranque de SSB, `ssb_config`, `package.json` y dependencias).
|
||||||
|
- `src/backend/request_guards.js` (CSP: no hay estilos ni scripts en linea, y asi
|
||||||
|
debe seguir).
|
||||||
|
- `translations/oasis_*.js`: se reescriben en 31 de 39 releases.
|
||||||
|
- `src/client/assets/styles/style.css` y `mobile.css`: son del dev.
|
||||||
|
- `src/client/assets/themes/*.css` de upstream, incluido `OasisMobile.css`.
|
||||||
|
|
||||||
|
### Ambar: solo un gancho de una linea, con la bandera de movil
|
||||||
|
|
||||||
|
Las vistas de upstream (`main_views.js`, `tribes_view.js`, `vote_view.js`,
|
||||||
|
`settings_view.js`...). Se reescriben a menudo, asi que lo que la rama necesite ahi va
|
||||||
|
como una llamada a una funcion nuestra, envuelta en
|
||||||
|
`process.env.OASIS_MOBILE === '1'`, y la logica vive en un fichero propio. Si el dev
|
||||||
|
reescribe la vista, se vuelve a poner una linea, no se reconstruye una funcion.
|
||||||
|
|
||||||
|
### Verde: nuestro, sin limite
|
||||||
|
|
||||||
|
- Hojas propias: `themes/Acampada.css`, `styles/mobile_base.css`,
|
||||||
|
`styles/hive_acampada.css`, `styles/mobile_ux.css`.
|
||||||
|
- Vistas propias: `hive_views.js`, `mobile_icons.js`, `identities_view.js` y las
|
||||||
|
`mobile_*.js`.
|
||||||
|
- El router propio `src/backend/fork_routes.js`: va justo antes del de upstream en la
|
||||||
|
cadena y ve todas las peticiones. Puede atender rutas nuevas, o envolver una ruta de
|
||||||
|
upstream (dejarla hacer y retocar la respuesta) sin editarla.
|
||||||
|
- Traducciones propias en `i18n_fork.js` (se mezclan encima de las de upstream).
|
||||||
|
- Modelo propio `fork_workflows.js`.
|
||||||
|
- Todo `android/`.
|
||||||
|
|
||||||
|
## 3. Como esta la rama hoy
|
||||||
|
|
||||||
|
43 ficheros de upstream modificados y 22 propios. Lo que mas pesa:
|
||||||
|
|
||||||
|
| Fichero de upstream | Lineas distintas | Riesgo |
|
||||||
|
|---|---|---|
|
||||||
|
| `themes/OasisMobile.css` | 4.873 | Alto: es casi otro fichero. El dev lo toca en 21/39 releases y cada vez hay que elegir entre su version y la nuestra. |
|
||||||
|
| `views/main_views.js` | 173 | Alto (31/39) |
|
||||||
|
| `backend/backend.js` | 133 (21 trozos) | Alto (34/39) |
|
||||||
|
| `configs/oasis-config.json` | 135 | Medio: es la config de serie del movil |
|
||||||
|
| `backend/blobHandler.js` | 190 | Medio (limpieza de fotos) |
|
||||||
|
| `backend/updater.js` | 133 | Bajo (3/39) |
|
||||||
|
| `views/modules_view.js` | 91 | Bajo (5/39) |
|
||||||
|
| `views/parliament_view.js`, `settings_view.js`, `vote_view.js`, `tribes_view.js` | 50-75 cada una | Medio-alto |
|
||||||
|
| `models/workflows_model.js` | 31 | Medio: redefine el workflow `activists` del dev |
|
||||||
|
| `server/ssb_config.js`, `SSB_server.js` | 40 | Medio (multicuenta) |
|
||||||
|
|
||||||
|
Las hojas propias y `OasisMobile.css` llevan selectores a clases que ya no pinta nadie:
|
||||||
|
61 en `OasisMobile.css`, 29 en `hive_acampada.css`, 5 en `Acampada.css`. No rompen
|
||||||
|
nada, pero son peso muerto y confunden.
|
||||||
|
|
||||||
|
## 4. Reglas para cada cambio
|
||||||
|
|
||||||
|
1. Primero CSS. Si se arregla con una hoja propia, se arregla ahi.
|
||||||
|
2. Si hace falta HTML distinto: funcion en un fichero propio + una linea en la vista
|
||||||
|
de upstream, con la bandera de movil.
|
||||||
|
3. Si hace falta una ruta o retocar una respuesta: en el router propio. Nunca una
|
||||||
|
ruta nueva ni un middleware en `backend.js`.
|
||||||
|
4. Nunca cambiar lo que se publica ni como se cuenta (modelos de upstream).
|
||||||
|
5. Textos nuevos en `i18n_fork.js`, en los 11 idiomas.
|
||||||
|
6. Nada en linea: la CSP no deja `style=""` ni `<script>` en la pagina.
|
||||||
|
7. Antes de integrar una release, repetir esta medicion y mirar si alguna clase de la
|
||||||
|
que dependemos ha desaparecido.
|
||||||
|
|
||||||
|
## 5. Plan para sanear la rama
|
||||||
|
|
||||||
|
- Fase A (hecha el 19/10): lo del 19/10 que se metio en ficheros del dev sale a
|
||||||
|
ficheros propios. Ver la seccion 6.
|
||||||
|
- Fase B (hecha el 19/10): `OasisMobile.css` vuelve a ser la del dev, identica a la
|
||||||
|
1.3.3. Nuestra maqueta esta en `styles/mobile_base.css`, que el movil carga siempre
|
||||||
|
antes del skin. Con el skin OasisMobile se ve el tema del dev encima de la maqueta.
|
||||||
|
- Fase C (hecha el 19/10): de los 20 trozos de `backend.js` quedan 9 (21 lineas).
|
||||||
|
El resto (rutas cortadas en el movil, modulos que no van, avatar, lanzador,
|
||||||
|
retoques del HTML, descargas de la copia de seguridad, cambio de tema) esta en
|
||||||
|
`backend/mobile_layer.js` y en `fork_routes.js`. Los que quedan no se pueden
|
||||||
|
hacer desde fuera: expresiones regulares para Node 18, no abrir el navegador, fotos
|
||||||
|
duplicadas, modo chats y el montaje del router.
|
||||||
|
- Fase D (hecha el 19/10): `activists` vuelve a ser el del dev. Karvan y el workflow
|
||||||
|
`acampada` se registran desde `models/fork_workflows.js`. La config de serie del
|
||||||
|
movil coincide con `acampada`. Queda en `workflows_model.js` la logica de skins del
|
||||||
|
movil.
|
||||||
|
- Correccion (19/10, noche): para el movil la referencia buena es el nodejs-project de
|
||||||
|
la APK oficial, no el git del dev. Parte de lo que la fase C saco de backend.js era
|
||||||
|
codigo de movil del propio dev; backend.js vuelve a ser su version de movil mas dos
|
||||||
|
lineas (montar nuestro router), y modules_view.js su version de movil. Contra la APK
|
||||||
|
oficial la rama cambia 17 ficheros y 561 lineas; la lista esta en la guia.
|
||||||
|
- Fuera Karvan (19/10): el dev ya trae Telefono y Salas, asi que se quita el modulo
|
||||||
|
propio de salas y llamadas (modelo, vista, JS, CSS, router, pruebas, textos).
|
||||||
|
`Clear-SNH.css` y `modules_view.js` vuelven a ser identicos a los del dev y la linea
|
||||||
|
de `SSB_server.js` que enchufaba el audio de Android pasa a `main.js`, usando los
|
||||||
|
`setAudioFactory`, `setVideoFactory` y `setStreamFactory` que el propio dev exporta.
|
||||||
|
- Fase E (hecha el 19/10): fuera 153 selectores muertos de `mobile_base.css`,
|
||||||
|
`hive_acampada.css` y `Acampada.css` (287 lineas). Comparado pixel a pixel en 14
|
||||||
|
pantallas: identicas.
|
||||||
|
|
||||||
|
## 6. Fase A: lo que se ha movido
|
||||||
|
|
||||||
|
| Antes (en ficheros del dev) | Ahora |
|
||||||
|
|---|---|
|
||||||
|
| `backend.js`: `checkMod` envuelto para recordar la seccion apagada | Quitado el 20/10: no se miran las respuestas del dev |
|
||||||
|
| `backend.js`: ruta `POST /modules/enable` y `?saved=1` tras guardar | Quitado el 20/10: se guarda con la ruta del dev y vuelve a donde dice el |
|
||||||
|
| `modules_view.js`: vista de Modulos del movil | `views/mobile_modules_view.js`, servida por el router propio |
|
||||||
|
| `workflows_model.js`: cuatro modulos mas en `activists` | Workflow propio `acampada` registrado desde `models/fork_workflows.js` |
|
||||||
|
| `vote_view.js`, `tribes_view.js`, `settings_view.js`, `main_views.js`, `welcome_view.js`: funciones nuevas | Funciones en `views/mobile_ui.js`; en la vista de upstream queda la llamada |
|
||||||
139
docs/PENDIENTE.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Pendiente
|
||||||
|
|
||||||
|
Lo que queda por hacer en la rama del móvil. Apuntado el 20/10/2026, con la APK
|
||||||
|
`oasis-1.3.3-activista` (commit b84a885) recién hecha para probar en el móvil.
|
||||||
|
|
||||||
|
|
||||||
|
## Integrar la 1.3.4 (hecho: integrada la 1.3.5 el 22/10)
|
||||||
|
|
||||||
|
- El dev ha publicado la 1.3.4 y el aviso de versión nueva sale en todas las
|
||||||
|
pantallas. Integrarla con los pasos de la guía antes de tocar el CSS de abajo, por si
|
||||||
|
cambian clases.
|
||||||
|
- Al integrar, rehacer `docs/FRONTERA_UPSTREAM.md` (ver "Antes de publicar").
|
||||||
|
|
||||||
|
|
||||||
|
## Arreglar en la capa (pruebas del 20/10): hecho el 24/10
|
||||||
|
|
||||||
|
Todo lo de esta lista está en la capa o ya no pasa con la 1.3.5: teléfono legible en
|
||||||
|
perfil y habitantes (y el de otra persona en una fila, no apilado), letra de 12 px o más
|
||||||
|
en melody, parlamento y banca, filtros y botones de 44 px, Parlamento y Gobierno de la
|
||||||
|
tribu sin sangrados ni huecos, imagen del método de gobierno a 160 px, campos de 44 px,
|
||||||
|
ficha del pad (texto de cada autora en negro sobre su color, "Por autora" y versiones de
|
||||||
|
44 px), y la primera ficha de Blogs y Wiki ya no empieza al 70 % de la pantalla.
|
||||||
|
|
||||||
|
|
||||||
|
## Probar en el móvil: hecho en el emulador el 24/10
|
||||||
|
|
||||||
|
APK `1.3.5-hacklab` en `Oasis_Pruebas`. Lógica 40/40 (Ajustes, los seis accesos,
|
||||||
|
Colores y que se conserva al reabrir, menú y atrás), usabilidad con las 8 paletas,
|
||||||
|
perfiles (los 10), tribus 396/396 y contenido. Unirse a un pub de verdad (SNH La
|
||||||
|
Plaza) de principio a fin con la barra del pub: "Descargando" y después "Guardando en
|
||||||
|
tu móvil: N % · M mensajes", siempre encima de la barra de abajo.
|
||||||
|
|
||||||
|
Lo que se vio y es del dev (backend):
|
||||||
|
|
||||||
|
- Mientras baja la red del pub la app no contesta: más de un minuto sin cargar una
|
||||||
|
pantalla, aunque la barra dice "Puedes seguir usando la app mientras tanto".
|
||||||
|
- Mientras guarda, cada pantalla tarda de 3 a 10 segundos.
|
||||||
|
|
||||||
|
Queda: instalarla en un móvil de verdad y repasarla a mano.
|
||||||
|
|
||||||
|
|
||||||
|
## Pedirle al dev (epsylon)
|
||||||
|
|
||||||
|
- **Botón atrás con tres botones.** `OasisActivity` atiende la tecla dos veces
|
||||||
|
(`onKeyDown` y `registerPredictiveBack`) y retrocede dos pasos; con el menú abierto
|
||||||
|
se va a la pantalla anterior en vez de cerrarlo. Con el gesto va bien. Detalle en
|
||||||
|
`docs/ARQUITECTURA_APK.md`.
|
||||||
|
- **Aviso al fallar unirse a un pub.** Si `acceptInvite` falla por lo que sea (sin red,
|
||||||
|
pub caído), dice "código no válido o ya usado". Habría que distinguir los casos.
|
||||||
|
- **Colores en variables CSS** (`--bg`, `--fg`, `--accent`) en sus hojas. Con eso
|
||||||
|
sobrarían muchos `!important` nuestros.
|
||||||
|
- **Fichas de Actividad en mayúsculas**: las escribe así en el HTML
|
||||||
|
(`toUpperCase`); con CSS se podría elegir.
|
||||||
|
|
||||||
|
|
||||||
|
## Blindar la capa
|
||||||
|
|
||||||
|
- Comprobador automático de las clases del dev que usa nuestro CSS (unas 424): avisar
|
||||||
|
en cada versión nueva de las que desaparecen.
|
||||||
|
- Capturas antes y después de cada integración, comparadas solas.
|
||||||
|
|
||||||
|
|
||||||
|
## Antes de publicar
|
||||||
|
|
||||||
|
- La APK va firmada con la clave de depuración. Para repartirla hace falta una clave
|
||||||
|
propia (`OASIS_KEYSTORE` en `android/scripts/sobre_oficial.sh`).
|
||||||
|
- `docs/FRONTERA_UPSTREAM.md` es un informe del 19/10 y habla aún de Acampada y
|
||||||
|
`hive_acampada.css`: rehacer la medición con la siguiente versión del dev.
|
||||||
|
|
||||||
|
|
||||||
|
## Repo
|
||||||
|
|
||||||
|
- `package.json` llama en el `postinstall` a `scripts/patch-node-modules.js`, que no
|
||||||
|
existe. La APK no lo nota (saca `node_modules` de la oficial), pero `npm install`
|
||||||
|
falla.
|
||||||
|
- El `origin` del clon es gitea y va por detrás; se sube a forgejo. Cambiarlo si gitea
|
||||||
|
ya no se usa.
|
||||||
|
|
||||||
|
|
||||||
|
## Tras los perfiles por escalera (22/10)
|
||||||
|
|
||||||
|
- `backend.js` apaga en el móvil `ai`, `aiNav`, `dev`, `games`, `logs`, `maps` y
|
||||||
|
`pixelia`. Movimiento (`activists`) y Por defecto encienden alguno, así que en el
|
||||||
|
móvil nunca salen como puestos y usan `bottomBarPins`. Pedir al dev que sus perfiles
|
||||||
|
de móvil no los incluyan, o quitarlos de la cuenta en `fork_workflows.js`. La prueba
|
||||||
|
de perfiles lo sigue marcando con `dev`, que no tiene entrada en el menú.
|
||||||
|
- Hecho: las sub-fichas de un módulo filtrado van en una fila, y EMPEZAR abre la
|
||||||
|
bienvenida como botón grande (el nombre va después, en la propia bienvenida).
|
||||||
|
|
||||||
|
|
||||||
|
## Tras las pruebas de tribus y mensajería (23/10)
|
||||||
|
|
||||||
|
Prueba con dos identidades de usar y tirar, B ve desde fuera la tribu de
|
||||||
|
A, se une, recorre las 24 pestañas, escribe en el feed y el foro, y comenta un blog.
|
||||||
|
Pasa entero salvo lo de abajo.
|
||||||
|
|
||||||
|
Arreglado en la capa: la hoja del menú sin JS (`:target`), iconos propios en vez de
|
||||||
|
emoji en avisos, fichas de visibilidad y Compartir, avisos de arriba en una línea,
|
||||||
|
responder y enviar en los chats, buscador con "más opciones" de la 1.3.5 en
|
||||||
|
`form.filter-box`, botones de voto del foro, etiquetas de la tribu, hora de un
|
||||||
|
comentario, ficha PÚBLICO y etiquetas del menú que se salían.
|
||||||
|
|
||||||
|
Pedirle al dev:
|
||||||
|
|
||||||
|
- **Dejar una tribu no pide confirmación.** El botón DEJAR va directo a
|
||||||
|
`/tribes/leave/:id`. Lo mismo con la ✕ de borrar un comentario. Él ya tiene
|
||||||
|
`confirmPage` (lo usa para unirse con invitación abierta).
|
||||||
|
- **Enlace a un hilo que no existe.** En Actividad, la ficha de un comentario a un
|
||||||
|
evento lleva a `/thread/<id>` y sale "No se ha encontrado".
|
||||||
|
- **Emoji metidos en el texto**: "👥: 1 · 💬 3" en foros, listas y actividad, y
|
||||||
|
"🔔 SUSCRIBIRSE". Con un `span` alrededor del emoji, como ya hace en las fichas de
|
||||||
|
visibilidad, se podrían cambiar por iconos desde CSS.
|
||||||
|
- **Etiquetas de la barra de abajo**: "Votaciones" no cabe con cinco pestañas a 412 px
|
||||||
|
y sale "Votacion…".
|
||||||
|
|
||||||
|
|
||||||
|
## Vuelta del 24/10
|
||||||
|
|
||||||
|
Hecho:
|
||||||
|
|
||||||
|
- Batería entera sobre la APK nueva: idiomas (13 idiomas, 71 pantallas, 320/360/480
|
||||||
|
px, 0 problemas), lógica, usabilidad con las 8 paletas, perfiles, tribus y
|
||||||
|
contenido. Y repaso a ojo de las 66 pantallas del menú con capturas.
|
||||||
|
- Filas de fichas que no caben: con cuatro o más, una fila con flechas a los lados
|
||||||
|
(carrusel de CSS, sin JS). También las fichas de Gobierno de la tribu. Ver la guía.
|
||||||
|
- Avisos de abajo: se esconden con el menú, con la barra del pub y con el teclado
|
||||||
|
(antes se quedaban encima del teclado). EMPEZAR ya no baja con ellos: se queda arriba.
|
||||||
|
En el WebView viejo (Android 9 con el 69 y Android 11 con el 83) van arriba y las
|
||||||
|
fichas se quedan en rejilla.
|
||||||
|
- Cajas de "no hay nada" una dentro de otra (Agenda, Calendarios, Encuestas), Pads
|
||||||
|
con la tarjeta metida 40 px, el borde doble de OASIS/CLEARNET en la wiki y
|
||||||
|
"GOBERNANDO" partido en L.A.R.P.
|
||||||
|
|
||||||
|
Queda:
|
||||||
|
|
||||||
|
- En Android 9, "INTRODUCIR INVITACIÓN" de la tribu sale de 36 px.
|
||||||
|
- Las etiquetas cortas de la barra (`bbShort_*`): repasarlas con alguien de cada idioma.
|
||||||
|
- Botones de icono sin nombre del dev (la ✕ de EMPEZAR y otros): sin `title`, la prueba
|
||||||
|
de idiomas los marca en todas las pantallas.
|
||||||
7
docs/botones/descargar.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="52" viewBox="0 0 240 52" role="img" aria-label="Descargar APK 1.3.7">
|
||||||
|
<rect x="1" y="1" width="238" height="50" rx="12" fill="#121212" stroke="#ffd700" stroke-width="2"/>
|
||||||
|
<g fill="none" stroke="#ffd700" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" transform="translate(18 14)">
|
||||||
|
<path d="M12 3v12M7 10l5 5 5-5M4 20h16"/>
|
||||||
|
</g>
|
||||||
|
<text x="52" y="32" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="16" font-weight="700" fill="#ffd700">Descargar APK 1.3.7</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 591 B |
BIN
docs/capturas/00-portada.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
docs/capturas/01-actividad.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/capturas/02-menu.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/capturas/03-menu-activista.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/capturas/04-tribu.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/capturas/05-blog.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
docs/capturas/06-ajustes.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/esquemas/01-red.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
docs/esquemas/02-gossip.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/esquemas/04-pub.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/esquemas/05-empezar.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/esquemas/14-registro.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
47
main.js
|
|
@ -8,6 +8,13 @@ process.env.CHLORIDE_JS = '1';
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const androidInfo = JSON.parse(fs.readFileSync(path.join(__dirname, '.android.json'), 'utf8'));
|
||||||
|
if (androidInfo.versionName) process.env.OASIS_ANDROID_VERSION = String(androidInfo.versionName);
|
||||||
|
if (androidInfo.versionCode) process.env.OASIS_ANDROID_VERSION_CODE = String(androidInfo.versionCode);
|
||||||
|
process.env.OASIS_ANDROID_INSTALLER = String(androidInfo.installer || '');
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
const tmpDir = path.join(process.env.HOME, 'tmp');
|
const tmpDir = path.join(process.env.HOME, 'tmp');
|
||||||
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
|
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
|
||||||
process.env.TMPDIR = tmpDir;
|
process.env.TMPDIR = tmpDir;
|
||||||
|
|
@ -19,6 +26,8 @@ try {
|
||||||
const dns = require('dns');
|
const dns = require('dns');
|
||||||
const net = require('net');
|
const net = require('net');
|
||||||
const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4'];
|
const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4'];
|
||||||
|
const systemLookup = dns.lookup;
|
||||||
|
const SYSTEM_LOOKUP_MS = 5000;
|
||||||
try { dns.setServers(servers); } catch (e) {}
|
try { dns.setServers(servers); } catch (e) {}
|
||||||
try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {}
|
try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {}
|
||||||
|
|
||||||
|
|
@ -41,11 +50,43 @@ try {
|
||||||
? callback(null, [direct])
|
? callback(null, [direct])
|
||||||
: callback(null, direct.address, direct.family));
|
: callback(null, direct.address, direct.family));
|
||||||
}
|
}
|
||||||
|
// rama movil: primero el resolutor del sistema (getaddrinfo), que es el que
|
||||||
|
// respeta la VPN y el DNS privado de Android. Upstream iba siempre a
|
||||||
|
// 8.8.8.8/1.1.1.1, y las VPN que evitan fugas de DNS (Mullvad, por ejemplo)
|
||||||
|
// o las redes que bloquean DNS externos los rechazan: no se encontraba
|
||||||
|
// ningun pub por nombre y unirse a uno "no hacia nada". Los publicos quedan
|
||||||
|
// de respaldo, para los moviles donde getaddrinfo no contesta.
|
||||||
|
// Los dos caminos compiten: el del sistema sale ya y, si en SYSTEM_LOOKUP_MS
|
||||||
|
// no ha contestado o falla, se pregunta tambien a los publicos. Gana la primera
|
||||||
|
// respuesta buena; si fallan los dos, se devuelve el error del sistema, que
|
||||||
|
// dice mas ("no existe") que el de unos servidores quiza bloqueados.
|
||||||
|
let done = false, sysErr = null, pubErr = null, sysDone = false, pubDone = false, pubStarted = false;
|
||||||
|
const finish = (err, address, fam) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err) return callback(err);
|
||||||
|
if (options.all) return callback(null, Array.isArray(address) ? address : [{ address, family: fam }]);
|
||||||
|
if (Array.isArray(address)) return callback(null, address[0].address, address[0].family);
|
||||||
|
return callback(null, address, fam);
|
||||||
|
};
|
||||||
|
const bothFailed = () => { if (sysDone && pubDone) finish(sysErr || pubErr || new Error('ENOTFOUND ' + hostname)); };
|
||||||
|
const viaPublic = () => {
|
||||||
|
if (pubStarted) return;
|
||||||
|
pubStarted = true;
|
||||||
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
|
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
|
||||||
resolver.call(dns, hostname, (err, addrs) => {
|
resolver.call(dns, hostname, (err, addrs) => {
|
||||||
if (err || !addrs || !addrs.length) return callback(err || new Error('ENOTFOUND ' + hostname));
|
pubDone = true;
|
||||||
if (options.all) return callback(null, addrs.map((a) => ({ address: a, family })));
|
if (err || !addrs || !addrs.length) { pubErr = err || new Error('ENOTFOUND ' + hostname); return bothFailed(); }
|
||||||
return callback(null, addrs[0], family);
|
finish(null, options.all ? addrs.map((x) => ({ address: x, family })) : addrs[0], family);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const timer = setTimeout(viaPublic, SYSTEM_LOOKUP_MS);
|
||||||
|
systemLookup.call(dns, hostname, { family, all: !!options.all }, (err, address, fam) => {
|
||||||
|
sysDone = true;
|
||||||
|
if (!err) return finish(null, address, fam);
|
||||||
|
sysErr = err;
|
||||||
|
if (pubStarted) bothFailed(); else viaPublic();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@krakenslab/oasis",
|
"name": "@krakenslab/oasis",
|
||||||
"version": "0.8.7",
|
"version": "1.3.5",
|
||||||
"description": "Oasis - Social Networking Utopia",
|
"description": "Oasis - Social Networking Utopia",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
105
src/AI/ai_client.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const axiosMod = require('../server/node_modules/axios');
|
||||||
|
const axios = axiosMod.default || axiosMod;
|
||||||
|
|
||||||
|
const net = require('net');
|
||||||
|
const BASE_PORT = Number(process.env.OASIS_AI_PORT) || 4001;
|
||||||
|
let PORT = BASE_PORT;
|
||||||
|
|
||||||
|
const portIsFree = (port) => new Promise((resolve) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.once('error', () => resolve(false));
|
||||||
|
probe.once('listening', () => probe.close(() => resolve(true)));
|
||||||
|
probe.listen(port, '127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickPort = async () => {
|
||||||
|
for (let p = BASE_PORT; p < BASE_PORT + 20; p++) {
|
||||||
|
if (await portIsFree(p)) return p;
|
||||||
|
}
|
||||||
|
return BASE_PORT;
|
||||||
|
};
|
||||||
|
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
|
||||||
|
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
|
||||||
|
const ASK_TIMEOUT_MS = 300000;
|
||||||
|
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||||
|
const STATUS_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(24).toString('hex');
|
||||||
|
let child = null;
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
const isModelInstalled = () => { try { return fs.existsSync(MODEL_PATH); } catch (_) { return false; } };
|
||||||
|
|
||||||
|
let starting = null;
|
||||||
|
const start = () => {
|
||||||
|
if (started) return starting || Promise.resolve();
|
||||||
|
started = true;
|
||||||
|
if (process.env.OASIS_TEST) return Promise.resolve();
|
||||||
|
starting = pickPort().then((port) => { PORT = port; spawnService(); }).catch(() => { spawnService(); }).finally(() => { starting = null; });
|
||||||
|
return starting;
|
||||||
|
};
|
||||||
|
|
||||||
|
const spawnService = () => {
|
||||||
|
try {
|
||||||
|
child = spawn(process.execPath, [path.join(__dirname, 'ai_service.mjs')], {
|
||||||
|
stdio: ['ignore', 'ignore', DEBUG ? 'inherit' : 'ignore'],
|
||||||
|
env: { ...process.env, OASIS_AI_TOKEN: token, OASIS_AI_PORT: String(PORT), OASIS_AI_PARENT_PID: String(process.pid), OASIS_AI_MODEL: MODEL_PATH }
|
||||||
|
});
|
||||||
|
child.on('exit', () => { child = null; started = false; });
|
||||||
|
} catch (_) { child = null; started = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (!child) return;
|
||||||
|
try { child.kill('SIGTERM'); } catch (_) {}
|
||||||
|
child = null;
|
||||||
|
started = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = () => ({ 'x-oasis-ai-token': token });
|
||||||
|
|
||||||
|
const status = async () => {
|
||||||
|
if (!isModelInstalled()) return { installed: false, ready: false, loading: false, error: 'model_missing' };
|
||||||
|
await start();
|
||||||
|
try {
|
||||||
|
const r = await axios.get(`http://127.0.0.1:${PORT}/status`, { headers: headers(), timeout: STATUS_TIMEOUT_MS, proxy: false });
|
||||||
|
return { installed: true, ...(r.data || {}) };
|
||||||
|
} catch (_) {
|
||||||
|
return { installed: true, ready: false, loading: true, error: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECT_RETRIES = 300;
|
||||||
|
const CONNECT_RETRY_MS = 1000;
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const ask = async ({ system = '', history = [], context = [], input = '', lang = 'en', maxTokens } = {}) => {
|
||||||
|
if (!isModelInstalled()) throw new Error('model_missing');
|
||||||
|
await start();
|
||||||
|
let lastErr = null;
|
||||||
|
for (let attempt = 0; attempt < CONNECT_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const r = await axios.post(`http://127.0.0.1:${PORT}/ai`, { system, history, context, input, lang, maxTokens }, { headers: headers(), timeout: ASK_TIMEOUT_MS, proxy: false });
|
||||||
|
if (!r.data || typeof r.data.answer !== 'string') throw new Error('bad_answer');
|
||||||
|
return r.data.answer;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
const httpStatus = e && e.response && e.response.status;
|
||||||
|
const retryable = (e && e.code === 'ECONNREFUSED') || httpStatus === 503 || httpStatus === 403;
|
||||||
|
if (!retryable) break;
|
||||||
|
await sleep(CONNECT_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr || new Error('not_ready');
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('exit', stop);
|
||||||
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||||
|
process.on(sig, () => { stop(); process.exit(0); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, status, ask, isModelInstalled, MODEL_PATH, MODEL_FILE, port: () => PORT };
|
||||||
168
src/AI/ai_service.mjs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import http from 'http';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { getLlama, LlamaChatSession } from './node_modules/node-llama-cpp/dist/index.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const PORT = Number(process.env.OASIS_AI_PORT) || 4001;
|
||||||
|
const TOKEN = String(process.env.OASIS_AI_TOKEN || '');
|
||||||
|
const PARENT_PID = Number(process.env.OASIS_AI_PARENT_PID) || 0;
|
||||||
|
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
|
||||||
|
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
|
||||||
|
const GENERATION_TIMEOUT_MS = 120000;
|
||||||
|
const MAX_TOKENS_DEFAULT = 400;
|
||||||
|
const MAX_INPUT_CHARS = 5000;
|
||||||
|
const MAX_CONTEXT_CHARS = 6000;
|
||||||
|
|
||||||
|
const state = { loading: false, ready: false, error: null, model: null, contextSize: 0, threads: 0 };
|
||||||
|
let llama, model, context;
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
let loadingPromise = null;
|
||||||
|
|
||||||
|
const pickThreads = () => Math.max(1, Math.min(os.cpus().length - 1, 6));
|
||||||
|
const pickContextSize = () => {
|
||||||
|
const gb = os.totalmem() / (1024 ** 3);
|
||||||
|
if (gb < 6) return 2048;
|
||||||
|
if (gb < 12) return 4096;
|
||||||
|
return 8192;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function initModel() {
|
||||||
|
if (state.ready) return;
|
||||||
|
if (loadingPromise) return loadingPromise;
|
||||||
|
state.loading = true;
|
||||||
|
loadingPromise = loadModel().finally(() => { loadingPromise = null; });
|
||||||
|
return loadingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||||
|
const log = (msg) => { if (!DEBUG) return; try { process.stderr.write(`[ai-service] ${msg}\n`); } catch (_) {} };
|
||||||
|
|
||||||
|
async function loadModel() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(MODEL_PATH)) throw new Error(`model_missing:${MODEL_PATH}`);
|
||||||
|
log(`loading ${path.basename(MODEL_PATH)}`);
|
||||||
|
llama = await getLlama({ gpu: false });
|
||||||
|
log(`llama ready (${Date.now() - t0} ms)`);
|
||||||
|
model = await llama.loadModel({ modelPath: MODEL_PATH });
|
||||||
|
log(`model loaded (${Date.now() - t0} ms)`);
|
||||||
|
state.threads = pickThreads();
|
||||||
|
state.contextSize = pickContextSize();
|
||||||
|
context = await model.createContext({ contextSize: state.contextSize, threads: state.threads });
|
||||||
|
log(`context ready: ${state.contextSize} tokens, ${state.threads} threads (${Date.now() - t0} ms)`);
|
||||||
|
state.model = path.basename(MODEL_PATH);
|
||||||
|
state.ready = true;
|
||||||
|
state.error = null;
|
||||||
|
} catch (e) {
|
||||||
|
state.error = String((e && e.message) || e);
|
||||||
|
log(`load failed: ${state.error}`);
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
|
||||||
|
|
||||||
|
const buildHistory = ({ system, history, context: ctxLines }) => {
|
||||||
|
const items = [];
|
||||||
|
const sys = [clip(system, 2000)];
|
||||||
|
const lines = Array.isArray(ctxLines) ? ctxLines.map(x => clip(x, 800)).filter(Boolean) : [];
|
||||||
|
if (lines.length) {
|
||||||
|
let block = '';
|
||||||
|
for (const l of lines) { if ((block + l).length > MAX_CONTEXT_CHARS) break; block += `- ${l}\n`; }
|
||||||
|
sys.push(`Knowledge from the Oasis network (use it when relevant, never invent beyond it):\n${block}`);
|
||||||
|
}
|
||||||
|
items.push({ type: 'system', text: sys.join('\n\n') });
|
||||||
|
for (const turn of (Array.isArray(history) ? history : []).slice(-8)) {
|
||||||
|
if (!turn || typeof turn.text !== 'string' || !turn.text.trim()) continue;
|
||||||
|
if (turn.type === 'user') items.push({ type: 'user', text: clip(turn.text, MAX_INPUT_CHARS) });
|
||||||
|
else if (turn.type === 'model') items.push({ type: 'model', response: [clip(turn.text, MAX_INPUT_CHARS)] });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function answer(body) {
|
||||||
|
await initModel();
|
||||||
|
if (!state.ready) throw new Error(state.error || 'not_ready');
|
||||||
|
const input = clip(body.input, MAX_INPUT_CHARS).trim();
|
||||||
|
if (!input) throw new Error('empty_input');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), GENERATION_TIMEOUT_MS);
|
||||||
|
const sequence = context.getSequence();
|
||||||
|
const session = new LlamaChatSession({ contextSequence: sequence });
|
||||||
|
try {
|
||||||
|
session.setChatHistory(buildHistory(body));
|
||||||
|
const text = await session.prompt(input, {
|
||||||
|
maxTokens: Math.max(32, Math.min(1024, Number(body.maxTokens) || MAX_TOKENS_DEFAULT)),
|
||||||
|
temperature: 0.6,
|
||||||
|
signal: controller.signal,
|
||||||
|
stopOnAbortSignal: true
|
||||||
|
});
|
||||||
|
return String(text || '').trim();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { session.dispose(); } catch (_) {}
|
||||||
|
try { sequence.dispose(); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readJson = (req) => new Promise((resolve, reject) => {
|
||||||
|
let raw = '';
|
||||||
|
req.on('data', (c) => { raw += c; if (raw.length > 200000) { reject(new Error('body_too_large')); req.destroy(); } });
|
||||||
|
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(e); } });
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = (res, status, obj) => {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (TOKEN && req.headers['x-oasis-ai-token'] !== TOKEN) return send(res, 403, { error: 'forbidden' });
|
||||||
|
if (req.method === 'GET' && req.url === '/status') {
|
||||||
|
if (!state.ready && !state.loading && !state.error) initModel();
|
||||||
|
return send(res, 200, { ...state });
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/ai') {
|
||||||
|
let body;
|
||||||
|
try { body = await readJson(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
|
||||||
|
const job = queue.then(() => answer(body));
|
||||||
|
queue = job.catch(() => {});
|
||||||
|
try {
|
||||||
|
const text = await job;
|
||||||
|
return send(res, 200, { answer: text });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String((e && e.message) || e);
|
||||||
|
const status = msg.startsWith('model_missing') ? 503 : (msg === 'not_ready' ? 503 : 500);
|
||||||
|
log(`answer failed: ${msg}`);
|
||||||
|
return send(res, status, { error: msg, ...state });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send(res, 404, { error: 'not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
let listenAttempts = 0;
|
||||||
|
server.on('error', (e) => {
|
||||||
|
if (e && e.code === 'EADDRINUSE' && listenAttempts < 20) {
|
||||||
|
listenAttempts += 1;
|
||||||
|
setTimeout(() => server.listen(PORT, '127.0.0.1'), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
server.listen(PORT, '127.0.0.1', () => { initModel(); });
|
||||||
|
|
||||||
|
const shutdown = () => { try { server.close(); } catch (_) {} process.exit(0); };
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
if (PARENT_PID > 0) {
|
||||||
|
setInterval(() => {
|
||||||
|
try { process.kill(PARENT_PID, 0); } catch (_) { shutdown(); }
|
||||||
|
}, 5000).unref();
|
||||||
|
}
|
||||||
238
src/AI/buildAIContext.js
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
const { readTyped } = require('../models/typed_log')
|
||||||
|
const { getConfig } = require('../configs/config-manager.js')
|
||||||
|
|
||||||
|
const logLimit = getConfig().ssbLogStream?.limit || 1000
|
||||||
|
|
||||||
|
let cooler = null
|
||||||
|
let ssb = null
|
||||||
|
let opening = null
|
||||||
|
|
||||||
|
function getCooler() {
|
||||||
|
let ssbPath = null
|
||||||
|
try { ssbPath = require.resolve('../server/SSB_server.js') } catch {}
|
||||||
|
if (ssbPath && require.cache[ssbPath]) {
|
||||||
|
if (!cooler) {
|
||||||
|
const gui = require('../client/gui.js')
|
||||||
|
cooler = gui({ offline: false })
|
||||||
|
}
|
||||||
|
return cooler
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCooler(c) {
|
||||||
|
cooler = c || null
|
||||||
|
ssb = null
|
||||||
|
opening = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true'
|
||||||
|
const debug = (msg) => { if (DEBUG) console.error(`[ai] ${msg}`) }
|
||||||
|
|
||||||
|
async function openSsb() {
|
||||||
|
const c = cooler || getCooler()
|
||||||
|
if (!c) return null
|
||||||
|
if (ssb && ssb.closed === false) return ssb
|
||||||
|
if (!opening) {
|
||||||
|
debug('opening the SSB connection for the network knowledge')
|
||||||
|
opening = c.open().then(x => { debug('SSB connection open'); return (ssb = x) }).finally(() => { opening = null })
|
||||||
|
}
|
||||||
|
await opening
|
||||||
|
return ssb
|
||||||
|
}
|
||||||
|
|
||||||
|
const clip = (s, n) => String(s || '').slice(0, n)
|
||||||
|
const squash = s => String(s || '').replace(/\s+/g, ' ').trim()
|
||||||
|
const compact = s => squash(clip(s, 160))
|
||||||
|
const normalize = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w\u0080-\uFFFF\s]+/g, '').trim()
|
||||||
|
|
||||||
|
function fieldsForSnippet(type, c) {
|
||||||
|
if (type === 'aiExchange') return [c?.question, clip(squash(c?.answer || ''), 120)]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishExchange({ q, a, ctx = [], tokens = {}, lang = '', tags = [], rating = 0 }) {
|
||||||
|
const s = await openSsb()
|
||||||
|
if (!s) return null
|
||||||
|
const safeLang = String(lang || '').trim().slice(0, 8).toLowerCase()
|
||||||
|
const safeTags = Array.isArray(tags)
|
||||||
|
? Array.from(new Set(tags.map(t => String(t || '').trim().slice(0, 32)).filter(Boolean))).slice(0, 10)
|
||||||
|
: []
|
||||||
|
const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0)))
|
||||||
|
const content = {
|
||||||
|
type: 'aiExchange',
|
||||||
|
question: clip(String(q || ''), 2000),
|
||||||
|
answer: clip(String(a || ''), 5000),
|
||||||
|
ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
if (safeLang) content.lang = safeLang
|
||||||
|
if (safeTags.length) content.tags = safeTags
|
||||||
|
if (safeRating > 0) content.rating = safeRating
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishExchangeVote({ targetId, helpful = true }) {
|
||||||
|
const s = await openSsb()
|
||||||
|
if (!s || !targetId) return null
|
||||||
|
const content = {
|
||||||
|
type: 'aiExchangeVote',
|
||||||
|
target: String(targetId),
|
||||||
|
helpful: !!helpful,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildContext(maxItems = 100) {
|
||||||
|
const s = await openSsb()
|
||||||
|
if (!s) return ''
|
||||||
|
const msgs = (await readTyped(s, ['aiExchange'], { limit: logLimit }).catch(() => null) || []).reverse()
|
||||||
|
const lines = []
|
||||||
|
for (const { value } of msgs) {
|
||||||
|
const c = value && value.content || {}
|
||||||
|
if (c.type !== 'aiExchange') continue
|
||||||
|
const d = new Date(value.timestamp || 0).toISOString().slice(0, 10)
|
||||||
|
const q = compact(c.question)
|
||||||
|
const a = compact(c.answer)
|
||||||
|
lines.push(`[${d}] (AIExchange) Q: ${q} | A: ${a}`)
|
||||||
|
if (lines.length >= maxItems) break
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return ''
|
||||||
|
return `## AIEXCHANGE\n\n${lines.join('\n')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const VECTORS_FILE = 'AI-vectors.json'
|
||||||
|
const VECTORS_MAX = 4000
|
||||||
|
const RATING_WEIGHT = 0.03
|
||||||
|
const VOTE_WEIGHT = 0.02
|
||||||
|
const CONTEXT_MIN_SCORE = 0.35
|
||||||
|
const DIRECT_ANSWER_MIN = 0.92
|
||||||
|
|
||||||
|
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
|
||||||
|
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
|
||||||
|
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
|
||||||
|
|
||||||
|
const exchangeText = (c) => clip(squash(c.question), 300)
|
||||||
|
|
||||||
|
async function trustedAuthors(s) {
|
||||||
|
const me = s.id
|
||||||
|
const msgs = await readTyped(s, ['contact'], { limit: logLimit }).catch(() => null) || []
|
||||||
|
const latest = new Map()
|
||||||
|
for (const m of msgs) {
|
||||||
|
const v = m && m.value || {}
|
||||||
|
const c = v.content || {}
|
||||||
|
if (v.author !== me || typeof c.contact !== 'string') continue
|
||||||
|
const ts = Number(v.timestamp) || 0
|
||||||
|
const prev = latest.get(c.contact)
|
||||||
|
if (prev && prev.ts > ts) continue
|
||||||
|
latest.set(c.contact, { ts, on: c.following === true && c.blocking !== true })
|
||||||
|
}
|
||||||
|
const out = new Set([me])
|
||||||
|
for (const [id, st] of latest) if (st.on) out.add(id)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listExchanges() {
|
||||||
|
const s = await openSsb()
|
||||||
|
if (!s) return { exchanges: [], votes: new Map() }
|
||||||
|
const msgs = await readTyped(s, ['aiExchange', 'aiExchangeVote'], { limit: logLimit }).catch(() => null) || []
|
||||||
|
const trusted = await trustedAuthors(s)
|
||||||
|
const exchanges = []
|
||||||
|
const votes = new Map()
|
||||||
|
for (const m of msgs) {
|
||||||
|
const c = m && m.value && m.value.content || {}
|
||||||
|
if (!trusted.has(m.value.author)) continue
|
||||||
|
if (c.type === 'aiExchange' && c.question && c.answer) {
|
||||||
|
exchanges.push({ key: m.key, author: m.value.author, ts: m.value.timestamp || m.timestamp || 0, question: String(c.question), answer: String(c.answer), rating: Number(c.rating) || 0, tags: Array.isArray(c.tags) ? c.tags : [], lang: c.lang || '' })
|
||||||
|
} else if (c.type === 'aiExchangeVote' && c.target) {
|
||||||
|
const k = String(c.target)
|
||||||
|
const per = votes.get(k) || new Map()
|
||||||
|
per.set(m.value.author, c.helpful !== false ? 1 : -1)
|
||||||
|
votes.set(k, per)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { exchanges, votes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const netVotes = (votes, key) => { const per = votes.get(key); if (!per) return 0; let n = 0; for (const v of per.values()) n += v; return n }
|
||||||
|
|
||||||
|
async function rankExchanges(question, { embed, cosine, k = 5, excludeAuthors = null } = {}) {
|
||||||
|
const listed = await listExchanges()
|
||||||
|
const votes = listed.votes
|
||||||
|
const exchanges = excludeAuthors instanceof Set && excludeAuthors.size
|
||||||
|
? listed.exchanges.filter(ex => !excludeAuthors.has(String(ex.author)))
|
||||||
|
: listed.exchanges
|
||||||
|
if (!exchanges.length) return []
|
||||||
|
const scored = []
|
||||||
|
if (typeof embed === 'function' && typeof cosine === 'function') {
|
||||||
|
const qv = await embed(question).catch(() => null)
|
||||||
|
if (qv) {
|
||||||
|
const cache = readVectors()
|
||||||
|
let dirty = false
|
||||||
|
const newest = exchanges.slice().sort((a, b) => b.ts - a.ts).slice(0, VECTORS_MAX)
|
||||||
|
for (const ex of newest) {
|
||||||
|
let v = cache[ex.key]
|
||||||
|
if (!Array.isArray(v)) {
|
||||||
|
v = await embed(exchangeText(ex)).catch(() => null)
|
||||||
|
if (!v) continue
|
||||||
|
cache[ex.key] = v
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
const sim = cosine(qv, v)
|
||||||
|
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
const keep = new Set(newest.map(x => x.key))
|
||||||
|
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
|
||||||
|
writeVectors(cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!scored.length) {
|
||||||
|
const want = normalize(question)
|
||||||
|
const terms = want.split(' ').filter(t => t.length > 2)
|
||||||
|
for (const ex of exchanges) {
|
||||||
|
const text = normalize(ex.question + ' ' + ex.answer)
|
||||||
|
const hits = terms.filter(t => text.includes(t)).length
|
||||||
|
if (!hits) continue
|
||||||
|
const sim = normalize(ex.question) === want ? 1 : hits / Math.max(1, terms.length)
|
||||||
|
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => b.score - a.score)
|
||||||
|
return scored.filter(x => x.similarity >= CONTEXT_MIN_SCORE).slice(0, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rankContext(question, opts = {}) {
|
||||||
|
const top = await rankExchanges(question, opts)
|
||||||
|
return top.map(ex => `Q: ${compact(ex.question)} | A: ${clip(squash(ex.answer), 400)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBestTrainedAnswer(question, opts = {}) {
|
||||||
|
const top = await rankExchanges(question, { ...opts, k: 1 })
|
||||||
|
const best = top[0]
|
||||||
|
if (!best || best.similarity < DIRECT_ANSWER_MIN || best.votes < 0) return null
|
||||||
|
return { answer: String(best.answer || '').trim(), ctx: [`Q: ${compact(best.question)}`], key: best.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFineTuning({ system = '' } = {}) {
|
||||||
|
const { exchanges, votes } = await listExchanges()
|
||||||
|
const lines = []
|
||||||
|
for (const ex of exchanges.sort((a, b) => a.ts - b.ts)) {
|
||||||
|
if (netVotes(votes, ex.key) < 0) continue
|
||||||
|
const messages = []
|
||||||
|
if (system) messages.push({ role: 'system', content: system })
|
||||||
|
messages.push({ role: 'user', content: ex.question })
|
||||||
|
messages.push({ role: 'assistant', content: ex.answer })
|
||||||
|
lines.push(JSON.stringify({ messages, meta: { key: ex.key, author: ex.author, lang: ex.lang, tags: ex.tags, rating: ex.rating, helpful: netVotes(votes, ex.key), ts: ex.ts } }))
|
||||||
|
}
|
||||||
|
return lines.join('\n') + (lines.length ? '\n' : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer, rankContext, rankExchanges, listExchanges, exportFineTuning, useCooler, DIRECT_ANSWER_MIN, CONTEXT_MIN_SCORE }
|
||||||
138
src/AI/embedder.js
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const { fork } = require('child_process')
|
||||||
|
|
||||||
|
const MODEL_DIR = path.join(__dirname, 'embeddings')
|
||||||
|
const MODEL_FILE = path.join(MODEL_DIR, 'onnx', 'model_quantized.onnx')
|
||||||
|
const EMBED_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
|
let worker = null
|
||||||
|
let workerReady = false
|
||||||
|
let permanentFail = false
|
||||||
|
let unavailableReason = null
|
||||||
|
let seq = 0
|
||||||
|
const pending = new Map()
|
||||||
|
|
||||||
|
const isInstalled = () => {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(MODEL_FILE)
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = (a, b) => {
|
||||||
|
let s = 0
|
||||||
|
for (let i = 0; i < a.length; i++) s += a[i] * b[i]
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (a) => Math.sqrt(dot(a, a)) || 1
|
||||||
|
|
||||||
|
const cosine = (a, b) => dot(a, b) / (norm(a) * norm(b))
|
||||||
|
|
||||||
|
const settlePending = () => {
|
||||||
|
for (const p of pending.values()) p.resolve(null)
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureWorker = () => {
|
||||||
|
if (worker || permanentFail) return
|
||||||
|
const child = fork(__filename, ['--embed-worker'], { silent: true })
|
||||||
|
if (child.stdout) child.stdout.resume()
|
||||||
|
if (child.stderr) child.stderr.resume()
|
||||||
|
child.on('message', (msg) => {
|
||||||
|
const p = pending.get(msg && msg.id)
|
||||||
|
if (!p) return
|
||||||
|
pending.delete(msg.id)
|
||||||
|
workerReady = true
|
||||||
|
p.resolve(Array.isArray(msg.vec) ? msg.vec : null)
|
||||||
|
})
|
||||||
|
child.on('error', () => {})
|
||||||
|
child.on('exit', () => {
|
||||||
|
settlePending()
|
||||||
|
if (worker === child) {
|
||||||
|
worker = null
|
||||||
|
if (!workerReady) {
|
||||||
|
permanentFail = true
|
||||||
|
unavailableReason = 'embedder_native_crash'
|
||||||
|
}
|
||||||
|
workerReady = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
worker = child
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = async (text, { timeoutMs = EMBED_TIMEOUT_MS } = {}) => {
|
||||||
|
if (!isInstalled() || permanentFail) return null
|
||||||
|
ensureWorker()
|
||||||
|
const child = worker
|
||||||
|
if (!child) return null
|
||||||
|
const id = ++seq
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (pending.has(id)) {
|
||||||
|
pending.delete(id)
|
||||||
|
try { child.kill() } catch (_) {}
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}, Math.max(1000, Number(timeoutMs) || EMBED_TIMEOUT_MS))
|
||||||
|
pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v) } })
|
||||||
|
try {
|
||||||
|
child.send({ id, text: String(text || '').trim() })
|
||||||
|
} catch (_) {
|
||||||
|
pending.delete(id)
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = () => ({
|
||||||
|
installed: isInstalled(),
|
||||||
|
loaded: workerReady,
|
||||||
|
unavailableReason
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = { embed, cosine, isInstalled, status, MODEL_DIR, MODEL_FILE }
|
||||||
|
|
||||||
|
if (process.argv[2] === '--embed-worker') {
|
||||||
|
let pipelinePromise = null
|
||||||
|
const ensurePipeline = async () => {
|
||||||
|
if (pipelinePromise) return pipelinePromise
|
||||||
|
if (!isInstalled()) return null
|
||||||
|
pipelinePromise = (async () => {
|
||||||
|
let mod
|
||||||
|
try {
|
||||||
|
const url = require('url')
|
||||||
|
const transformersPath = path.join(__dirname, 'node_modules', '@xenova', 'transformers', 'src', 'transformers.js')
|
||||||
|
mod = await import(url.pathToFileURL(transformersPath).href)
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const { pipeline, env } = mod
|
||||||
|
env.allowRemoteModels = false
|
||||||
|
env.localModelPath = path.join(__dirname)
|
||||||
|
env.cacheDir = path.join(__dirname, '.cache')
|
||||||
|
try {
|
||||||
|
return await pipeline('feature-extraction', 'embeddings', { quantized: true })
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const fe = await pipelinePromise
|
||||||
|
if (!fe) pipelinePromise = null
|
||||||
|
return fe
|
||||||
|
}
|
||||||
|
process.on('message', async (msg) => {
|
||||||
|
let vec = null
|
||||||
|
try {
|
||||||
|
const fe = await ensurePipeline()
|
||||||
|
if (fe) {
|
||||||
|
const out = await fe(String((msg && msg.text) || ''), { pooling: 'mean', normalize: true })
|
||||||
|
vec = Array.from(out.data)
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
try { process.send({ id: msg && msg.id, vec }) } catch (_) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
397
src/AI/intents.js
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
const INTENTS = [
|
||||||
|
{ key: 'funds', description: 'my balance, how much ECO do I have, my wallet funds, my ECOin address', keywords: ['balance', 'saldo', 'funds', 'fondos', 'wallet', 'cartera', 'address', 'dirección', 'my ecoin', 'mi ecoin', 'fons', 'moneder', 'carteira', 'adreça', 'enderezo', 'el meu ecoin', 'o meu ecoin'] },
|
||||||
|
{ key: 'exchange', description: 'the value of one ECO, ECO price, ECOin exchange rate, how much is an ECO worth, ECO per hour, supply and inflation of ECOin', keywords: ['value of', 'valor de', 'eco value', 'ecoin value', 'valor del eco', 'price', 'precio', 'worth', 'vale un eco', 'cuánto vale', 'exchange', 'cotización', 'cambio', 'supply', 'suministro', 'inflation', 'inflación', 'eco/h', '1 eco', 'un eco', 'preu', 'prezo', 'quant val', 'canto vale', 'cotització', 'canvi', 'oferta total', 'inflació'] },
|
||||||
|
{ key: 'ubi', description: 'my UBI, universal basic income, can I claim the UBI, when did I claim, how much UBI did I receive', keywords: ['ubi', 'rbu', 'basic income', 'renta básica', 'claim', 'reclamar', 'cobrar', 'renda bàsica', 'renda básica'] },
|
||||||
|
{ key: 'karma', description: 'my karma score, my taxes, eco tax, arch tax, engagement score', keywords: ['karma', 'tax', 'impuesto', 'engagement', 'impost', 'imposto', 'taxa eco', 'taxa arch'] },
|
||||||
|
{ key: 'agenda', description: 'my agenda, what do I have coming up, upcoming dates, next days, schedule', keywords: ['agenda', 'schedule', 'próximo', 'upcoming', 'coming', 'esta semana', 'this week', 'calendar', 'calendario', 'aquesta setmana', 'propers dies', 'calendari', 'axenda', 'próximos días'] },
|
||||||
|
{ key: 'events', description: 'upcoming events, next event, events near me, what events are there', keywords: ['event', 'evento', 'esdeveniment'] },
|
||||||
|
{ key: 'tasks', description: 'my open tasks, pending tasks, to-do, what do I have to do', keywords: ['task', 'tarea', 'to-do', 'todo', 'pendiente', 'tasca', 'tarefa', 'pendent', 'tasques'] },
|
||||||
|
{ key: 'transfers', description: 'pending transfers, contracts waiting for my confirmation, payments to confirm', keywords: ['transfer', 'transferencia', 'contract', 'contrato', 'confirm', 'confirmar', 'payment', 'pago', 'contracte', 'pagament', 'pagamento'] },
|
||||||
|
{ key: 'relationships', description: 'who follows me, who do I follow, my followers, my mutual contacts, friends', keywords: ['follow', 'sigue', 'seguidor', 'follower', 'mutual', 'friend', 'amigo', 'contacto', 'segueix', 'segue', 'amic', 'contacte', 'mutus'] },
|
||||||
|
{ key: 'inbox', description: 'my inbox, unread messages, private messages, notifications, mentions', keywords: ['inbox', 'unread', 'no leído', 'mensaje', 'message', 'pm', 'notification', 'notificación', 'mention', 'mención', 'no llegit', 'non lido', 'missatge', 'mensaxe', 'notificació', 'bústia', 'buzón', 'caixa de entrada', 'menció'] },
|
||||||
|
{ key: 'government', description: 'current government, parliament, who rules, governance cycle, LARP house', keywords: ['government', 'gobierno', 'parliament', 'parlamento', 'rules', 'gobierna', 'larp', 'house', 'casa', 'govern', 'goberno', 'parlament', 'governa', 'goberna'] },
|
||||||
|
{ key: 'emergencies', description: 'active emergencies, alerts, is there any emergency', keywords: ['emergency', 'emergencia', 'alert', 'alerta', 'emergència', 'emerxencia', 'emergències'] },
|
||||||
|
{ key: 'jobs', description: 'open job offers, jobs for me, work, salary', keywords: ['job', 'trabajo', 'empleo', 'offer', 'oferta', 'salary', 'salario', 'vacante', 'feina', 'treball', 'ocupació', 'traballo', 'emprego', 'salari', 'feines'] },
|
||||||
|
{ key: 'market', description: 'items for sale in the market, second hand, auctions, what can I buy', keywords: ['market', 'mercado', 'buy', 'comprar', 'vender', 'item', 'auction', 'subasta', 'mercat', 'vendre', 'subhasta', 'poxa', 'subhastes'] },
|
||||||
|
{ key: 'tribes', description: 'my tribes, which tribes am I in, tribe members', keywords: ['tribe', 'tribu', 'tribo'] },
|
||||||
|
{ key: 'network', description: 'network status, inhabitants count, peers online, last sync, population', keywords: ['network', 'red', 'peer', 'inhabitant', 'habitante', 'population', 'población', 'online', 'sync', 'sincron', 'xarxa', 'rede', 'habitant', 'població', 'poboación'] },
|
||||||
|
{ key: 'me', description: 'who am I, my identity, my id, my name, my profile, oasis version', keywords: ['who am i', 'quién soy', 'my id', 'mi id', 'identity', 'identidad', 'profile', 'perfil', 'version', 'versión', 'qui sóc', 'quen son', 'el meu id', 'o meu id', 'identitat', 'versió'] },
|
||||||
|
{ key: 'shops', description: 'products in the shops, what do the shops sell, shop catalogue, buy a product', keywords: ['shop', 'tienda', 'product', 'producto', 'catálogo', 'catalog', 'botiga', 'tenda', 'producte', 'produto', 'catàleg', 'botigues'] },
|
||||||
|
{ key: 'school', description: 'courses in the school, lessons, my courses, enrol, learn, certificates', keywords: ['school', 'escuela', 'course', 'curso', 'lesson', 'lección', 'learn', 'aprender', 'certificate', 'certificado', 'student', 'estudiante', 'escola', ' curs', 'lliçó', 'aprendre', 'certificat', 'estudiant', 'estudante'] },
|
||||||
|
{ key: 'projects', description: 'crowdfunding projects, projects looking for funding, pledges, project goals', keywords: ['project', 'proyecto', 'crowdfunding', 'pledge', 'aportación', 'financiar', 'funding', 'projecte', 'proxecto', 'aportació', 'achega', 'finançar'] },
|
||||||
|
{ key: 'housing', description: 'housing, places offered, rooms, homes, rent a place, accommodation', keywords: ['housing', 'vivienda', 'room', 'habitación', 'rent', 'alquil', 'accommodation', 'alojamiento', 'habitatge', 'vivenda', 'habitació', 'lloguer', 'aluguer', 'allotjament', 'aloxamento'] },
|
||||||
|
{ key: 'industry', description: 'industry facilities, factories, builds, production, what is being built', keywords: ['industry', 'industria', 'facility', 'fábrica', 'factory', 'build', 'construir', 'production', 'producción', 'indústria', 'fàbrica', 'producció', 'produción'] },
|
||||||
|
{ key: 'campaigns', description: 'campaigns, petitions, signatures, collective demands', keywords: ['campaign', 'campaña', 'petition', 'petición', 'signature', 'firma', 'demand', 'campanya', 'petició', 'signatura', 'sinatura', 'campanyes', 'signatures'] },
|
||||||
|
{ key: 'logistics', description: 'logistics routes, deliveries, transport, shipping, packages, bookings', keywords: ['logistic', 'logística', 'route', 'ruta', 'delivery', 'entrega', 'transport', 'envío', 'shipping', 'package', 'paquete', 'loxística', 'lliurament', 'enviament', 'paquet'] },
|
||||||
|
{ key: 'calendars', description: 'calendars, dates, upcoming dates in my calendars, deadlines, reminders', keywords: ['calendar', 'calendario', 'date', 'fecha', 'deadline', 'recordatorio', 'reminder', 'calendari', 'termini', 'prazo', 'recordatori'] },
|
||||||
|
{ key: 'votes', description: 'votations, polls, open votes, voting, ballots, what can I vote on', keywords: ['vote', 'votación', 'votar', 'poll', 'encuesta', 'ballot', 'referendum', 'votació', 'enquesta', 'enquisa', 'enquestes'] },
|
||||||
|
{ key: 'wiki', description: 'wiki pages, knowledge base, documentation written by inhabitants', keywords: ['wiki'] },
|
||||||
|
{ key: 'chats', description: 'my chats, group chats, conversations, chat rooms', keywords: ['chat', 'conversación', 'sala', 'xat', 'conversa'] },
|
||||||
|
{ key: 'mailing', description: 'mailing lists, subscriptions, newsletters, discussion lists', keywords: ['mailing', 'lista de correo', 'newsletter', 'subscri', 'llista de correu'] },
|
||||||
|
{ key: 'podcasts', description: 'podcasts, audio shows, episodes, radio', keywords: ['podcast', 'episode', 'episodio', 'radio', 'episodi'] },
|
||||||
|
{ key: 'media', description: 'audios, videos, images, documents, files, media library, how many files', keywords: ['audio', 'video', 'vídeo', 'image', 'imagen', 'photo', 'foto', 'document', 'documento', 'file', 'archivo', 'media', 'gallery', 'galería', 'torrent', 'àudio', 'imatge', 'imaxe', 'fitxer', 'arxiu', 'ficheiro', 'arquivo', 'galeria'] },
|
||||||
|
{ key: 'blogs', description: 'blogs, blog entries, articles, latest posts written by inhabitants', keywords: ['blog', 'entrada', 'article', 'artículo', 'artigo'] },
|
||||||
|
{ key: 'reports', description: 'reports, complaints, flagged content, incidents', keywords: ['report', 'reporte', 'denuncia', 'complaint', 'queja', 'flag', 'incident', 'denúncia', 'queixa', 'informe'] },
|
||||||
|
{ key: 'games', description: 'games, hall of fame, scores, best players, play', keywords: ['game', 'juego', 'play', 'jugar', 'score', 'puntuación', 'hall of fame', 'ranking', ' joc', 'xogo', 'xogar', 'puntuació'] },
|
||||||
|
{ key: 'tags', description: 'tags, hashtags, topics, most used tags, what is the network talking about', keywords: ['tag', 'etiqueta', 'hashtag', 'topic', 'tema', 'trending', 'tendencia', 'tendència', 'etiquetes'] },
|
||||||
|
{ key: 'favorites', description: 'my favorites, things I starred, saved items, bookmarks', keywords: ['favorite', 'favorito', 'starred', 'saved', 'guardado', 'bookmark', 'marcador', 'preferit', 'favorit', 'desat', 'gardado'] },
|
||||||
|
{ key: 'modules', description: 'which modules are enabled, what features does this node have, oasis modules', keywords: ['module', 'módulo', 'feature', 'funcionalidad', 'enabled', 'activado', 'disabled', 'desactivado', 'plugin', 'mòdul', 'funcionalitat', 'activat', 'desactivat'] },
|
||||||
|
{ key: 'help', description: 'what can you do, help, how can you help me, what do you know, commands', keywords: ['what can you', 'qué puedes', 'help', 'ayuda', 'how can you', 'cómo puedes', 'what do you know', 'qué sabes', 'capabilities', 'què pots', 'que podes', 'ajuda', 'axuda', 'com pots', 'como podes', 'què saps'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const KEYWORD_MIN = 1;
|
||||||
|
const EMBED_MIN = 0.55;
|
||||||
|
const EMBED_MARGIN = 0.05;
|
||||||
|
|
||||||
|
const norm = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||||
|
|
||||||
|
const detectByKeywords = (question) => {
|
||||||
|
const q = ' ' + norm(question) + ' ';
|
||||||
|
let best = null;
|
||||||
|
for (const it of INTENTS) {
|
||||||
|
const hits = it.keywords.filter(k => q.includes(norm(k))).length;
|
||||||
|
if (hits >= KEYWORD_MIN && (!best || hits > best.hits)) best = { key: it.key, hits };
|
||||||
|
}
|
||||||
|
return best ? best.key : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let intentVectors = null;
|
||||||
|
const detect = async (question, { embed, cosine } = {}) => {
|
||||||
|
const byKeywords = detectByKeywords(question);
|
||||||
|
if (byKeywords) return byKeywords;
|
||||||
|
if (typeof embed !== 'function' || typeof cosine !== 'function') return null;
|
||||||
|
const qv = await embed(question).catch(() => null);
|
||||||
|
if (!qv) return null;
|
||||||
|
if (!intentVectors) {
|
||||||
|
const out = [];
|
||||||
|
for (const it of INTENTS) { const v = await embed(it.description).catch(() => null); if (v) out.push({ key: it.key, v }); }
|
||||||
|
intentVectors = out;
|
||||||
|
}
|
||||||
|
const scored = intentVectors.map(it => ({ key: it.key, s: cosine(qv, it.v) })).sort((a, b) => b.s - a.s);
|
||||||
|
const best = scored[0];
|
||||||
|
const second = scored[1];
|
||||||
|
if (!best || best.s < EMBED_MIN) return null;
|
||||||
|
if (second && best.s - second.s < EMBED_MARGIN) return null;
|
||||||
|
return best.key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtEco = (n) => `${Number(n || 0).toFixed(6)} ECO`;
|
||||||
|
const fmtDate = (d) => { const t = new Date(d || 0); return Number.isFinite(t.getTime()) && t.getTime() > 0 ? t.toISOString().replace('T', ' ').slice(0, 16) : ''; };
|
||||||
|
const titleOf = (c) => String(c.title || c.name || c.question || c.concept || c.subject || '').trim();
|
||||||
|
const dateOf = (c) => c.date || c.startTime || c.startDate || c.deadline || c.endTime || c.createdAt || null;
|
||||||
|
const line = (text, href) => ({ text, href: href || null });
|
||||||
|
|
||||||
|
const RUNNERS = {
|
||||||
|
async funds({ models, me }) {
|
||||||
|
const out = [];
|
||||||
|
const data = await models.banking.listBanking('overview', me).catch(() => null);
|
||||||
|
const summary = (data && data.summary) || {};
|
||||||
|
const addr = await models.banking.getUserAddress(me).catch(() => null);
|
||||||
|
if (summary.hasValidWallet) out.push(line(`Wallet balance: ${fmtEco(summary.userBalance)}`, '/wallet'));
|
||||||
|
else out.push(line('Wallet: not connected (Settings › Wallet)', '/settings#wallet'));
|
||||||
|
out.push(line(addr ? `ECOin address: ${addr}` : 'ECOin address: none published', '/banking?filter=addresses'));
|
||||||
|
if (data && data.exchange && data.exchange.isSynced) out.push(line(`ECO value (1h): ${Number(data.exchange.ecoValue || 0).toFixed(4)} ECO`, '/banking?filter=exchange'));
|
||||||
|
if (Number.isFinite(Number(summary.industryBalance)) && Number(summary.industryBalance) !== 0) out.push(line(`Industry balance: ${fmtEco(summary.industryBalance)}`, '/industry'));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async exchange({ models, me }) {
|
||||||
|
const data = await models.banking.listBanking('exchange', me).catch(() => null);
|
||||||
|
const ex = (data && data.exchange) || {};
|
||||||
|
if (!ex.isSynced) return [line('ECO value: unknown right now, the wallet is not synced with the ECOin network', '/banking?filter=exchange')];
|
||||||
|
const hours = Number(ex.ecoTimeMs || 0) / 3600000;
|
||||||
|
const out = [
|
||||||
|
line(`Value of 1 ECO right now: ${Number(ex.ecoValue || 0).toFixed(4)} ECO per hour of UBI (1 ECO ≈ ${hours > 0 ? hours.toFixed(2) + ' hours' : 'n/a'})`, '/banking?filter=exchange'),
|
||||||
|
line(`ECOin total supply: ${fmtEco(ex.totalSupply)} · current supply: ${fmtEco(ex.currentSupply)}`, '/banking?filter=exchange'),
|
||||||
|
line(`Inflation: ${Number(ex.inflationFactor || 0).toFixed(2)}% annual · ${Number(ex.inflationMonthly || 0).toFixed(2)}% monthly`, '/banking?filter=exchange')
|
||||||
|
];
|
||||||
|
if (ex.pubsSupply != null) out.push(line(`PUBs pool supply: ${fmtEco(ex.pubsSupply)}${ex.holdingSupply != null ? ` · holding supply: ${fmtEco(ex.holdingSupply)}` : ''}`, '/banking?filter=exchange'));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async ubi({ models, me }) {
|
||||||
|
const out = [];
|
||||||
|
const data = await models.banking.listBanking('ubi', me).catch(() => null);
|
||||||
|
const summary = (data && data.summary) || {};
|
||||||
|
const bank = await models.banking.getBankingData(me).catch(() => ({}));
|
||||||
|
if (summary.epochId) out.push(line(`Current epoch: ${summary.epochId}`, '/banking?filter=ubi'));
|
||||||
|
if (summary.alreadyClaimed) out.push(line('This month: already claimed', '/banking?filter=ubi'));
|
||||||
|
else if (summary.alreadyRefused) out.push(line('This month: refused', '/banking?filter=ubi'));
|
||||||
|
else if (data && data.pendingUBI) out.push(line(`This month: ${fmtEco(data.pendingUBI.amount)} available to claim`, '/banking?filter=ubi'));
|
||||||
|
else out.push(line(`This month: nothing to claim yet (${summary.ubiAvailability === 'NO_FUNDS' ? 'PUB without funds' : 'no allocation'})`, '/banking?filter=ubi'));
|
||||||
|
if (bank.estimatedUBI != null) out.push(line(`Estimated UBI: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi'));
|
||||||
|
out.push(line(`Total received: ${fmtEco(bank.totalClaimed)}${bank.lastClaimedDate ? ` (last: ${fmtDate(bank.lastClaimedDate)})` : ''}`, '/banking?filter=ubi'));
|
||||||
|
const pubs = Array.isArray(data && data.ubiPubs) ? data.ubiPubs : [];
|
||||||
|
if (pubs.length) out.push(line(`PUBs announcing UBI: ${pubs.length}`, '/banking?filter=ubi'));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async karma({ models, me }) {
|
||||||
|
const bank = await models.banking.getBankingData(me).catch(() => ({}));
|
||||||
|
return [
|
||||||
|
line(`Karma score: ${Number(bank.karmaScore || 0).toFixed(2)}`, '/banking?filter=overview'),
|
||||||
|
line(`ECOin tax: ${fmtEco(bank.userEcoinTax)} · ARCH tax: ${fmtEco(bank.userArchTax)} · total: ${fmtEco(bank.userTotalTax)}`, '/banking?filter=taxes'),
|
||||||
|
line(`Estimated UBI after taxes: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi')
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async agenda({ models, hrefFor }) {
|
||||||
|
const items = await models.agenda.listAgenda('all').catch(() => []);
|
||||||
|
const now = Date.now();
|
||||||
|
const soon = (Array.isArray(items) ? items : [])
|
||||||
|
.map(it => ({ it, t: new Date(dateOf(it) || 0).getTime() }))
|
||||||
|
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000)
|
||||||
|
.sort((a, b) => a.t - b.t)
|
||||||
|
.slice(0, 8);
|
||||||
|
if (!soon.length) return [line('Agenda: nothing upcoming', '/agenda')];
|
||||||
|
return soon.map(({ it, t }) => line(`${fmtDate(t)} · ${titleOf(it) || it.type || 'item'}${it.type ? ` (${it.type})` : ''}`, hrefFor(it.type, it.id || it.tipId)));
|
||||||
|
},
|
||||||
|
async events({ models, hrefFor }) {
|
||||||
|
const events = await models.events.listAll(null, 'all').catch(() => []);
|
||||||
|
const now = Date.now();
|
||||||
|
const upcoming = (Array.isArray(events) ? events : [])
|
||||||
|
.map(e => ({ e, t: new Date(e.date || 0).getTime() }))
|
||||||
|
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000 && String(x.e.status || '').toUpperCase() !== 'CLOSED')
|
||||||
|
.sort((a, b) => a.t - b.t).slice(0, 8);
|
||||||
|
if (!upcoming.length) return [line('Events: none upcoming', '/events')];
|
||||||
|
return upcoming.map(({ e, t }) => line(`${fmtDate(t)} · ${titleOf(e)}${e.location ? ` · ${e.location}` : ''}${Number(e.price) > 0 ? ` · ${fmtEco(e.price)}` : ''}`, hrefFor('event', e.id)));
|
||||||
|
},
|
||||||
|
async tasks({ models, me, hrefFor }) {
|
||||||
|
const tasks = await models.tasks.listAll().catch(() => []);
|
||||||
|
const mine = (Array.isArray(tasks) ? tasks : []).filter(t => String(t.status || '').toUpperCase() === 'OPEN' && (t.author === me || (Array.isArray(t.assignees) && t.assignees.includes(me))));
|
||||||
|
if (!mine.length) return [line('Tasks: nothing open assigned to you', '/tasks')];
|
||||||
|
return mine.slice(0, 10).map(t => line(`${titleOf(t)}${t.endTime ? ` · due ${fmtDate(t.endTime)}` : ''}`, hrefFor('task', t.id)));
|
||||||
|
},
|
||||||
|
async transfers({ models, me, hrefFor }) {
|
||||||
|
const all = await models.transfers.listAll('all').catch(() => []);
|
||||||
|
const pending = (Array.isArray(all) ? all : []).filter(t => String(t.status || '').toUpperCase() === 'UNCONFIRMED' && (t.to === me || t.from === me) && !(Array.isArray(t.confirmedBy) && t.confirmedBy.includes(me)));
|
||||||
|
if (!pending.length) return [line('Transfers: nothing waiting for your confirmation', '/transfers?filter=pending')];
|
||||||
|
return pending.slice(0, 10).map(t => line(`${fmtEco(t.amount)} · ${t.concept || t.id} · ${t.from === me ? 'to' : 'from'} ${t.from === me ? t.to : t.from}`, hrefFor('transfer', t.id)));
|
||||||
|
},
|
||||||
|
async relationships({ models, me }) {
|
||||||
|
const users = await models.inhabitants.listInhabitants({ filter: 'all', includeInactive: true }).catch(() => []);
|
||||||
|
const followers = [], following = [], mutuals = [];
|
||||||
|
for (const u of (Array.isArray(users) ? users : []).slice(0, 400)) {
|
||||||
|
if (!u || !u.id || u.id === me) continue;
|
||||||
|
const rel = await models.friend.getRelationship(u.id).catch(() => null);
|
||||||
|
if (!rel) continue;
|
||||||
|
const name = u.name || u.id.slice(0, 9);
|
||||||
|
if (rel.following && rel.followsMe) mutuals.push(name);
|
||||||
|
else if (rel.following) following.push(name);
|
||||||
|
else if (rel.followsMe) followers.push(name);
|
||||||
|
}
|
||||||
|
const list = (arr) => arr.slice(0, 12).join(', ') + (arr.length > 12 ? '…' : '');
|
||||||
|
return [
|
||||||
|
line(`Mutual contacts: ${mutuals.length}${mutuals.length ? ` · ${list(mutuals)}` : ''}`, '/inhabitants?filter=mutuals'),
|
||||||
|
line(`You follow: ${following.length + mutuals.length}${following.length ? ` · also ${list(following)}` : ''}`, '/inhabitants?filter=following'),
|
||||||
|
line(`Follow you: ${followers.length + mutuals.length}${followers.length ? ` · also ${list(followers)}` : ''}`, '/inhabitants?filter=followers')
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async inbox({ sharedState }) {
|
||||||
|
return [
|
||||||
|
line(`Unread private messages: ${Number(sharedState.getInboxPmCount ? sharedState.getInboxPmCount() : 0) || 0}`, '/inbox?filter=pms'),
|
||||||
|
line(`Unread bot notifications: ${Number(sharedState.getInboxNotifCount ? sharedState.getInboxNotifCount() : 0) || 0}`, '/inbox?filter=notifications'),
|
||||||
|
line(`Unread mentions: ${Number(sharedState.getMentionsCount ? sharedState.getMentionsCount() : 0) || 0}`, '/mentions')
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async government({ models, me, nameOf }) {
|
||||||
|
const out = [];
|
||||||
|
const card = await models.parliament.getLatestGovernmentCard().catch(() => null);
|
||||||
|
if (card) {
|
||||||
|
const leader = card.leaderId ? await nameOf(card.leaderId) : '';
|
||||||
|
out.push(line(`Government: ${String(card.method || '').toUpperCase()}${leader ? ` · leader ${leader}` : ''}${card.end ? ` · cycle ends ${fmtDate(card.end)}` : ''}`, '/parliament'));
|
||||||
|
} else out.push(line('Government: no cycle published yet', '/parliament'));
|
||||||
|
if (models.larp && typeof models.larp.getUserHouse === 'function') {
|
||||||
|
const house = await models.larp.getUserHouse(me).catch(() => null);
|
||||||
|
if (house) out.push(line(`Your LARP house: ${house}`, '/larp'));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async emergencies({ models, me, hrefFor }) {
|
||||||
|
const all = await models.emergencies.listAll({ filter: 'all', viewerId: me }).catch(() => []);
|
||||||
|
const active = (Array.isArray(all) ? all : []).filter(e => String(e.status || '').toUpperCase() !== 'RESOLVED').slice(0, 8);
|
||||||
|
if (!active.length) return [line('Emergencies: none active', '/emergencies')];
|
||||||
|
return active.map(e => line(`${titleOf(e)}${e.status ? ` · ${e.status}` : ''}`, hrefFor('emergency', e.id)));
|
||||||
|
},
|
||||||
|
async jobs({ models, me, hrefFor }) {
|
||||||
|
const jobs = await models.jobs.listJobs('ALL', me).catch(() => []);
|
||||||
|
const open = (Array.isArray(jobs) ? jobs : []).filter(j => String(j.status || 'OPEN').toUpperCase() !== 'CLOSED').slice(0, 8);
|
||||||
|
if (!open.length) return [line('Jobs: no open offers', '/jobs')];
|
||||||
|
return open.map(j => line(`${titleOf(j)}${Number(j.salary) > 0 ? ` · ${fmtEco(j.salary)}` : ''}`, hrefFor('job', j.id)));
|
||||||
|
},
|
||||||
|
async market({ models, hrefFor }) {
|
||||||
|
const items = await models.market.listAllItems('all').catch(() => []);
|
||||||
|
const forSale = (Array.isArray(items) ? items : []).filter(i => String(i.status || '').toUpperCase() === 'FOR SALE' || String(i.status || '').toUpperCase() === 'OPEN' || !i.status).slice(0, 8);
|
||||||
|
if (!forSale.length) return [line('Market: nothing for sale right now', '/market')];
|
||||||
|
return forSale.map(i => line(`${titleOf(i)}${i.price != null ? ` · ${fmtEco(i.price)}` : ''}`, hrefFor('market', i.id)));
|
||||||
|
},
|
||||||
|
async tribes({ models, me, hrefFor }) {
|
||||||
|
const all = await models.tribes.listAll().catch(() => []);
|
||||||
|
const mine = (Array.isArray(all) ? all : []).filter(t => Array.isArray(t.members) && t.members.includes(me));
|
||||||
|
if (!mine.length) return [line('Tribes: you are not in any tribe', '/tribes')];
|
||||||
|
return mine.slice(0, 10).map(t => line(`${titleOf(t)} · ${t.members.length} members`, hrefFor('tribe', t.id)));
|
||||||
|
},
|
||||||
|
async network({ sharedState }) {
|
||||||
|
const ts = sharedState.getLastSyncTs ? sharedState.getLastSyncTs() : null;
|
||||||
|
return [
|
||||||
|
line(`Inhabitants: ${Number(sharedState.getInhabitantCount ? sharedState.getInhabitantCount() : 0) || 0}`, '/inhabitants'),
|
||||||
|
line(`Tribes: ${Number(sharedState.getTribesCount ? sharedState.getTribesCount() : 0) || 0}`, '/tribes'),
|
||||||
|
line(`Peers online: ${Number(sharedState.getOnlinePeerCount ? sharedState.getOnlinePeerCount() : 0) || 0}`, '/peers'),
|
||||||
|
line(`Last sync: ${ts ? fmtDate(ts) : 'never'}`, '/peers'),
|
||||||
|
line(`ECO value (1h): ${sharedState.getEcoValue && sharedState.getEcoValue() ? sharedState.getEcoValue() + ' ECO' : 'unknown (wallet not synced)'}`, '/banking?filter=exchange')
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async me({ me, nameOf, version }) {
|
||||||
|
const name = await nameOf(me);
|
||||||
|
return [
|
||||||
|
line(`You are ${name} (${me})`, '/profile'),
|
||||||
|
line(`Oasis version: ${version || 'unknown'}`, '/settings')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listOf = (x) => Array.isArray(x) ? x : (x && Array.isArray(x.items) ? x.items : []);
|
||||||
|
const isOpen = (x) => { const s = String(x && x.status || 'OPEN').toUpperCase(); return !['CLOSED', 'RESOLVED', 'EXPIRED', 'DISSOLVED', 'CANCELLED', 'DELETED'].includes(s); };
|
||||||
|
const few = (arr, n = 8) => arr.slice(0, n);
|
||||||
|
|
||||||
|
Object.assign(RUNNERS, {
|
||||||
|
async shops({ models, hrefFor }) {
|
||||||
|
const items = listOf(await models.shops.listAllProducts({ filter: 'all' }).catch(() => []));
|
||||||
|
const open = few(items.filter(isOpen));
|
||||||
|
if (!open.length) return [line('Shops: no products on sale right now', '/shops')];
|
||||||
|
return open.map(p => line(`${titleOf(p)}${p.price != null ? ` · ${fmtEco(p.price)}` : ''}${Number(p.stock) > 0 ? ` · stock ${p.stock}` : ''}`, hrefFor('shopProduct', p.id || p.key)));
|
||||||
|
},
|
||||||
|
async school({ models, me, hrefFor }) {
|
||||||
|
const courses = listOf(await models.school.listCourses('ALL', me, {}).catch(() => []));
|
||||||
|
const mine = courses.filter(c => c.author === me || (Array.isArray(c.students) && c.students.includes(me)));
|
||||||
|
const open = few(courses.filter(isOpen));
|
||||||
|
const out = [line(`School: ${courses.length} courses, ${mine.length} of them yours (teaching or enrolled)`, '/school')];
|
||||||
|
return out.concat(open.map(c => line(`${titleOf(c)}${Number(c.price) > 0 ? ` · ${fmtEco(c.price)}` : ' · free'}${Array.isArray(c.students) ? ` · ${c.students.length} students` : ''}`, hrefFor('schoolCourse', c.id))));
|
||||||
|
},
|
||||||
|
async projects({ models, hrefFor }) {
|
||||||
|
const projects = listOf(await models.projects.listProjects('all', {}).catch(() => []));
|
||||||
|
const open = few(projects.filter(isOpen));
|
||||||
|
if (!open.length) return [line('Projects: none looking for funding right now', '/projects')];
|
||||||
|
return open.map(p => line(`${titleOf(p)}${p.goal != null ? ` · goal ${fmtEco(p.goal)}` : ''}${p.pledged != null ? ` · pledged ${fmtEco(p.pledged)}` : ''}${p.deadline ? ` · until ${fmtDate(p.deadline)}` : ''}`, hrefFor('project', p.id)));
|
||||||
|
},
|
||||||
|
async housing({ models, me, hrefFor }) {
|
||||||
|
const items = listOf(await models.housing.listHousing('ALL', me, {}).catch(() => []));
|
||||||
|
const open = few(items.filter(isOpen));
|
||||||
|
if (!open.length) return [line('Housing: no places offered right now', '/housing')];
|
||||||
|
return open.map(h => line(`${titleOf(h)}${h.price != null ? ` · ${fmtEco(h.price)}` : ''}${h.location ? ` · ${h.location}` : ''}`, hrefFor('housing', h.id)));
|
||||||
|
},
|
||||||
|
async industry({ models, me, hrefFor }) {
|
||||||
|
const facilities = listOf(await models.industry.listFacilities('ALL').catch(() => []));
|
||||||
|
const mine = facilities.filter(f => Array.isArray(f.members) && f.members.includes(me));
|
||||||
|
const out = [line(`Industry: ${facilities.length} facilities, you belong to ${mine.length}`, '/industry')];
|
||||||
|
return out.concat(few(facilities.filter(isOpen)).map(f => line(`${titleOf(f)}${Array.isArray(f.members) ? ` · ${f.members.length} members` : ''}`, hrefFor('industry', f.id))));
|
||||||
|
},
|
||||||
|
async campaigns({ models, me, hrefFor }) {
|
||||||
|
const items = listOf(await models.campaigns.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||||
|
const open = few(items.filter(isOpen));
|
||||||
|
if (!open.length) return [line('Campaigns: none open right now', '/campaigns')];
|
||||||
|
return open.map(c => line(`${titleOf(c)}${Array.isArray(c.signers) ? ` · ${c.signers.length} signatures` : ''}${c.goal ? ` of ${c.goal}` : ''}`, hrefFor('campaign', c.id)));
|
||||||
|
},
|
||||||
|
async logistics({ models, hrefFor }) {
|
||||||
|
const routes = listOf(await models.logistics.listAll({ filter: 'all' }).catch(() => []));
|
||||||
|
const open = few(routes.filter(isOpen));
|
||||||
|
if (!open.length) return [line('Logistics: no open routes right now', '/logistics')];
|
||||||
|
return open.map(r => line(`${titleOf(r)}${r.price != null && Number(r.price) > 0 ? ` · ${Number(r.price)} ${r.priceType === 'TIME' ? 'h' : 'ECO'}` : ''}${r.status ? ` · ${r.status}` : ''}`, hrefFor('logisticsRoute', r.id)));
|
||||||
|
},
|
||||||
|
async calendars({ models, me, hrefFor }) {
|
||||||
|
const cals = listOf(await models.calendars.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||||
|
if (!cals.length) return [line('Calendars: you are not in any calendar', '/calendars')];
|
||||||
|
const now = Date.now();
|
||||||
|
const soon = cals.map(c => ({ c, t: new Date(dateOf(c) || 0).getTime() })).filter(x => Number.isFinite(x.t) && x.t >= now - 3600000).sort((a, b) => a.t - b.t);
|
||||||
|
const out = [line(`Calendars: ${cals.length}`, '/calendars')];
|
||||||
|
return out.concat(few(soon).map(({ c, t }) => line(`${fmtDate(t)} · ${titleOf(c)}`, hrefFor('calendar', c.id))));
|
||||||
|
},
|
||||||
|
async votes({ models, hrefFor }) {
|
||||||
|
const votes = listOf(await models.votes.listAll('all').catch(() => []));
|
||||||
|
const polls = listOf(await models.polls.listAll('ALL', {}).catch(() => []));
|
||||||
|
const openV = few(votes.filter(isOpen), 5);
|
||||||
|
const openP = few(polls.filter(isOpen), 5);
|
||||||
|
if (!openV.length && !openP.length) return [line('Votes: nothing open to vote on right now', '/votes')];
|
||||||
|
return openV.map(v => line(`Votation: ${titleOf(v)}${v.deadline ? ` · until ${fmtDate(v.deadline)}` : ''}`, hrefFor('vote', v.id)))
|
||||||
|
.concat(openP.map(p => line(`Poll: ${titleOf(p)}`, hrefFor('poll', p.id))));
|
||||||
|
},
|
||||||
|
async wiki({ models, me, hrefFor }) {
|
||||||
|
const pages = listOf(await models.wiki.listPages({ viewerId: me }).catch(() => []));
|
||||||
|
if (!pages.length) return [line('Wiki: no pages yet', '/wiki')];
|
||||||
|
const out = [line(`Wiki: ${pages.length} pages`, '/wiki')];
|
||||||
|
return out.concat(few(pages, 6).map(p => line(titleOf(p), hrefFor('wikiPage', p.id || p.slug))));
|
||||||
|
},
|
||||||
|
async chats({ models, me, hrefFor }) {
|
||||||
|
const chats = listOf(await models.chats.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||||
|
const mine = chats.filter(c => c.author === me || (Array.isArray(c.members) && c.members.includes(me)) || (Array.isArray(c.participants) && c.participants.includes(me)));
|
||||||
|
if (!mine.length) return [line('Chats: you are not in any chat', '/chats')];
|
||||||
|
return few(mine).map(c => line(`${titleOf(c)}${Array.isArray(c.members) ? ` · ${c.members.length} members` : ''}`, hrefFor('chat', c.key || c.id)));
|
||||||
|
},
|
||||||
|
async mailing({ models, hrefFor }) {
|
||||||
|
const lists = listOf(await models.mailing.listAll({ filter: 'all' }).catch(() => []));
|
||||||
|
const active = few(lists.filter(l => String(l.status || 'ACTIVE').toUpperCase() === 'ACTIVE'));
|
||||||
|
if (!active.length) return [line('Mailing lists: none active', '/mailing')];
|
||||||
|
return active.map(l => line(`${titleOf(l)}${Array.isArray(l.subscribers) ? ` · ${l.subscribers.length} subscribers` : ''}`, hrefFor('mailingList', l.id)));
|
||||||
|
},
|
||||||
|
async podcasts({ models, me, hrefFor }) {
|
||||||
|
const channels = listOf(await models.podcasts.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||||
|
if (!channels.length) return [line('Podcasts: none yet', '/podcasts')];
|
||||||
|
return few(channels).map(c => line(`${titleOf(c)}${Array.isArray(c.episodes) ? ` · ${c.episodes.length} episodes` : ''}`, hrefFor('podcast', c.id)));
|
||||||
|
},
|
||||||
|
async media({ models }) {
|
||||||
|
const count = async (m) => m && typeof m.listAll === 'function' ? listOf(await m.listAll('all').catch(() => [])).length : 0;
|
||||||
|
const [audios, videos, images, documents, torrents, files] = await Promise.all([count(models.audios), count(models.videos), count(models.images), count(models.documents), count(models.torrents), count(models.files)]);
|
||||||
|
return [
|
||||||
|
line(`Audios: ${audios}`, '/audios'), line(`Videos: ${videos}`, '/videos'), line(`Images: ${images}`, '/images'),
|
||||||
|
line(`Documents: ${documents}`, '/documents'), line(`Files: ${files}`, '/files'), line(`Torrents: ${torrents}`, '/torrents')
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async blogs({ models }) {
|
||||||
|
const blogs = listOf(await models.blog.listAll('all').catch(() => []));
|
||||||
|
if (!blogs.length) return [line('Blogs: no entries yet', '/blogs')];
|
||||||
|
const latest = blogs.slice().sort((a, b) => new Date(dateOf(b) || 0) - new Date(dateOf(a) || 0));
|
||||||
|
return [line(`Blogs: ${blogs.length} entries`, '/blogs')].concat(few(latest, 6).map(b => line(`${titleOf(b) || 'Untitled'}${dateOf(b) ? ` · ${fmtDate(dateOf(b))}` : ''}`, b.key || b.id ? `/blogs/${encodeURIComponent(b.key || b.id)}` : '/blogs')));
|
||||||
|
},
|
||||||
|
async reports({ models, hrefFor }) {
|
||||||
|
const reports = listOf(await models.reports.listAll().catch(() => []));
|
||||||
|
const open = few(reports.filter(isOpen));
|
||||||
|
if (!open.length) return [line('Reports: none open', '/reports')];
|
||||||
|
return open.map(r => line(`${titleOf(r)}${r.status ? ` · ${r.status}` : ''}`, hrefFor('report', r.id)));
|
||||||
|
},
|
||||||
|
async games({ models, nameOf }) {
|
||||||
|
const hall = listOf(await models.games.getHallOfFame().catch(() => []));
|
||||||
|
if (!hall.length) return [line('Games: no scores yet', '/games')];
|
||||||
|
const out = [];
|
||||||
|
for (const h of few(hall, 6)) out.push(line(`${h.game || 'game'} · ${await nameOf(h.author)} · ${h.score != null ? h.score : ''}`, '/games'));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async tags({ models }) {
|
||||||
|
const tags = listOf(await models.tags.listTags('all', '').catch(() => []));
|
||||||
|
if (!tags.length) return [line('Tags: none yet', '/tags')];
|
||||||
|
const top = tags.slice().sort((a, b) => (Number(b.count) || 0) - (Number(a.count) || 0));
|
||||||
|
return [line(`Tags in use: ${tags.length}`, '/tags')].concat(few(top, 10).map(t => line(`#${t.name || t.tag || t.id || t}${t.count != null ? ` · ${t.count}` : ''}`, `/tags/${encodeURIComponent(String(t.name || t.tag || t.id || t))}`)));
|
||||||
|
},
|
||||||
|
async favorites({ models }) {
|
||||||
|
const res = await models.favorites.listAll({}).catch(() => null);
|
||||||
|
const counts = res && res.counts && typeof res.counts === 'object' ? res.counts : {};
|
||||||
|
const keys = Object.keys(counts).filter(k => Number(counts[k]) > 0);
|
||||||
|
if (!keys.length) return [line('Favorites: nothing starred yet', '/favorites')];
|
||||||
|
return keys.map(k => line(`${k}: ${counts[k]}`, `/favorites?filter=${encodeURIComponent(k)}`));
|
||||||
|
},
|
||||||
|
async modules({ config }) {
|
||||||
|
const mods = (config && config.modules) || {};
|
||||||
|
const on = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] === 'on').map(k => k.slice(0, -3));
|
||||||
|
const off = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] !== 'on').map(k => k.slice(0, -3));
|
||||||
|
return [line(`Enabled modules (${on.length}): ${on.join(', ')}`, '/modules'), line(`Disabled modules (${off.length}): ${off.join(', ') || 'none'}`, '/modules')];
|
||||||
|
},
|
||||||
|
async help() {
|
||||||
|
return [
|
||||||
|
line('I answer from the live data of this node: your funds and ECO value, your UBI and karma, your agenda, events, tasks, transfers, who follows you, your inbox and mentions, the government, emergencies, jobs, market, shops, school, projects, housing, industry, campaigns, logistics, calendars, votes, wiki, chats, mailing lists, podcasts, media, blogs, reports, games, tags, favorites, the modules and the network status.', '/ai'),
|
||||||
|
line('Anything else I answer with the model, helped by the answers the network approved. Rate my answers with the stars so they are shared and I learn.', '/ai')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (key, deps) => {
|
||||||
|
const runner = RUNNERS[key];
|
||||||
|
if (!runner) return null;
|
||||||
|
try { return await runner(deps); } catch (_) { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { INTENTS, detect, detectByKeywords, run, RUNNERS };
|
||||||
2677
src/AI/package-lock.json
generated
Normal file
18
src/AI/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@krakenslab/oasis-ai",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "AI stack of Oasis (assistant and embeddings); installed only when AI features are enabled",
|
||||||
|
"dependencies": {
|
||||||
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"onnxruntime-node": "1.19.2"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"node-llama-cpp": "^3.10.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"onnxruntime-node": "1.19.2",
|
||||||
|
"onnxruntime-common": "1.19.2",
|
||||||
|
"onnxruntime-web": "1.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
275
src/AI/routes_index.js
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
{ path: '/blogs', mod: 'blogsMod', description: 'blogs, blog posts, articles, written entries, long posts, public writing' },
|
||||||
|
{ path: '/polls', mod: 'pollsMod', description: 'polls, survey, ask a question with options, multiple choice, anonymous poll, count answers' },
|
||||||
|
{ path: '/polls?filter=CREATE', mod: 'pollsMod', description: 'create a poll, new survey, ask the network with options' },
|
||||||
|
{ path: '/blogs?filter=CREATE', mod: 'blogsMod', description: 'write a blog, publish a blog, compose an article, new blog entry' },
|
||||||
|
{ path: '/blogs?filter=TOP', mod: 'blogsMod', description: 'best blogs, most commented blogs, most valued blog entries' },
|
||||||
|
{ path: '/feed', mod: 'feedMod', description: 'feed, microblog, opinions, share thoughts, vote on posts, refeeds' },
|
||||||
|
{ path: '/forum', mod: 'forumMod', description: 'forum, discussions, threads, debates, conversation by category' },
|
||||||
|
{ path: '/inhabitants', mod: null, description: 'inhabitants, users, people, profiles, contacts, follow, block' },
|
||||||
|
{ path: '/inhabitants?filter=SUGGESTED', mod: null, description: 'suggested inhabitants, recommendations, who to follow, similar people, people you might know, friend suggestions' },
|
||||||
|
{ path: '/data', mod: null, description: 'matches, match skills, cross-data, big data of the network, affinities, cohesion coefficient, who shares my expertise, skill overlap, algorithmic suggestions' },
|
||||||
|
{ path: '/data?filter=INHABITANTS', mod: null, description: 'people with the same skills, professional matches, find collaborators' },
|
||||||
|
{ path: '/data?filter=JOBS', mod: null, description: 'jobs that match my skills, work suited to my profile' },
|
||||||
|
{ path: '/inhabitants?filter=CVs', mod: null, description: 'curriculums, CVs, resumes, professional profiles, people with experience, find expertise' },
|
||||||
|
{ path: '/inhabitants?filter=TOP%20KARMA', mod: null, description: 'top karma, most active inhabitants, highest reputation, leaderboard' },
|
||||||
|
{ path: '/inhabitants?filter=TOP%20ECO', mod: null, description: 'top eco, most ecological, least carbon footprint, sustainable users, efficient inhabitants' },
|
||||||
|
{ path: '/inhabitants?filter=TOP%20ACTIVITY', mod: null, description: 'top activity, most recently active inhabitants, fresh users, recently online' },
|
||||||
|
{ path: '/inhabitants?filter=contacts', mod: null, description: 'my contacts, who I follow, my network, friends list, mutuals' },
|
||||||
|
{ path: '/inhabitants?filter=GALLERY', mod: null, description: 'gallery of inhabitants, all avatars, visual list, photos' },
|
||||||
|
{ path: '/tribes', mod: 'tribesMod', description: 'tribes, groups, communities, private rooms, sub-tribes, governance, public tribes, browse all visible tribes' },
|
||||||
|
{ path: '/tribes?filter=mine', mod: 'tribesMod', description: 'my tribes, tribes I created, tribes I authored, my groups' },
|
||||||
|
{ path: '/tribes?filter=membership', mod: 'tribesMod', description: 'tribes I am a member of, my memberships, joined tribes, where I belong' },
|
||||||
|
{ path: '/larp', mod: 'larpMod', description: 'larp, role playing, nine houses, academia, solaris, arrakis, terraverde, unsystem, dogma, helix, quark, hermandad, governance cycle, ruling house, will test' },
|
||||||
|
{ path: '/larp?filter=houses', mod: 'larpMod', description: 'list of houses, all houses, browse houses, every house, choose a house, house directory, house grid' },
|
||||||
|
{ path: '/larp?filter=rules', mod: 'larpMod', description: 'larp faq, rules, governance wall, starting house, will test, invitation code, larp emblem' },
|
||||||
|
{ path: '/larp/academia', mod: 'larpMod', description: 'ACADEMIA house, aca, education, teachers, coordinators, newcomers, talent discovery, selection process, nosce te ipsum, take will test, leave house, return to academia' },
|
||||||
|
{ path: '/larp/solaris', mod: 'larpMod', description: 'SolarIS house, sol, governance, law, diplomacy, politics, rulers, lawyers, diplomats, communication, mediation' },
|
||||||
|
{ path: '/larp/arrakis', mod: 'larpMod', description: 'ARRakis house, arr, engineers, scientists, technicians, technology, invention, problem solving, building, prototypes, repairs, automation' },
|
||||||
|
{ path: '/larp/terraverde', mod: 'larpMod', description: 'TERRA.VErDE house, ter, farmers, ecologists, doctors, nutritionists, healthcare, food, nature, climate, biosphere, regeneration' },
|
||||||
|
{ path: '/larp/unsystem', mod: 'larpMod', description: 'UNSYSTem house, uns, chaos agents, trolls, punks, sabotage, tactical chaos, provocation, anti-system' },
|
||||||
|
{ path: '/larp/dogma', mod: 'larpMod', description: 'DogmA house, dog, thinkers, journalists, philosophers, information control, knowledge, archive, narratives, ai, memory' },
|
||||||
|
{ path: '/larp/helix', mod: 'larpMod', description: 'HeliX house, hlx, clowns, influencers, musicians, priests, entertainment, culture, humor, celebration, ritual, joy, festival' },
|
||||||
|
{ path: '/larp/quark', mod: 'larpMod', description: 'QuarK house, quk, athletes, soldiers, street people, protection, security, defense, survival, family, mutual aid' },
|
||||||
|
{ path: '/larp/hermandad', mod: 'larpMod', description: 'HERmanDAD house, hrm, architects, builders, investors, industrialists, construction, development, sustainability, logistics, production, supply chain' },
|
||||||
|
{ path: '/larp/test', mod: 'larpMod', description: 'will test, psychological test, take house test, assigned to house, find my house, profile questions' },
|
||||||
|
{ path: '/chats', mod: 'chatsMod', description: 'chats, messaging, encrypted rooms, group conversations' },
|
||||||
|
{ path: '/pads', mod: 'padsMod', description: 'pads, collaborative editor, shared notes, encrypted documents' },
|
||||||
|
{ path: '/rooms', mod: 'roomsMod', description: 'rooms, audio rooms, voice rooms, group audio, talk together, live conversation, salas, salas de audio' },
|
||||||
|
{ path: '/wiki', mod: 'wikiMod', description: 'wiki, knowledge base, shared pages, articles, documentation, glossary, versioned pages' },
|
||||||
|
{ path: '/campaigns', mod: 'campaignsMod', description: 'campaigns, petitions, signatures, sign a petition, collect signatures, change.org, goal, supporters' },
|
||||||
|
{ path: '/podcasts', mod: 'podcastsMod', description: 'podcasts, podcast channels, episodes, audio series, video series, listen, subscribe to a podcast, download a whole podcast' },
|
||||||
|
{ path: '/logistics', mod: 'logisticsMod', description: 'logistics, trips, rides, carpool, ride sharing, shipments, deliveries, carry goods, move something, transport, book a seat, route ratings' },
|
||||||
|
{ path: '/mailing', mod: 'mailingMod', description: 'mailing lists, mailing list, mailman, newsletter, list of subscribers, write to a list, list archive, threads' },
|
||||||
|
{ path: '/emergencies', mod: 'emergenciesMod', description: 'emergencies, alerts, warnings, weather warning, power outage, water cut, health alert, security, lost and found, neighborhood notice' },
|
||||||
|
{ path: '/calendars', mod: 'calendarsMod', description: 'calendar, events by date, schedule, reminders, recurring dates' },
|
||||||
|
{ path: '/maps', mod: 'mapsMod', description: 'maps, locations, markers, geography, places' },
|
||||||
|
{ path: '/events', mod: 'eventsMod', description: 'events, agenda, meetups, gatherings, RSVP' },
|
||||||
|
{ path: '/agenda', mod: 'agendaMod', description: 'agenda, scheduled items, upcoming, my dates, my tasks events transfers projects jobs market reports tribes' },
|
||||||
|
{ path: '/agenda?filter=discarded', mod: 'agendaMod', description: 'discarded agenda items, archived agenda, removed from agenda, hidden tasks' },
|
||||||
|
{ path: '/tasks', mod: 'tasksMod', description: 'tasks, todo, assignments, work items, priorities' },
|
||||||
|
{ path: '/projects', mod: 'projectsMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
|
||||||
|
{ path: '/school', mod: 'schoolMod', description: 'school, courses, classes, lessons, learning, teach, study, education, enroll, students, certificates, diploma pdf, exams, course invite code, join course with code' },
|
||||||
|
{ path: '/industry', mod: 'industryMod', description: 'industry, facilities, factories, workshops, means of manufacturing, commons, cooperatives, blueprints, builds' },
|
||||||
|
{ path: '/industry?filter=BLUEPRINTS', mod: 'industryMod', description: 'blueprints, designs, recipes, open hardware designs, copyleft designs, bill of materials, materials list' },
|
||||||
|
{ path: '/industry?filter=BUILDS', mod: 'industryMod', description: 'builds, manufacturing runs, fabrication orders, batches, build status, contributions to a build' },
|
||||||
|
{ path: '/industry?filter=MEMBER', mod: 'industryMod', description: 'facilities I belong to, my industry memberships, where I am a worker' },
|
||||||
|
{ path: '/industry?filter=MINE', mod: 'industryMod', description: 'facilities I steward, industries I created' },
|
||||||
|
{ path: '/industry?filter=ACTIVE', mod: 'industryMod', description: 'working facilities, active factories, industries manufacturing right now' },
|
||||||
|
{ path: '/industry?filter=PAUSED', mod: 'industryMod', description: 'paused facilities, halted manufacturing, stopped industries' },
|
||||||
|
{ path: '/industry?filter=DISSOLVED', mod: 'industryMod', description: 'dissolved facilities, closed factories, ended cooperatives' },
|
||||||
|
{ path: '/industry?filter=RULES', mod: 'industryMod', description: 'industry rules, how facilities work, stewardship, quorum, majority, karma shares, distribution' },
|
||||||
|
{ path: '/housing', mod: 'housingMod', description: 'housing, homes, flats, apartments, rooms, lodging, accommodation, real estate, places to live' },
|
||||||
|
{ path: '/housing?filter=SALE', mod: 'housingMod', description: 'homes for sale, property for sale, real estate on sale, houses being sold' },
|
||||||
|
{ path: '/housing?filter=RENT', mod: 'housingMod', description: 'places to rent, rentals, monthly rent, flat to let, room to rent' },
|
||||||
|
{ path: '/housing?filter=COUCHSURFING', mod: 'housingMod', description: 'couchsurfing, free stay, hospitality, host me, sofa to sleep, guest room offered for free' },
|
||||||
|
{ path: '/housing?filter=REQUESTED', mod: 'housingMod', description: 'places I requested, my housing requests, stays I asked for' },
|
||||||
|
{ path: '/housing?filter=MINE', mod: 'housingMod', description: 'my places, housing listings I published, my properties' },
|
||||||
|
{ path: '/jobs', mod: 'jobsMod', description: 'jobs, work, hiring, salaries, vacancies, applications' },
|
||||||
|
{ path: '/market', mod: 'marketMod', description: 'market, marketplace, buy, sell, items, auctions, ECO' },
|
||||||
|
{ path: '/shops', mod: 'shopsMod', description: 'shops, stores, products, ecommerce, vendors' },
|
||||||
|
{ path: '/banking', mod: 'bankingMod', description: 'banking, wallet, ECO balance, send money, transfers, payments, UBI claim, karma score, eco tax penalty, ECOin value' },
|
||||||
|
{ path: '/transfers', mod: 'transfersMod', description: 'transfers, payments, money movements, ECO transactions, history, smart contracts, contract PDF, export contract' },
|
||||||
|
{ path: '/wallet', mod: 'walletMod', description: 'wallet, ECOin address, send and receive, QR code, balance' },
|
||||||
|
{ path: '/parliament', mod: 'parliamentMod', description: 'parliament, governance, government, proposals, laws, leaders, voting' },
|
||||||
|
{ path: '/courts', mod: 'courtsMod', description: 'courts, judges, accusations, mediators, justice, disputes' },
|
||||||
|
{ path: '/votes', mod: 'votesMod', description: 'votes, votations, ballots, decisions, polling, voting, polls, surveys, multi-option votes' },
|
||||||
|
{ path: '/opinions', mod: 'opinionsMod', description: 'opinions, reactions, ratings, sentiment, expressing views, interesting useful funny boring sad joyful angry confused inspiring frustrating curious sympathetic challenged surprised exited categories' },
|
||||||
|
{ path: '/trending', mod: 'trendingMod', description: 'trending, popular, hot, top voted, what is being discussed, most opinions, top spread, viral content' },
|
||||||
|
{ path: '/activity', mod: null, description: 'activity, recent actions, what is happening, my history, others actions, feed votes, opinions activity, spread activity' },
|
||||||
|
{ path: '/activity?filter=mine', mod: null, description: 'my activity, my actions, what I did, my history, what I published' },
|
||||||
|
{ path: '/activity?filter=today', mod: null, description: 'today activity, last 24h, recent today' },
|
||||||
|
{ path: '/reports', mod: 'reportsMod', description: 'reports, bug reports, abuse, incidents, severity, confirmations' },
|
||||||
|
{ path: '/audios', mod: 'audiosMod', description: 'audios, music, podcasts, voice recordings, sound files' },
|
||||||
|
{ path: '/audios?filter=bcs', mod: 'audiosMod', description: 'BCS audios, blockchain sonification audios, melody compositions published by inhabitants, transcode source audio, embedded steganography' },
|
||||||
|
{ path: '/audios?filter=favorites', mod: 'audiosMod', description: 'favorite audios, starred audios, saved music' },
|
||||||
|
{ path: '/videos', mod: 'videosMod', description: 'videos, films, clips, recordings, watch' },
|
||||||
|
{ path: '/images', mod: 'imagesMod', description: 'images, photos, pictures, gallery, memes' },
|
||||||
|
{ path: '/documents', mod: 'docsMod', description: 'documents, PDFs, files, papers, references' },
|
||||||
|
{ path: '/bookmarks', mod: 'bookmarksMod', description: 'bookmarks, links, saved websites, favorites' },
|
||||||
|
{ path: '/files', mod: 'filesMod', description: 'files, any file type, uploads, downloads, binaries, archives' },
|
||||||
|
{ path: '/torrents', mod: 'torrentsMod', description: 'torrents, magnet links, file sharing, downloads' },
|
||||||
|
{ path: '/tags', mod: 'tagsMod', description: 'tags, hashtags, topics, categories, labels' },
|
||||||
|
{ path: '/search', mod: null, description: 'search, find, query, lookup' },
|
||||||
|
{ path: '/inbox', mod: null, description: 'inbox, notifications, mentions, emergencies, messages addressed to me, received PM' },
|
||||||
|
{ path: '/inbox?filter=sent', mod: null, description: 'sent messages, outgoing PM, my sent private messages, what I wrote' },
|
||||||
|
{ path: '/inbox?filter=reminders', mod: null, description: 'reminders, task reminders, calendar reminders, automatic notifications' },
|
||||||
|
{ path: '/phone', mod: 'phoneMod', description: 'phone, call, calls, voice call, call someone, ring, telephone, voip, audio call, private audio message, PaM, voicemail, voice message, missed calls, call history' },
|
||||||
|
{ path: '/inbox?filter=notifications', mod: null, description: 'bot notifications, BankingBot, PoliticalBot, MarketBot, notices per bot, unread notices' },
|
||||||
|
{ path: '/inbox?filter=archived', mod: null, description: 'archived messages, archived PMs, put away conversations' },
|
||||||
|
{ path: '/pm', mod: null, description: 'private messages, direct messages, DMs, encrypted PM, compose new PM' },
|
||||||
|
{ path: '/mentions', mod: null, description: 'mentions, who mentioned me, tagged me, my mentions, posts mentioning me, tribe mentions' },
|
||||||
|
{ path: '/games', mod: 'gamesMod', description: 'games, play, mini-games, scoring, fun' },
|
||||||
|
{ path: '/ai', mod: 'aiMod', description: '42, AI assistant, ask the AI, chat with AI, oasis assistant, artificial intelligence, ai help, ai answers' },
|
||||||
|
{ path: '/pixelia', mod: 'pixeliaMod', description: 'pixelia, pixel canvas, draw, collaborative pixel art' },
|
||||||
|
{ path: '/cv', mod: null, description: 'cv, curriculum, resume, my profile, skills, experiences' },
|
||||||
|
{ path: '/backup', mod: 'backupMod', description: 'backup, export keys, import keys, recovery kit, full copy of my data, restore on another device, encrypted backup' },
|
||||||
|
{ path: '/cipher', mod: 'cipherMod', description: 'cipher, encrypt, decrypt, password, vault' },
|
||||||
|
{ path: '/dev', mod: 'devMod', description: 'source code, oasis codebase, file tree, browse files, read the code' },
|
||||||
|
{ path: '/dev/search', mod: 'devMod', description: 'grep the codebase, find a function, find a string in source files' },
|
||||||
|
{ path: '/dev/map', mod: 'devMod', description: 'module map, which file implements a module, models views routes tests' },
|
||||||
|
{ path: '/stats', mod: null, description: 'stats, statistics, KPIs, metrics, dashboard, carbon footprint' },
|
||||||
|
{ path: '/peers', mod: null, description: 'peers, connections, network, nodes, who am I connected to, LAN, refresh discovery, export peer list, import peer list, remove idle' },
|
||||||
|
{ path: '/invites', mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB, federations, federated networks, import pubs, export pubs, unreachable pubs, redeem tribe invite code, join tribe by code, redeem course code, join school course by code, accept invitation' },
|
||||||
|
{ path: '/graphos', mod: 'graphosMod', description: 'graphos, network map, visualization, relationship graph' },
|
||||||
|
{ path: '/modules', mod: null, description: 'modules, features, enable disable plugins, settings' },
|
||||||
|
{ path: '/settings', mod: null, description: 'settings, preferences, language, theme, configuration' },
|
||||||
|
{ path: '/favorites', mod: 'favoritesMod', description: 'favorites, starred items, saved content, my bookmarks audios videos images documents files pads rooms chats calendars maps torrents' },
|
||||||
|
{ path: '/favorites?filter=recent', mod: 'favoritesMod', description: 'recent favorites, latest starred items' },
|
||||||
|
{ path: '/logs', mod: 'logsMod', description: 'logs, life log, personal records, journal, experiences' },
|
||||||
|
{ path: '/melody', mod: 'melodyMod', description: 'melody, sound of my blockchain, music, generate sound, audio of blocks, sonification, publish BCS, hidden message, steganography, embed text in waveform' },
|
||||||
|
{ path: '/melody?filter=all', mod: 'melodyMod', description: 'BCS from other inhabitants, all blockchain compositions, listen to peers melodies, transcode audio of others' },
|
||||||
|
{ path: '/profile', mod: null, description: 'my profile, my avatar, my page, my identity, my data, my clearnet link' },
|
||||||
|
{ path: '/profile/edit', mod: null, description: 'edit profile, edit avatar, change name, change description, visibility prefs, sensors, eco tax toggle, clearnet toggle, GPG fingerprint' },
|
||||||
|
{ path: '/blockexplorer', mod: null, description: 'blockexplorer, blockchain, blockchain explorer, blocks, ledger, chain, carbon footprint per block, chain history' },
|
||||||
|
{ path: '/fediverse', mod: 'fediverseMod', description: 'multiverse, fediverse, mastodon, telegram, activitypub, external social networks, federated timeline, telegram chats and channels, cross-post, fediverse handle, follow external accounts' },
|
||||||
|
{ path: '/stats?filter=ALL', mod: null, description: 'global stats, network kpis, total carbon footprint, total inhabitants, network size' },
|
||||||
|
{ path: '/stats?filter=MINE', mod: null, description: 'my stats, my carbon footprint, my activity numbers, personal kpis' },
|
||||||
|
{ path: '/tribes/create', mod: 'tribesMod', description: 'create tribe, new tribe, new group, start community, create private room' },
|
||||||
|
{ path: '/chats', mod: 'chatsMod', description: 'create chat, new chat, start conversation, new encrypted room' },
|
||||||
|
{ path: '/pads', mod: 'padsMod', description: 'create pad, new pad, new collaborative document, start shared note' },
|
||||||
|
{ path: '/rooms?filter=create', mod: 'roomsMod', description: 'create room, new room, open an audio room, start a voice room, crear sala' },
|
||||||
|
{ path: '/wiki?filter=create', mod: 'wikiMod', description: 'create wiki page, new wiki page, write article, document knowledge' },
|
||||||
|
{ path: '/campaigns?filter=CREATE', mod: 'campaignsMod', description: 'start a campaign, create a petition, collect signatures for a cause' },
|
||||||
|
{ path: '/podcasts?filter=CREATE', mod: 'podcastsMod', description: 'create podcast, new podcast channel, start a podcast, publish episodes' },
|
||||||
|
{ path: '/logistics?filter=CREATE', mod: 'logisticsMod', description: 'offer a ride, request a ride, offer transport, need to move something, publish a shipment, new route' },
|
||||||
|
{ path: '/mailing?filter=CREATE', mod: 'mailingMod', description: 'create mailing list, new mailing list, open a list, closed encrypted list' },
|
||||||
|
{ path: '/emergencies?filter=CREATE', mod: 'emergenciesMod', description: 'create emergency, raise emergency, raise alert, warn the network, report outage' },
|
||||||
|
{ path: '/calendars', mod: 'calendarsMod', description: 'create calendar, new calendar, start schedule' },
|
||||||
|
{ path: '/maps', mod: 'mapsMod', description: 'create map, new map, new offline map' },
|
||||||
|
{ path: '/events', mod: 'eventsMod', description: 'create event, new event, schedule meetup' },
|
||||||
|
{ path: '/projects', mod: 'projectsMod', description: 'create project, new project, start crowdfunding' },
|
||||||
|
{ path: '/school?filter=create', mod: 'schoolMod', description: 'create course, new course, teach a class, offer lessons' },
|
||||||
|
{ path: '/industry?filter=CREATE', mod: 'industryMod', description: 'create facility, new factory, new workshop, found a cooperative, means of manufacturing' },
|
||||||
|
{ path: '/housing?filter=CREATE', mod: 'housingMod', description: 'publish a place, offer accommodation, list a home, new housing listing, host someone' },
|
||||||
|
{ path: '/jobs', mod: 'jobsMod', description: 'create job, post job offer, new vacancy, hire' },
|
||||||
|
{ path: '/market', mod: 'marketMod', description: 'create market item, sell something, new auction, list for sale' },
|
||||||
|
{ path: '/shops', mod: 'shopsMod', description: 'create shop, open store, new vendor, list products' },
|
||||||
|
{ path: '/tasks', mod: 'tasksMod', description: 'create task, new todo, new assignment' },
|
||||||
|
{ path: '/reports', mod: 'reportsMod', description: 'create report, file bug, report issue, report abuse' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const CACHE_FILE = path.join(__dirname, 'embeddings', 'routes_cache.json')
|
||||||
|
|
||||||
|
let cache = null
|
||||||
|
|
||||||
|
const buildCacheKey = () => ROUTES.map(r => r.path + '|' + r.description).join('\n')
|
||||||
|
|
||||||
|
const loadCache = () => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(CACHE_FILE)) return null
|
||||||
|
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
|
||||||
|
if (data.key === buildCacheKey() && Array.isArray(data.entries)) return data.entries
|
||||||
|
return null
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCache = (entries) => {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
|
||||||
|
fs.writeFileSync(CACHE_FILE, JSON.stringify({ key: buildCacheKey(), entries }, null, 2), 'utf8')
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureIndex = async ({ embed }) => {
|
||||||
|
if (cache) return cache
|
||||||
|
const cached = loadCache()
|
||||||
|
if (cached) { cache = cached; return cache }
|
||||||
|
const entries = []
|
||||||
|
for (const r of ROUTES) {
|
||||||
|
const vec = await embed(r.description)
|
||||||
|
if (!vec) return null
|
||||||
|
entries.push({ path: r.path, mod: r.mod, vector: vec })
|
||||||
|
}
|
||||||
|
cache = entries
|
||||||
|
saveCache(entries)
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = (a, b) => {
|
||||||
|
let s = 0
|
||||||
|
const n = Math.min(a.length, b.length)
|
||||||
|
for (let i = 0; i < n; i++) s += a[i] * b[i]
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptionByPath = (p) => {
|
||||||
|
const r = ROUTES.find(x => x.path === p)
|
||||||
|
return r ? r.description : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeByPath = (list) => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const item of list) {
|
||||||
|
if (seen.has(item.path)) continue
|
||||||
|
seen.add(item.path)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveBest = async (queryVector, { isModuleEnabled, threshold = 0.4, embed } = {}) => {
|
||||||
|
const idx = await ensureIndex({ embed })
|
||||||
|
if (!idx) return null
|
||||||
|
let best = null
|
||||||
|
for (const entry of idx) {
|
||||||
|
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
|
||||||
|
const score = dot(queryVector, entry.vector)
|
||||||
|
if (!best || score > best.score) best = { path: entry.path, score }
|
||||||
|
}
|
||||||
|
if (!best || best.score < threshold) return null
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTopK = async (queryVector, { isModuleEnabled, threshold = 0.35, embed } = {}, k = 5) => {
|
||||||
|
const idx = await ensureIndex({ embed })
|
||||||
|
if (!idx) return []
|
||||||
|
const all = []
|
||||||
|
for (const entry of idx) {
|
||||||
|
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
|
||||||
|
const score = dot(queryVector, entry.vector)
|
||||||
|
if (score < threshold) continue
|
||||||
|
all.push({ path: entry.path, mod: entry.mod, score, description: descriptionByPath(entry.path) })
|
||||||
|
}
|
||||||
|
all.sort((a, b) => b.score - a.score)
|
||||||
|
return dedupeByPath(all).slice(0, Math.max(1, k|0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYWORD_STOPWORDS = new Set([
|
||||||
|
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into', 'about', 'want', 'show', 'see', 'find', 'give', 'get', 'go', 'my', 'me', 'to', 'of', 'in', 'on', 'at', 'an', 'is', 'are', 'do', 'does',
|
||||||
|
'week', 'year', 'month', 'today', 'tomorrow', 'now', 'next', 'last',
|
||||||
|
'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'de', 'del', 'este', 'esta', 'esto', 'estos', 'estas', 'ese', 'esa', 'eso', 'que', 'quiero', 'ver', 'buscar', 'dame', 'ir', 'mi', 'mis', 'lo', 'se', 'al', 'en', 'con', 'para', 'por',
|
||||||
|
'semana', 'mes', 'hoy', 'ahora', 'proximo', 'próximo', 'proxima', 'próxima', 'ultimo', 'último', 'ultima', 'última',
|
||||||
|
'els', 'les', 'uns', 'unes', 'dels', 'aquest', 'aquesta', 'aquests', 'aquestes', 'això', 'aquell', 'aquella', 'vull', 'veure', 'cercar', 'anar', 'meu', 'meva', 'meus', 'meves', 'amb', 'per', 'als', 'on', 'què', 'com',
|
||||||
|
'setmana', 'avui', 'ara', 'proper', 'propera', 'darrer', 'darrera',
|
||||||
|
'os', 'as', 'unha', 'unhas', 'do', 'da', 'dos', 'das', 'isto', 'iso', 'estes', 'quero', 'miña', 'miñas', 'meu', 'meus', 'no', 'na', 'nos', 'nas', 'co', 'coa', 'onde', 'como',
|
||||||
|
'hoxe', 'agora'
|
||||||
|
])
|
||||||
|
|
||||||
|
const resolveKeywordTopK = ({ isModuleEnabled } = {}, query, k = 8) => {
|
||||||
|
let tokens = String(query || '').toLowerCase().split(/[^a-z0-9À-ſ]+/).filter(t => t && t.length >= 2)
|
||||||
|
const meaningful = tokens.filter(t => !KEYWORD_STOPWORDS.has(t))
|
||||||
|
if (meaningful.length) tokens = meaningful
|
||||||
|
if (!tokens.length) return []
|
||||||
|
const all = []
|
||||||
|
for (const entry of ROUTES) {
|
||||||
|
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
|
||||||
|
const haystack = (entry.description || '').toLowerCase() + ' ' + entry.path.toLowerCase()
|
||||||
|
let hits = 0
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (haystack.includes(t)) hits += 1
|
||||||
|
}
|
||||||
|
if (hits === 0) continue
|
||||||
|
all.push({ path: entry.path, mod: entry.mod, score: hits / tokens.length, description: entry.description })
|
||||||
|
}
|
||||||
|
all.sort((a, b) => b.score - a.score)
|
||||||
|
return dedupeByPath(all).slice(0, Math.max(1, k|0))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ROUTES, ensureIndex, resolveBest, resolveTopK, resolveKeywordTopK }
|
||||||
77
src/AI/semantic_search.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
const fs = require('fs')
|
||||||
|
const { readTyped } = require('../models/typed_log')
|
||||||
|
const { buildValidatedTombstoneSet } = require('../models/tombstone_validator')
|
||||||
|
const { versionGroups, versionEntry } = require('../backend/dedupe')
|
||||||
|
|
||||||
|
const VECTORS_FILE = 'AI-search-vectors.json'
|
||||||
|
const MAX_ITEMS = 4000
|
||||||
|
const MIN_SCORE = 0.42
|
||||||
|
const TEXT_MAX = 400
|
||||||
|
|
||||||
|
const TYPES = ['post', 'event', 'job', 'project', 'market', 'shop', 'shopProduct', 'tribe', 'task', 'wikiPage', 'document', 'audio', 'video', 'image', 'bookmark', 'forum', 'campaign', 'emergency', 'podcast', 'schoolCourse', 'housing', 'logisticsRoute', 'mailingList', 'room', 'curriculum', 'blog', 'report', 'poll', 'torrent', 'file']
|
||||||
|
|
||||||
|
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
|
||||||
|
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
|
||||||
|
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
|
||||||
|
|
||||||
|
const squash = (s) => String(s || '').replace(/\s+/g, ' ').trim()
|
||||||
|
const textOf = (c) => {
|
||||||
|
if (!c || typeof c !== 'object') return ''
|
||||||
|
if (c.encryptedPayload || c.encryptedQuestion) return ''
|
||||||
|
const title = squash(c.title || c.name || c.question || c.subject || c.concept || '')
|
||||||
|
const body = squash(c.description || c.text || c.body || c.contentWarning || '')
|
||||||
|
const tags = Array.isArray(c.tags) ? c.tags.map(t => String(t || '')).join(' ') : ''
|
||||||
|
return `${title} ${body} ${tags}`.trim().slice(0, TEXT_MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
let indexing = null
|
||||||
|
|
||||||
|
const indexOnce = async (ssb, { embed, limit }) => {
|
||||||
|
const msgs = await readTyped(ssb, [...TYPES, 'tombstone'], { limit }).catch(() => [])
|
||||||
|
const tomb = buildValidatedTombstoneSet(msgs)
|
||||||
|
const content = msgs.filter(m => m && m.key && m.value && m.value.content && TYPES.includes(m.value.content.type))
|
||||||
|
const byKey = new Map(content.map(m => [m.key, m]))
|
||||||
|
const vg = versionGroups(content.map(versionEntry))
|
||||||
|
const usable = [...vg.roots].map(r => byKey.get(vg.tipOf(r)))
|
||||||
|
.filter(m => m && !tomb.has(m.key) && textOf(m.value.content))
|
||||||
|
.sort((a, b) => ((a.value.timestamp || 0) - (b.value.timestamp || 0)))
|
||||||
|
const newest = usable.slice(-MAX_ITEMS)
|
||||||
|
const cache = readVectors()
|
||||||
|
let dirty = false
|
||||||
|
const items = []
|
||||||
|
for (const m of newest) {
|
||||||
|
let v = cache[m.key]
|
||||||
|
if (!Array.isArray(v)) {
|
||||||
|
v = await embed(textOf(m.value.content)).catch(() => null)
|
||||||
|
if (!v) continue
|
||||||
|
cache[m.key] = v
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
items.push({ msg: m, vector: v })
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
const keep = new Set(newest.map(m => m.key))
|
||||||
|
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
|
||||||
|
writeVectors(cache)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = async (ssb, query, { embed, cosine, limit = 1000, k = 20 } = {}) => {
|
||||||
|
if (typeof embed !== 'function' || typeof cosine !== 'function' || !ssb) return []
|
||||||
|
const q = squash(query)
|
||||||
|
if (!q) return []
|
||||||
|
const qv = await embed(q).catch(() => null)
|
||||||
|
if (!qv) return []
|
||||||
|
if (!indexing) indexing = indexOnce(ssb, { embed, limit }).finally(() => { indexing = null })
|
||||||
|
const items = await indexing.catch(() => [])
|
||||||
|
const scored = []
|
||||||
|
for (const it of items) {
|
||||||
|
const s = cosine(qv, it.vector)
|
||||||
|
if (s >= MIN_SCORE) scored.push({ msg: it.msg, score: s })
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => b.score - a.score)
|
||||||
|
return scored.slice(0, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { search, textOf, TYPES, MIN_SCORE }
|
||||||
74
src/backend/accounts.js
Normal 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 };
|
||||||
12267
src/backend/backend.js
|
|
@ -5,19 +5,202 @@ const ssb = require("../client/gui");
|
||||||
const config = require("../server/SSB_server").config;
|
const config = require("../server/SSB_server").config;
|
||||||
const cooler = ssb({ offline: config.offline });
|
const cooler = ssb({ offline: config.offline });
|
||||||
|
|
||||||
let sharp;
|
let sharpLib;
|
||||||
try {
|
const getSharp = () => {
|
||||||
sharp = require("sharp");
|
if (sharpLib === undefined) {
|
||||||
} catch (e) {
|
try { sharpLib = require('../server/node_modules/sharp'); } catch (_) { sharpLib = null; }
|
||||||
}
|
}
|
||||||
|
return sharpLib;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
let scan = false;
|
||||||
|
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)); scan = true; 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;
|
||||||
|
}
|
||||||
|
// sin datos de imagen (SOS) es un JPEG cortado: devolver la cabecera sola subiria una foto vacia
|
||||||
|
if (!scan) return null;
|
||||||
|
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]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formatos que pueden llevar EXIF (GPS incluido) y que aqui no se saben limpiar:
|
||||||
|
// HEIC/HEIF/AVIF (caja ftyp) y TIFF. Antes subian tal cual, con la ubicacion.
|
||||||
|
const isUncleanable = (buf) => {
|
||||||
|
if (buf.length >= 12 && buf.toString("latin1", 4, 8) === "ftyp") {
|
||||||
|
return /^(heic|heix|hevc|hevx|heim|heis|mif1|msf1|avif|avis)$/.test(buf.toString("latin1", 8, 12));
|
||||||
|
}
|
||||||
|
return buf.length >= 4 && (buf.toString("latin1", 0, 4) === "II*\0" || buf.toString("latin1", 0, 4) === "MM\0*");
|
||||||
|
};
|
||||||
|
const looksCleanable = (buf) =>
|
||||||
|
(buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xD8) ||
|
||||||
|
(buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) ||
|
||||||
|
(buf.length >= 12 && buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP");
|
||||||
|
|
||||||
|
const unsafe = () => {
|
||||||
|
let msg = "This image could not be cleaned of its metadata, so it has not been uploaded.";
|
||||||
|
try { msg = require("../views/main_views").i18n.imageMetadataUnsafe || msg; } catch (_) {}
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.status = 400;
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia segun el formato. Si es JPEG/PNG/WebP y no se puede limpiar, o es un
|
||||||
|
* formato con EXIF que no sabemos limpiar, LANZA: es mejor no subir la foto que
|
||||||
|
* subirla con la ubicacion. Formatos sin metadatos de ese tipo (BMP, SVG, ICO...)
|
||||||
|
* pasan tal cual.
|
||||||
|
*/
|
||||||
|
const stripMetadataPure = (buffer) => {
|
||||||
|
if (isUncleanable(buffer)) throw unsafe();
|
||||||
|
if (!looksCleanable(buffer)) return buffer;
|
||||||
|
let out = null;
|
||||||
|
try { out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer); } catch (_) { out = null; }
|
||||||
|
if (!out || !out.length) throw unsafe();
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
const stripImageMetadata = async (buffer) => {
|
const stripImageMetadata = async (buffer) => {
|
||||||
if (typeof sharp !== "function") return buffer;
|
// Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
|
||||||
|
// orientacion girando los pixeles de verdad.
|
||||||
|
const sharp = getSharp();
|
||||||
|
if (sharp) {
|
||||||
try {
|
try {
|
||||||
return await sharp(buffer).rotate().toBuffer();
|
return await sharp(buffer).rotate().toBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
return buffer;
|
return stripMetadataPure(buffer);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return stripMetadataPure(buffer);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PDF_METADATA_KEYS = [
|
const PDF_METADATA_KEYS = [
|
||||||
|
|
@ -47,7 +230,141 @@ const stripPdfMetadata = (buffer) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_BLOB_SIZE = 50 * 1024 * 1024;
|
const STRIP_CHUNK = 64 * 1024;
|
||||||
|
const STRIP_IN_MEMORY_MAX = 256 * 1024 * 1024;
|
||||||
|
const FFMPEG_STRIP_MS = 120000;
|
||||||
|
const MP4_LIKE = /mp4|quicktime|m4a|m4v|3gpp/;
|
||||||
|
const MP4_METADATA_BOXES = new Set(['udta', 'meta']);
|
||||||
|
const MP4_WALKED_BOXES = new Set(['moov', 'trak']);
|
||||||
|
const XMP_UUID = Buffer.from('be7acfcb97a942e89c71999491e3afac', 'hex');
|
||||||
|
const WAV_METADATA_CHUNKS = new Set(['LIST', 'id3 ', 'ID3 ', 'bext', 'iXML', '_PMX', 'XMP ']);
|
||||||
|
const FLAC_METADATA_BLOCKS = new Set([4, 6]);
|
||||||
|
const FLAC_PADDING = 1;
|
||||||
|
const ID3V1_NO_GENRE = 255;
|
||||||
|
const FFMPEG_FORMATS = { 'video/webm': 'webm', 'audio/webm': 'webm', 'video/x-matroska': 'matroska', 'audio/x-matroska': 'matroska', 'video/ogg': 'ogg', 'audio/ogg': 'ogg', 'audio/opus': 'ogg', 'video/x-msvideo': 'avi' };
|
||||||
|
|
||||||
|
const readAt = async (fh, pos, len) => {
|
||||||
|
const buf = Buffer.alloc(len);
|
||||||
|
const { bytesRead } = await fh.read(buf, 0, len, pos);
|
||||||
|
return buf.subarray(0, bytesRead);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zeroAt = async (fh, pos, len) => {
|
||||||
|
const zeros = Buffer.alloc(Math.min(len, STRIP_CHUNK));
|
||||||
|
for (let done = 0; done < len; done += zeros.length) await fh.write(zeros, 0, Math.min(zeros.length, len - done), pos + done);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripMp4 = async (fh, start, end, depth = 0) => {
|
||||||
|
let pos = start;
|
||||||
|
while (pos + 8 <= end) {
|
||||||
|
const head = await readAt(fh, pos, 16);
|
||||||
|
if (head.length < 8) return;
|
||||||
|
let size = head.readUInt32BE(0);
|
||||||
|
let header = 8;
|
||||||
|
const type = head.toString('latin1', 4, 8);
|
||||||
|
if (size === 1) { if (head.length < 16) return; size = Number(head.readBigUInt64BE(8)); header = 16; }
|
||||||
|
else if (size === 0) size = end - pos;
|
||||||
|
if (size < header || pos + size > end) return;
|
||||||
|
const isXmp = type === 'uuid' && (await readAt(fh, pos + header, XMP_UUID.length)).equals(XMP_UUID);
|
||||||
|
if (MP4_METADATA_BOXES.has(type) || isXmp) {
|
||||||
|
await fh.write(Buffer.from('free', 'latin1'), 0, 4, pos + 4);
|
||||||
|
await zeroAt(fh, pos + header, size - header);
|
||||||
|
}
|
||||||
|
else if (MP4_WALKED_BOXES.has(type) && depth < MP4_WALKED_BOXES.size) await stripMp4(fh, pos + header, pos + size, depth + 1);
|
||||||
|
pos += size;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripMp3 = async (fh, size) => {
|
||||||
|
const head = await readAt(fh, 0, 10);
|
||||||
|
if (head.length === 10 && head.toString('latin1', 0, 3) === 'ID3') {
|
||||||
|
const tagSize = ((head[6] & 0x7f) << 21) | ((head[7] & 0x7f) << 14) | ((head[8] & 0x7f) << 7) | (head[9] & 0x7f);
|
||||||
|
const footer = head[5] & 0x10 ? 10 : 0;
|
||||||
|
await fh.write(Buffer.from([0]), 0, 1, 5);
|
||||||
|
await zeroAt(fh, 10, Math.min(tagSize + footer, size - 10));
|
||||||
|
}
|
||||||
|
if (size >= 128 && (await readAt(fh, size - 128, 3)).toString('latin1') === 'TAG') {
|
||||||
|
await zeroAt(fh, size - 125, 125);
|
||||||
|
await fh.write(Buffer.from([ID3V1_NO_GENRE]), 0, 1, size - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripFlac = async (fh, size) => {
|
||||||
|
if ((await readAt(fh, 0, 4)).toString('latin1') !== 'fLaC') return;
|
||||||
|
for (let pos = 4; pos + 4 <= size;) {
|
||||||
|
const head = await readAt(fh, pos, 4);
|
||||||
|
if (head.length < 4) return;
|
||||||
|
const last = head[0] & 0x80;
|
||||||
|
const len = head.readUIntBE(1, 3);
|
||||||
|
if (FLAC_METADATA_BLOCKS.has(head[0] & 0x7f)) {
|
||||||
|
await fh.write(Buffer.from([last | FLAC_PADDING]), 0, 1, pos);
|
||||||
|
await zeroAt(fh, pos + 4, len);
|
||||||
|
}
|
||||||
|
if (last) return;
|
||||||
|
pos += 4 + len;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripWav = async (fh, size) => {
|
||||||
|
const head = await readAt(fh, 0, 12);
|
||||||
|
if (head.length < 12 || head.toString('latin1', 0, 4) !== 'RIFF' || head.toString('latin1', 8, 12) !== 'WAVE') return;
|
||||||
|
for (let pos = 12; pos + 8 <= size;) {
|
||||||
|
const chunk = await readAt(fh, pos, 8);
|
||||||
|
if (chunk.length < 8) return;
|
||||||
|
const len = chunk.readUInt32LE(4);
|
||||||
|
if (WAV_METADATA_CHUNKS.has(chunk.toString('latin1', 0, 4))) {
|
||||||
|
await fh.write(Buffer.from('JUNK', 'latin1'), 0, 4, pos);
|
||||||
|
await zeroAt(fh, pos + 8, Math.min(len, size - pos - 8));
|
||||||
|
}
|
||||||
|
pos += 8 + len + (len & 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripWithFfmpeg = (filepath, format) => new Promise((resolve) => {
|
||||||
|
const out = `${filepath}.clean`;
|
||||||
|
const args = ['-v', 'error', '-y', '-i', filepath, '-map', '0', '-map_metadata', '-1', '-c', 'copy', '-f', format, out];
|
||||||
|
require('child_process').execFile(process.env.OASIS_FFMPEG || 'ffmpeg', args, { timeout: FFMPEG_STRIP_MS }, async (err) => {
|
||||||
|
try {
|
||||||
|
if (err) await promisesFs.unlink(out).catch(() => {});
|
||||||
|
else await promisesFs.rename(out, filepath);
|
||||||
|
} catch (_) {}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripFileMetadata = async (filepath, mime) => {
|
||||||
|
const m = String(mime || '');
|
||||||
|
try {
|
||||||
|
const { size } = await promisesFs.stat(filepath);
|
||||||
|
if ((m.startsWith('image/') && m !== 'image/gif') || m === 'application/pdf') {
|
||||||
|
if (size > STRIP_IN_MEMORY_MAX) return;
|
||||||
|
const data = await promisesFs.readFile(filepath);
|
||||||
|
const clean = m === 'application/pdf' ? stripPdfMetadata(data) : await stripImageMetadata(data);
|
||||||
|
if (clean !== data) await promisesFs.writeFile(filepath, clean);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!m.startsWith('audio/') && !m.startsWith('video/')) return;
|
||||||
|
if (FFMPEG_FORMATS[m]) { await stripWithFfmpeg(filepath, FFMPEG_FORMATS[m]); return; }
|
||||||
|
const fh = await promisesFs.open(filepath, 'r+');
|
||||||
|
try {
|
||||||
|
if (MP4_LIKE.test(m)) await stripMp4(fh, 0, size);
|
||||||
|
else if (m === 'audio/mpeg') await stripMp3(fh, size);
|
||||||
|
else if (m === 'audio/flac' || m === 'audio/x-flac') await stripFlac(fh, size);
|
||||||
|
else if (/wav|wave/.test(m)) await stripWav(fh, size);
|
||||||
|
} finally { await fh.close(); }
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_BLOB_SIZE = 75 * 1024 * 1024;
|
||||||
|
const KIND_MIME = { image: /^image\//, audio: /^audio\//, video: /^video\//, document: /^application\/pdf$/, torrent: /^application\/x-bittorrent$/ };
|
||||||
|
const looksLikeTorrent = (data) => data.length > 0 && data[0] === 0x64 && data.subarray(0, 4096).includes(Buffer.from('4:info'));
|
||||||
|
|
||||||
|
class WrongFileTypeError extends Error {
|
||||||
|
constructor(fileName, mime) {
|
||||||
|
super(`Wrong file type: ${fileName} (${mime})`);
|
||||||
|
this.name = 'WrongFileTypeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FileTooLargeError extends Error {
|
class FileTooLargeError extends Error {
|
||||||
constructor(fileName, fileSize) {
|
constructor(fileName, fileSize) {
|
||||||
|
|
@ -58,25 +375,32 @@ 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 sealedRef = (sealed, mime, name) => `/blob/sealed/${encodeURIComponent(sealed.manifestBlobId)}?k=${encodeURIComponent(sealed.key)}&t=${encodeURIComponent(mime)}&n=${encodeURIComponent(name)}`;
|
||||||
|
|
||||||
|
const storeUploadedFile = async function (blobUpload, seal = null, kind = null) {
|
||||||
|
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,29 +417,42 @@ 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) {
|
||||||
blob.mime = 'application/octet-stream';
|
blob.mime = 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
if (blob.mime === 'application/octet-stream' && looksLikeTorrent(data)) blob.mime = 'application/x-bittorrent';
|
||||||
|
if (kind && KIND_MIME[kind] && !KIND_MIME[kind].test(blob.mime)) throw new WrongFileTypeError(blob.name, blob.mime);
|
||||||
|
|
||||||
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
|
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
|
||||||
data = await stripImageMetadata(data);
|
data = await stripImageMetadata(data);
|
||||||
} else if (blob.mime === 'application/pdf') {
|
} else if (blob.mime === 'application/pdf') {
|
||||||
data = stripPdfMetadata(data);
|
data = stripPdfMetadata(data);
|
||||||
|
} else if (blob.mime.startsWith('audio/') || blob.mime.startsWith('video/')) {
|
||||||
|
await stripFileMetadata(blobUpload.filepath, blob.mime);
|
||||||
|
data = await promisesFs.readFile(blobUpload.filepath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof seal === 'function') {
|
||||||
|
await promisesFs.writeFile(blobUpload.filepath, data);
|
||||||
|
const sealed = await seal({ filepath: blobUpload.filepath, size: data.length, name: blob.name, mime: blob.mime });
|
||||||
|
blob.id = sealedRef(sealed, blob.mime, blob.name);
|
||||||
|
} else {
|
||||||
const ssbClient = await cooler.open();
|
const ssbClient = await cooler.open();
|
||||||
|
|
||||||
blob.id = await new Promise((resolve, reject) => {
|
blob.id = await new Promise((resolve, reject) => {
|
||||||
pull(
|
pull(
|
||||||
pull.values([data]),
|
pull.values([data]),
|
||||||
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
|
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (blob.mime.startsWith("image/")) return `\n`;
|
if (blob.mime.startsWith("image/")) return `\n`;
|
||||||
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
|
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
|
||||||
|
|
@ -126,6 +463,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, seal = null, kind = null) {
|
||||||
|
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, seal, kind);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) {
|
||||||
|
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, seal);
|
||||||
|
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;
|
||||||
|
|
@ -168,6 +524,52 @@ function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentDisposition = (type, name) => {
|
||||||
|
const clean = String(name || 'file').replace(/["\r\n\\/]/g, '').trim() || 'file';
|
||||||
|
const ascii = clean.replace(/[^\x20-\x7e]/g, '_');
|
||||||
|
if (ascii === clean) return `${type}; filename="${clean}"`;
|
||||||
|
return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendBlobBuffer = (ctx, buffer) => {
|
||||||
|
const size = buffer.length;
|
||||||
|
const range = ctx.headers.range;
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||||
|
if (!match) {
|
||||||
|
ctx.status = 416;
|
||||||
|
ctx.set('Content-Range', `bytes */${size}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = match[1] ? parseInt(match[1], 10) : 0;
|
||||||
|
let end = match[2] ? parseInt(match[2], 10) : size - 1;
|
||||||
|
|
||||||
|
if (Number.isNaN(start) || start < 0) start = 0;
|
||||||
|
if (Number.isNaN(end) || end >= size) end = size - 1;
|
||||||
|
|
||||||
|
if (start > end || start >= size) {
|
||||||
|
ctx.status = 416;
|
||||||
|
ctx.set('Content-Range', `bytes */${size}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = buffer.slice(start, end + 1);
|
||||||
|
|
||||||
|
ctx.status = 206;
|
||||||
|
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
|
||||||
|
ctx.set('Accept-Ranges', 'bytes');
|
||||||
|
ctx.set('Content-Length', String(chunk.length));
|
||||||
|
ctx.body = chunk;
|
||||||
|
} else {
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.set('Accept-Ranges', 'bytes');
|
||||||
|
ctx.set('Content-Length', String(size));
|
||||||
|
ctx.body = buffer;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const serveBlob = async function (ctx) {
|
const serveBlob = async function (ctx) {
|
||||||
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
|
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
|
||||||
const raw = decodeURIComponent(encodedParam);
|
const raw = decodeURIComponent(encodedParam);
|
||||||
|
|
@ -207,8 +609,6 @@ const serveBlob = async function (ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = buffer.length;
|
|
||||||
|
|
||||||
let mime = 'application/octet-stream';
|
let mime = 'application/octet-stream';
|
||||||
try {
|
try {
|
||||||
const ft = await FileType.fromBuffer(buffer);
|
const ft = await FileType.fromBuffer(buffer);
|
||||||
|
|
@ -216,7 +616,7 @@ const serveBlob = async function (ctx) {
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
|
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
|
||||||
const head = buffer.slice(0, 128).toString('ascii');
|
const head = buffer.slice(0, 512).toString('ascii');
|
||||||
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
|
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,51 +629,22 @@ 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, '');
|
||||||
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
|
const wantsDownload = String(ctx.query.download || '') === '1';
|
||||||
const disposition = isSvg ? 'attachment' : 'inline';
|
const extByMime = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'application/pdf': 'pdf', 'application/x-bittorrent': 'torrent' };
|
||||||
|
const ext = extByMime[mime] || String(mime.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||||
|
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : (wantsDownload ? `oasis-${safeRaw.replace(/^&/, '').slice(0, 8)}.${ext}` : safeRaw));
|
||||||
|
const disposition = isSvg || wantsDownload ? 'attachment' : 'inline';
|
||||||
ctx.type = mime;
|
ctx.type = mime;
|
||||||
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
|
ctx.set('Content-Disposition', contentDisposition(disposition, filename));
|
||||||
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
|
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
|
||||||
const range = ctx.headers.range;
|
sendBlobBuffer(ctx, buffer);
|
||||||
|
|
||||||
if (range) {
|
|
||||||
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
||||||
if (!match) {
|
|
||||||
ctx.status = 416;
|
|
||||||
ctx.set('Content-Range', `bytes */${size}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = match[1] ? parseInt(match[1], 10) : 0;
|
|
||||||
let end = match[2] ? parseInt(match[2], 10) : size - 1;
|
|
||||||
|
|
||||||
if (Number.isNaN(start) || start < 0) start = 0;
|
|
||||||
if (Number.isNaN(end) || end >= size) end = size - 1;
|
|
||||||
|
|
||||||
if (start > end || start >= size) {
|
|
||||||
ctx.status = 416;
|
|
||||||
ctx.set('Content-Range', `bytes */${size}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunk = buffer.slice(start, end + 1);
|
|
||||||
|
|
||||||
ctx.status = 206;
|
|
||||||
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
|
|
||||||
ctx.set('Accept-Ranges', 'bytes');
|
|
||||||
ctx.set('Content-Length', String(chunk.length));
|
|
||||||
ctx.body = chunk;
|
|
||||||
} else {
|
|
||||||
ctx.status = 200;
|
|
||||||
ctx.set('Accept-Ranges', 'bytes');
|
|
||||||
ctx.set('Content-Length', String(size));
|
|
||||||
ctx.body = buffer;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { handleBlobUpload, serveBlob, FileTooLargeError };
|
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, WrongFileTypeError, stripImageMetadata, stripFileMetadata };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,43 @@
|
||||||
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 storePath = () => require("../configs/state-manager").statePath("content_favorites.json");
|
||||||
|
|
||||||
const DEFAULT = {
|
const DEFAULT = {
|
||||||
|
emergencies: [],
|
||||||
audios: [],
|
audios: [],
|
||||||
|
blogs: [],
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
calendars: [],
|
calendars: [],
|
||||||
|
campaigns: [],
|
||||||
chats: [],
|
chats: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
|
events: [],
|
||||||
|
forum: [],
|
||||||
|
housing: [],
|
||||||
images: [],
|
images: [],
|
||||||
|
jobs: [],
|
||||||
|
logistics: [],
|
||||||
|
logs: [],
|
||||||
|
mailing: [],
|
||||||
maps: [],
|
maps: [],
|
||||||
pads: [],
|
pads: [],
|
||||||
|
rooms: [],
|
||||||
|
podcasts: [],
|
||||||
|
polls: [],
|
||||||
|
projects: [],
|
||||||
|
reports: [],
|
||||||
|
school: [],
|
||||||
shops: [],
|
shops: [],
|
||||||
market: [],
|
market: [],
|
||||||
shopProducts: [],
|
shopProducts: [],
|
||||||
|
tasks: [],
|
||||||
torrents: [],
|
torrents: [],
|
||||||
videos: []
|
files: [],
|
||||||
|
transfers: [],
|
||||||
|
videos: [],
|
||||||
|
votes: [],
|
||||||
|
wiki: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||||
|
|
@ -39,32 +60,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) => {
|
||||||
|
|
@ -73,21 +94,21 @@ const assertKind = (kind) => {
|
||||||
return k;
|
return k;
|
||||||
};
|
};
|
||||||
|
|
||||||
const lastArg = (args, n) => args[args.length - n];
|
|
||||||
|
|
||||||
const kindFromArgs = (args) => {
|
|
||||||
const k = String(lastArg(args, 1) || "").trim();
|
|
||||||
return assertKind(k);
|
|
||||||
};
|
|
||||||
|
|
||||||
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
|
|
||||||
|
|
||||||
exports.getFavoriteSet = async (kind) => {
|
exports.getFavoriteSet = async (kind) => {
|
||||||
const k = assertKind(kind);
|
const k = assertKind(kind);
|
||||||
const data = await readAll();
|
const data = await readAll();
|
||||||
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);
|
||||||
|
|
@ -112,15 +133,4 @@ exports.removeFavorite = async (kind, id) =>
|
||||||
await writeAll(data);
|
await writeAll(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
|
exports.storePath = storePath;
|
||||||
exports.addToFavorites = async (...args) => {
|
|
||||||
const kind = kindFromArgs(args.slice(0, -1));
|
|
||||||
const id = idFromArgs(args);
|
|
||||||
return exports.addFavorite(kind, id);
|
|
||||||
};
|
|
||||||
exports.removeFromFavorites = async (...args) => {
|
|
||||||
const kind = kindFromArgs(args.slice(0, -1));
|
|
||||||
const id = idFromArgs(args);
|
|
||||||
return exports.removeFavorite(kind, id);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -49,4 +49,79 @@ const dedupeByPreferring = (items, keyFn, scoreFn) => {
|
||||||
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
|
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm };
|
const versionGroups = (entries) => {
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const e of (entries || [])) if (e && e.key) byKey.set(e.key, e);
|
||||||
|
const memo = new Map();
|
||||||
|
const rootOf = (key) => {
|
||||||
|
if (memo.has(key)) return memo.get(key);
|
||||||
|
const path = [];
|
||||||
|
const seen = new Set([key]);
|
||||||
|
let cur = key;
|
||||||
|
let root = null;
|
||||||
|
while (root === null) {
|
||||||
|
if (memo.has(cur)) { root = memo.get(cur); break; }
|
||||||
|
const e = byKey.get(cur);
|
||||||
|
const p = e && e.replaces;
|
||||||
|
if (!p || !byKey.has(p) || seen.has(p)) { root = cur; break; }
|
||||||
|
path.push(cur);
|
||||||
|
seen.add(p);
|
||||||
|
cur = p;
|
||||||
|
}
|
||||||
|
memo.set(cur, root);
|
||||||
|
for (const k of path) memo.set(k, root);
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
const isOrphan = (key) => {
|
||||||
|
const e = byKey.get(key);
|
||||||
|
return !!(e && e.replaces && !byKey.has(e.replaces));
|
||||||
|
};
|
||||||
|
const isForeignCopy = (key) => {
|
||||||
|
const e = byKey.get(key);
|
||||||
|
return !!(e && isOrphan(key) && typeof e.claimedAuthor === 'string' && e.claimedAuthor && e.claimedAuthor !== e.author);
|
||||||
|
};
|
||||||
|
const groups = new Map();
|
||||||
|
for (const k of byKey.keys()) {
|
||||||
|
const r = rootOf(k);
|
||||||
|
if (!groups.has(r)) groups.set(r, []);
|
||||||
|
groups.get(r).push(k);
|
||||||
|
}
|
||||||
|
const later = (a, b) => (a.seq || 0) > (b.seq || 0) || ((a.seq || 0) === (b.seq || 0) && (a.ts || 0) >= (b.ts || 0));
|
||||||
|
for (const [r, keys] of Array.from(groups.entries())) {
|
||||||
|
if (!isForeignCopy(r)) continue;
|
||||||
|
const owner = byKey.get(r).claimedAuthor;
|
||||||
|
let first = null;
|
||||||
|
for (const k of keys) { const e = byKey.get(k); if (e.author === owner && (!first || later(byKey.get(first), e))) first = k; }
|
||||||
|
if (!first) continue;
|
||||||
|
groups.delete(r);
|
||||||
|
groups.set(first, keys);
|
||||||
|
for (const k of keys) memo.set(k, first);
|
||||||
|
}
|
||||||
|
const ownerTip = new Map();
|
||||||
|
for (const [r, keys] of groups) {
|
||||||
|
const re = byKey.get(r);
|
||||||
|
for (const k of keys) {
|
||||||
|
const e = byKey.get(k);
|
||||||
|
if (!re || e.author !== re.author) continue;
|
||||||
|
const prev = ownerTip.has(r) ? byKey.get(ownerTip.get(r)) : null;
|
||||||
|
if (!prev || later(e, prev)) ownerTip.set(r, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isForeign = (key) => {
|
||||||
|
const e = byKey.get(key);
|
||||||
|
const re = byKey.get(rootOf(key));
|
||||||
|
return !!(e && re && e.author !== re.author);
|
||||||
|
};
|
||||||
|
const roots = new Set();
|
||||||
|
for (const r of groups.keys()) if (!isForeignCopy(r)) roots.add(r);
|
||||||
|
const tipOf = (key) => { const r = rootOf(key); return ownerTip.get(r) || r; };
|
||||||
|
return { rootOf, tipOf, roots, groups, isOrphan, isForeign, isForeignCopy };
|
||||||
|
};
|
||||||
|
|
||||||
|
const versionEntry = (m) => {
|
||||||
|
const v = (m && m.value) || {};
|
||||||
|
const c = v.content || {};
|
||||||
|
return { key: m && m.key, author: v.author, seq: v.sequence || 0, ts: v.timestamp || (m && m.timestamp) || 0, replaces: typeof c.replaces === 'string' ? c.replaces : null, claimedAuthor: typeof c.author === 'string' ? c.author : null };
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm, versionGroups, versionEntry };
|
||||||
|
|
|
||||||
80
src/backend/desktopNotify.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { getConfig } = require('../configs/config-manager.js');
|
||||||
|
|
||||||
|
const REPEAT_MS = 60000;
|
||||||
|
const ICON = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||||
|
const recent = new Map();
|
||||||
|
|
||||||
|
const onPath = (bin) => (process.env.PATH || '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin)));
|
||||||
|
|
||||||
|
const i18nNow = () => {
|
||||||
|
const all = require('../client/assets/translations/i18n');
|
||||||
|
let lang = 'en';
|
||||||
|
try { lang = getConfig().language || 'en'; } catch (_) {}
|
||||||
|
return { ...all.en, ...(all[lang] || {}) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = (id, name) => {
|
||||||
|
const n = String(name || '').trim();
|
||||||
|
if (!n || n === 'Redacted' || n === String(id || '').replace(/^@/, '').slice(0, 8)) return String(id || '');
|
||||||
|
return n.startsWith('@') ? n : `@${n}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOBILE_QUEUE = path.join(__dirname, '..', '..', '.notifications.json');
|
||||||
|
const queueForAndroid = (keyword, body, line, key) => {
|
||||||
|
try {
|
||||||
|
let list = [];
|
||||||
|
try { list = JSON.parse(fs.readFileSync(MOBILE_QUEUE, 'utf8')); } catch (_) { list = []; }
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
const t = i18nNow();
|
||||||
|
const k = String(keyword || '');
|
||||||
|
const route = k === t.phoneTitle ? '/phone' : k === t.inbox ? '/inbox' : k === t.emergenciesTitle ? '/emergencies' : '/';
|
||||||
|
const entry = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 8), keyword: k || 'Oasis', body, line, route, key: key ? String(key) : null, ts: Date.now() };
|
||||||
|
if (entry.key && entry.key.indexOf('call:') === 0) {
|
||||||
|
const ringing = String(t.phoneIsCalling || '');
|
||||||
|
entry.caller = ringing && body.endsWith(ringing) ? body.slice(0, body.length - ringing.length).trim() : body;
|
||||||
|
entry.accept = String(t.phoneAnswer || '');
|
||||||
|
entry.reject = String(t.phoneReject || '');
|
||||||
|
}
|
||||||
|
list.push(entry);
|
||||||
|
while (list.length > 20) list.shift();
|
||||||
|
fs.writeFileSync(MOBILE_QUEUE, JSON.stringify(list));
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notify = (keyword, text, key = null, opts = {}) => {
|
||||||
|
const onMobile = process.env.OASIS_MOBILE === '1';
|
||||||
|
if (!onMobile && !onPath('notify-send')) return false;
|
||||||
|
const body = String(text || '').replace(/\s+/g, ' ').trim().replace(/[.。!…]+$/, '').slice(0, 200);
|
||||||
|
const line = `${String(keyword || 'Oasis').toUpperCase()}: ${body}...`;
|
||||||
|
const now = Date.now();
|
||||||
|
const dedupe = key || line;
|
||||||
|
for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k);
|
||||||
|
if (recent.has(dedupe)) return false;
|
||||||
|
recent.set(dedupe, now);
|
||||||
|
if (onMobile) return queueForAndroid(keyword, body, line, key);
|
||||||
|
const actions = Array.isArray(opts.actions) ? opts.actions.filter(x => x && x.key && x.label) : [];
|
||||||
|
const extra = [
|
||||||
|
...(opts.timeoutMs > 0 ? ['-t', String(Math.round(opts.timeoutMs))] : []),
|
||||||
|
...actions.flatMap(x => ['-A', `${x.key}=${x.label}`])
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
const p = spawn('notify-send', ['-a', 'Oasis', ...(fs.existsSync(ICON) ? ['-i', ICON] : []), ...extra, line], { stdio: ['ignore', actions.length ? 'pipe' : 'ignore', 'ignore'] });
|
||||||
|
p.on('error', () => {});
|
||||||
|
if (actions.length && typeof opts.onAction === 'function') {
|
||||||
|
let out = '';
|
||||||
|
p.stdout.on('data', (d) => { out += d; });
|
||||||
|
p.on('close', () => { const chosen = out.trim(); if (actions.some(x => x.key === chosen)) opts.onAction(chosen); });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { notify, i18nNow, displayName };
|
||||||
74
src/backend/fileshare_crypto.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const ALG = 'aes-256-gcm';
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
const KEY_LEN = 32;
|
||||||
|
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const generateFileKey = () => crypto.randomBytes(KEY_LEN);
|
||||||
|
|
||||||
|
const keyToHex = (key) => Buffer.isBuffer(key) ? key.toString('hex') : String(key);
|
||||||
|
|
||||||
|
const keyFromHex = (hex) => {
|
||||||
|
const buf = Buffer.from(String(hex), 'hex');
|
||||||
|
if (buf.length !== KEY_LEN) throw new Error('Invalid file key');
|
||||||
|
return buf;
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptChunk = (plaintext, key) => {
|
||||||
|
const iv = crypto.randomBytes(IV_LEN);
|
||||||
|
const cipher = crypto.createCipheriv(ALG, key, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, tag, ct]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptChunk = (payload, key) => {
|
||||||
|
if (!Buffer.isBuffer(payload) || payload.length < IV_LEN + TAG_LEN) throw new Error('Corrupt chunk');
|
||||||
|
const iv = payload.subarray(0, IV_LEN);
|
||||||
|
const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||||
|
const ct = payload.subarray(IV_LEN + TAG_LEN);
|
||||||
|
const decipher = crypto.createDecipheriv(ALG, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sha256Hex = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
|
|
||||||
|
const splitBuffer = (buffer, chunkSize = DEFAULT_CHUNK_SIZE) => {
|
||||||
|
const size = Math.max(1, Number(chunkSize) || DEFAULT_CHUNK_SIZE);
|
||||||
|
const out = [];
|
||||||
|
for (let off = 0; off < buffer.length; off += size) {
|
||||||
|
out.push(buffer.subarray(off, Math.min(off + size, buffer.length)));
|
||||||
|
}
|
||||||
|
if (!out.length) out.push(Buffer.alloc(0));
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildManifest = ({ filename, mime, size, chunkSize, chunkHashes, plainSha256 }) => ({
|
||||||
|
v: 1,
|
||||||
|
alg: ALG,
|
||||||
|
filename: String(filename || 'file'),
|
||||||
|
mime: String(mime || 'application/octet-stream'),
|
||||||
|
size: Number(size) || 0,
|
||||||
|
chunkSize: Number(chunkSize) || DEFAULT_CHUNK_SIZE,
|
||||||
|
chunks: Array.isArray(chunkHashes) ? chunkHashes.slice() : [],
|
||||||
|
plainSha256: plainSha256 || null
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptManifest = (manifest, key) => encryptChunk(Buffer.from(JSON.stringify(manifest), 'utf8'), key);
|
||||||
|
|
||||||
|
const decryptManifest = (payload, key) => {
|
||||||
|
const json = decryptChunk(payload, key).toString('utf8');
|
||||||
|
const m = JSON.parse(json);
|
||||||
|
if (!m || m.v !== 1 || !Array.isArray(m.chunks)) throw new Error('Invalid manifest');
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ALG, IV_LEN, TAG_LEN, KEY_LEN, DEFAULT_CHUNK_SIZE,
|
||||||
|
generateFileKey, keyToHex, keyFromHex,
|
||||||
|
encryptChunk, decryptChunk, sha256Hex, splitBuffer,
|
||||||
|
buildManifest, encryptManifest, decryptManifest
|
||||||
|
};
|
||||||
137
src/backend/fork_routes.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"use strict";
|
||||||
|
// Router propio de la rama movil.
|
||||||
|
//
|
||||||
|
// Va en la cadena justo delante de las rutas del dev (backend.js lo monta con una
|
||||||
|
// sola linea). Lo que no atiende pasa al del dev con next(). Aqui viven:
|
||||||
|
// - mobile_layer.js: lanzador, filtro y avatar de la barra de arriba
|
||||||
|
// - las rutas propias: tema, colores y Modulos en el movil, barra de abajo, identidades
|
||||||
|
//
|
||||||
|
// No se toca lo que contesta el dev (ni sus redirecciones ni su HTML): si hace falta
|
||||||
|
// otra cosa, ruta propia aqui o enlace a la pantalla del dev. Ver GUIA_DE_DESARROLLO.md.
|
||||||
|
//
|
||||||
|
// backend.js es del dev y cambia en 34 de cada 39 versiones: nada de esto va alli.
|
||||||
|
// Ver GUIA_DE_DESARROLLO.md.
|
||||||
|
|
||||||
|
const koaRouter = require("../server/node_modules/@koa/router");
|
||||||
|
const { koaBody } = require("../server/node_modules/koa-body");
|
||||||
|
const compose = require("../server/node_modules/koa-compose");
|
||||||
|
const { mobileModulesView } = require("../views/mobile_modules_view");
|
||||||
|
require("../models/fork_workflows").register();
|
||||||
|
|
||||||
|
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||||
|
|
||||||
|
module.exports = ({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage, about, backupModel }) => {
|
||||||
|
const pubBar = require('../views/mobile_pubbar');
|
||||||
|
pubBar.setSource(backupModel);
|
||||||
|
const mobileLayer = require('./mobile_layer')({ getViewerId, about });
|
||||||
|
|
||||||
|
const forkRouter = new koaRouter();
|
||||||
|
forkRouter
|
||||||
|
// tema en el movil: solo los skins de movil (uno de escritorio deja la app sin
|
||||||
|
// maqueta). Hace lo mismo que la ruta del dev, con esa comprobacion delante
|
||||||
|
.post('/settings/theme', koaBody(), async (ctx, next) => {
|
||||||
|
if (!isMobile()) return next();
|
||||||
|
const theme = String((ctx.request.body || {}).theme || '').trim();
|
||||||
|
const { MOBILE_THEMES } = require('../views/main_views');
|
||||||
|
if (!MOBILE_THEMES.some(([value]) => value === theme)) { ctx.redirect('/settings#theme'); return; }
|
||||||
|
const cfg = getConfig();
|
||||||
|
cfg.themes.current = theme;
|
||||||
|
saveConfig(cfg);
|
||||||
|
ctx.cookies.set('theme', theme, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
|
||||||
|
ctx.redirect('/settings#theme');
|
||||||
|
})
|
||||||
|
// colores del movil (mobile_settings.js): se guardan en la config
|
||||||
|
.post('/settings/colors', koaBody(), async (ctx, next) => {
|
||||||
|
if (!isMobile()) return next();
|
||||||
|
const body = ctx.request.body || {};
|
||||||
|
const cfg = getConfig();
|
||||||
|
cfg.mobileColors = require('../views/mobile_settings').colorsFromBody(body);
|
||||||
|
saveConfig(cfg);
|
||||||
|
const back = String(body.returnTo || '');
|
||||||
|
ctx.redirect(/^\/(settings|welcome)(#[a-z-]+)?$/.test(back) ? back : '/settings#colors');
|
||||||
|
})
|
||||||
|
// barra de unirse a un pub (mobile_pubbar.js): cerrar el aviso de terminado
|
||||||
|
.post('/mobile/pub-status/dismiss', koaBody(), async (ctx, next) => {
|
||||||
|
if (!isMobile()) return next();
|
||||||
|
pubBar.dismiss();
|
||||||
|
const back = String(ctx.get('referer') || '').replace(/^https?:\/\/[^/]+/, '');
|
||||||
|
ctx.redirect(/^\/[\w\-/?=&%.#]*$/.test(back) ? back : '/activity');
|
||||||
|
})
|
||||||
|
.get('/mobile-colors.css', async (ctx, next) => {
|
||||||
|
if (!isMobile()) return next();
|
||||||
|
ctx.type = 'text/css';
|
||||||
|
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
ctx.body = require('../views/mobile_settings').colorsCss(getConfig());
|
||||||
|
})
|
||||||
|
// Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream
|
||||||
|
.get('/modules', async (ctx, next) => {
|
||||||
|
if (!isMobile()) return next();
|
||||||
|
ctx.body = mobileModulesView();
|
||||||
|
})
|
||||||
|
.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 || [];
|
||||||
|
// cada skin tiene su barra (fork_workflows.js): se cambia la del skin puesto
|
||||||
|
const bars = require("../models/fork_workflows");
|
||||||
|
let pins = bars.barFor(cfg).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 = [];
|
||||||
|
}
|
||||||
|
bars.saveBar(cfg, 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 compose([mobileLayer, forkRouter.routes()]);
|
||||||
|
};
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
|
||||||
|
|
||||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/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;
|
|
||||||
|
|
||||||
const splitSegments = (line) => {
|
|
||||||
const segs = [];
|
|
||||||
let last = 0;
|
|
||||||
const re = new RegExp(linkPattern.source, 'g');
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(line)) !== null) {
|
|
||||||
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
|
|
||||||
segs.push({ t: m[0], l: true });
|
|
||||||
last = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < line.length) segs.push({ t: line.slice(last), l: false });
|
|
||||||
return segs;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrap = (txt, max = 82) => {
|
|
||||||
const out = [];
|
|
||||||
for (const raw of String(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;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildLogsPdf(entries, oasisId, opts = {}) {
|
|
||||||
const pageW = 612;
|
|
||||||
const pageH = 792;
|
|
||||||
const marginX = 50;
|
|
||||||
const headerH = 90;
|
|
||||||
const footerH = 40;
|
|
||||||
const bodyTop = pageH - headerH - 22;
|
|
||||||
const bodyBottom = footerH + 10;
|
|
||||||
const lineH = 12;
|
|
||||||
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
|
|
||||||
|
|
||||||
let logoBuf = null;
|
|
||||||
let logoDims = null;
|
|
||||||
try {
|
|
||||||
logoBuf = fs.readFileSync(LOGO_PATH);
|
|
||||||
logoDims = readJpegDims(logoBuf);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
const allLines = [];
|
|
||||||
for (const e of entries) {
|
|
||||||
const ts = new Date(e.ts);
|
|
||||||
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
|
|
||||||
allLines.push({ kind: 'header', text: `[${when}]:` });
|
|
||||||
allLines.push({ kind: 'blank', text: '' });
|
|
||||||
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
|
|
||||||
allLines.push({ kind: 'blank', text: '' });
|
|
||||||
}
|
|
||||||
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
|
|
||||||
|
|
||||||
const pages = [];
|
|
||||||
for (let i = 0; i < allLines.length; i += maxBodyLines) {
|
|
||||||
pages.push(allLines.slice(i, i + maxBodyLines));
|
|
||||||
}
|
|
||||||
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
|
|
||||||
|
|
||||||
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 >>');
|
|
||||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
|
||||||
|
|
||||||
let logoXObjId = null;
|
|
||||||
if (logoBuf && logoDims) {
|
|
||||||
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
|
||||||
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /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);
|
|
||||||
const logoX = marginX;
|
|
||||||
const logoY = pageH - headerH + 15;
|
|
||||||
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} 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('OASIS - Experience logs')}) Tj\nET`);
|
|
||||||
const inhabitantPrefix = 'Inhabitant: ';
|
|
||||||
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
|
|
||||||
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
|
|
||||||
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) 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;
|
|
||||||
const charW = 6;
|
|
||||||
for (const ln of pg) {
|
|
||||||
if (ln.kind === 'header') {
|
|
||||||
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
|
||||||
} else if (ln.text) {
|
|
||||||
const segs = splitSegments(ln.text);
|
|
||||||
let x = marginX;
|
|
||||||
for (const s of segs) {
|
|
||||||
if (!s.t) continue;
|
|
||||||
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
|
|
||||||
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
|
|
||||||
x += s.t.length * charW;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)} >>\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 = { buildLogsPdf };
|
|
||||||
195
src/backend/long_text.js
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const CHUNK_TYPE = 'textChunk';
|
||||||
|
const TEXT_CAP = 60000;
|
||||||
|
const CONTENT_BYTES = { plain: 6500, boxed: 4500, sealed: 3300 };
|
||||||
|
const MIN_HEAD_BYTES = 200;
|
||||||
|
const CACHE_MAX = 400;
|
||||||
|
const KEY_RE = /^%[A-Za-z0-9+/]{43}=?\.sha256$/;
|
||||||
|
const KEY_SAMPLE = '%' + 'A'.repeat(44) + '.sha256';
|
||||||
|
|
||||||
|
const byteLength = (s) => Buffer.byteLength(String(s == null ? '' : s), 'utf8');
|
||||||
|
const contentBytes = (content) => { try { return byteLength(JSON.stringify(content)); } catch (_) { return Infinity; } };
|
||||||
|
const isChunkKey = (k) => typeof k === 'string' && KEY_RE.test(k);
|
||||||
|
const tooLong = (text) => String(text == null ? '' : text).length > TEXT_CAP;
|
||||||
|
|
||||||
|
const cutAt = (text, maxBytes) => {
|
||||||
|
let i = 0, bytes = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
const cp = text.codePointAt(i);
|
||||||
|
const len = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
||||||
|
if (bytes + len > maxBytes) break;
|
||||||
|
bytes += len;
|
||||||
|
i += cp >= 0x10000 ? 2 : 1;
|
||||||
|
}
|
||||||
|
if (i >= text.length) return text.length;
|
||||||
|
const min = Math.floor(i / 2);
|
||||||
|
const para = text.lastIndexOf('\n\n', i);
|
||||||
|
if (para > min) return para + 2;
|
||||||
|
const line = text.lastIndexOf('\n', i);
|
||||||
|
if (line > min) return line + 1;
|
||||||
|
const space = text.lastIndexOf(' ', i);
|
||||||
|
if (space > min) return space + 1;
|
||||||
|
return Math.max(i, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitText = (text, maxBytes, firstMax = maxBytes) => {
|
||||||
|
const s = String(text == null ? '' : text);
|
||||||
|
const out = [];
|
||||||
|
let rest = s;
|
||||||
|
let limit = Math.max(1, firstMax);
|
||||||
|
while (rest.length) {
|
||||||
|
const cut = cutAt(rest, limit);
|
||||||
|
out.push(rest.slice(0, cut));
|
||||||
|
rest = rest.slice(cut);
|
||||||
|
limit = Math.max(1, maxBytes);
|
||||||
|
}
|
||||||
|
return out.length ? out : [''];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkTemplate = (extra) => ({ type: CHUNK_TYPE, text: '', index: 999, total: 999, group: 'g'.repeat(16), ...extra });
|
||||||
|
const chunkBudget = (extra, maxBytes) => maxBytes - contentBytes(chunkTemplate(extra));
|
||||||
|
const headBudget = (content, field, maxBytes, count) => maxBytes - contentBytes({ ...content, [field]: '', chunks: new Array(count).fill(KEY_SAMPLE) });
|
||||||
|
|
||||||
|
const needsChunking = (content, maxBytes = CONTENT_BYTES.plain) => contentBytes(content) > maxBytes;
|
||||||
|
|
||||||
|
const planPieces = (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {} } = {}) => {
|
||||||
|
const text = String(content[field] == null ? '' : content[field]);
|
||||||
|
if (tooLong(text)) throw new Error('Text too long');
|
||||||
|
if (!needsChunking(content, maxBytes)) return null;
|
||||||
|
const perChunk = chunkBudget(extra, maxBytes);
|
||||||
|
if (perChunk < MIN_HEAD_BYTES) throw new Error('Text does not fit');
|
||||||
|
let count = 0;
|
||||||
|
let pieces = null;
|
||||||
|
for (let guard = 0; guard < 12; guard++) {
|
||||||
|
const head = headBudget(content, field, maxBytes, count);
|
||||||
|
if (head < MIN_HEAD_BYTES) throw new Error('Text does not fit');
|
||||||
|
pieces = splitText(text, perChunk, head);
|
||||||
|
if (pieces.length - 1 <= count) return pieces;
|
||||||
|
count = pieces.length - 1;
|
||||||
|
}
|
||||||
|
return pieces;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishChunks = async (pieces, publish, { extra = {}, group } = {}) => {
|
||||||
|
const g = group || crypto.randomBytes(8).toString('hex');
|
||||||
|
const total = pieces.length;
|
||||||
|
const keys = [];
|
||||||
|
for (let i = 0; i < pieces.length; i++) {
|
||||||
|
const r = await publish({ type: CHUNK_TYPE, text: pieces[i], index: i + 1, total, group: g, ...extra });
|
||||||
|
const key = r && typeof r === 'object' ? r.key : r;
|
||||||
|
if (!isChunkKey(key)) throw new Error('Chunk publish failed');
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkContent = async (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {}, publish } = {}) => {
|
||||||
|
const base = { ...content };
|
||||||
|
delete base.chunks;
|
||||||
|
const pieces = planPieces(base, field, { maxBytes, extra });
|
||||||
|
if (!pieces) return base;
|
||||||
|
const keys = await publishChunks(pieces.slice(1), publish, { extra });
|
||||||
|
return { ...base, [field]: pieces[0], chunks: keys };
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkOf = (author, c) => {
|
||||||
|
if (!c || typeof c !== 'object' || typeof c.text !== 'string') return null;
|
||||||
|
if (c.type !== CHUNK_TYPE && c.k !== CHUNK_TYPE) return null;
|
||||||
|
return { author, text: c.text, index: Number(c.index) || 0, total: Number(c.total) || 0, group: String(c.group || '') };
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinText = (head, keys, lookup, author) => {
|
||||||
|
let out = String(head == null ? '' : head);
|
||||||
|
if (!Array.isArray(keys) || !keys.length) return out;
|
||||||
|
let group = null;
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const k = keys[i];
|
||||||
|
if (!isChunkKey(k)) break;
|
||||||
|
const ch = lookup(k);
|
||||||
|
if (!ch || (author && ch.author !== author)) break;
|
||||||
|
if (group === null) group = ch.group; else if (ch.group !== group) break;
|
||||||
|
if (ch.index !== i + 1 || ch.total !== keys.length) break;
|
||||||
|
out += ch.text;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChunks = (content) => !!content && Array.isArray(content.chunks) && content.chunks.length > 0;
|
||||||
|
|
||||||
|
const resolveField = (content, field, author, lookup) => {
|
||||||
|
if (!hasChunks(content)) return content;
|
||||||
|
return { ...content, [field]: joinText(content[field], content.chunks, lookup, author) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const indexChunks = (messages, decode) => {
|
||||||
|
const index = new Map();
|
||||||
|
for (const m of messages || []) {
|
||||||
|
const v = m && m.value;
|
||||||
|
if (!v || !m.key) continue;
|
||||||
|
const raw = v.content;
|
||||||
|
const c = decode ? decode(raw, m) : raw;
|
||||||
|
const ch = chunkOf(v.author, c);
|
||||||
|
if (ch) index.set(m.key, ch);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookupIn = (index) => (k) => index.get(k) || null;
|
||||||
|
|
||||||
|
const caches = new WeakMap();
|
||||||
|
const cacheFor = (ssb) => {
|
||||||
|
let c = caches.get(ssb);
|
||||||
|
if (!c) { c = new Map(); caches.set(ssb, c); }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
const remember = (cache, key, value) => {
|
||||||
|
if (cache.has(key)) cache.delete(key);
|
||||||
|
cache.set(key, value);
|
||||||
|
while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChunk = async (ssb, key, unbox) => {
|
||||||
|
const cache = cacheFor(ssb);
|
||||||
|
if (cache.has(key)) { const v = cache.get(key); cache.delete(key); cache.set(key, v); return v; }
|
||||||
|
const raw = await new Promise((resolve) => { try { ssb.get(key, (err, m) => resolve(err ? null : m)); } catch (_) { resolve(null); } });
|
||||||
|
if (!raw) return null;
|
||||||
|
let content = raw.content;
|
||||||
|
if (typeof content === 'string' && unbox) { try { content = unbox(content); } catch (_) { content = null; } }
|
||||||
|
const ch = chunkOf(raw.author, content);
|
||||||
|
return ch ? remember(cache, key, ch) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinChunks = async (ssb, content, author, { field = 'text', unbox = null } = {}) => {
|
||||||
|
if (!hasChunks(content)) return content;
|
||||||
|
const found = new Map();
|
||||||
|
for (const k of content.chunks) {
|
||||||
|
if (!isChunkKey(k)) break;
|
||||||
|
const ch = await fetchChunk(ssb, k, unbox);
|
||||||
|
if (!ch) break;
|
||||||
|
found.set(k, ch);
|
||||||
|
}
|
||||||
|
return resolveField(content, field, author, lookupIn(found));
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CHUNK_TYPE,
|
||||||
|
TEXT_CAP,
|
||||||
|
CONTENT_BYTES,
|
||||||
|
byteLength,
|
||||||
|
contentBytes,
|
||||||
|
tooLong,
|
||||||
|
splitText,
|
||||||
|
needsChunking,
|
||||||
|
planPieces,
|
||||||
|
publishChunks,
|
||||||
|
chunkContent,
|
||||||
|
chunkOf,
|
||||||
|
joinText,
|
||||||
|
hasChunks,
|
||||||
|
resolveField,
|
||||||
|
indexChunks,
|
||||||
|
lookupIn,
|
||||||
|
joinChunks
|
||||||
|
};
|
||||||
67
src/backend/mobile_layer.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use strict";
|
||||||
|
// Lo que la interfaz de la rama necesita del backend, fuera de backend.js.
|
||||||
|
//
|
||||||
|
// Prepara lo que leen la barra de arriba y el lanzador de hive_views.js: el filtro
|
||||||
|
// Personal/Social, que el lanzador no va en la portada y el avatar y nombre propios. Lo
|
||||||
|
// monta el router propio (fork_routes.js) delante de las rutas del dev. Nada de
|
||||||
|
// esto corre en escritorio. Ver GUIA_DE_DESARROLLO.md.
|
||||||
|
//
|
||||||
|
// Lo demas que el movil necesita del backend (modulos que no van, rutas que se
|
||||||
|
// cortan, retoques del HTML, descargas de la copia de seguridad) lo hace el propio
|
||||||
|
// dev en la version de movil de su backend.js, la que va dentro de su APK.
|
||||||
|
|
||||||
|
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
|
||||||
|
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||||
|
const isPage = (p) => !/^\/(assets|image|blob|qr|c)(\/|$)/.test(p);
|
||||||
|
|
||||||
|
module.exports = ({ getViewerId, about }) => {
|
||||||
|
let avatarAt = 0;
|
||||||
|
const refreshAvatar = async () => {
|
||||||
|
avatarAt = Date.now();
|
||||||
|
try {
|
||||||
|
const me = getViewerId();
|
||||||
|
const img = me ? await about.image(me) : null;
|
||||||
|
if (typeof img === 'string' && img[0] === '&' && img !== NULL_IMG) global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(img)}`;
|
||||||
|
else if (typeof img === 'string' && img[0] === '/') global.__OASIS_SELF_AVATAR__ = img;
|
||||||
|
else global.__OASIS_SELF_AVATAR__ = null;
|
||||||
|
// el nombre, para el bloque de identidad de Ajustes
|
||||||
|
const name = me ? await about.name(me) : null;
|
||||||
|
// sin nombre puesto, ssb-about da un trozo de la clave: eso no es un nombre
|
||||||
|
global.__OASIS_SELF_NAME__ = typeof name === 'string' && name[0] !== '@' && !String(me).startsWith('@' + name) ? name : '';
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// un solo skin en el movil desde el 20/10/2026: quien venga de Acampada u
|
||||||
|
// OasisMobile pasa a Activista (su config se conserva al actualizar la APK)
|
||||||
|
let skinChecked = false;
|
||||||
|
const checkSkin = () => {
|
||||||
|
if (skinChecked) return;
|
||||||
|
skinChecked = true;
|
||||||
|
try {
|
||||||
|
const { getConfig, saveConfig } = require('../configs/config-manager.js');
|
||||||
|
const cfg = getConfig();
|
||||||
|
if ((cfg.themes || {}).current !== 'Activista') { cfg.themes = { ...(cfg.themes || {}), current: 'Activista' }; saveConfig(cfg); }
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return async (ctx, next) => {
|
||||||
|
if (!isMobile() || !isPage(ctx.path)) return next();
|
||||||
|
checkSkin();
|
||||||
|
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
|
||||||
|
// el lanzador ya no va en la portada: la portada es la actividad y los modulos
|
||||||
|
// estan en la hoja de la pestaña Menu (docs/ESTUDIO_NAVEGACION.md)
|
||||||
|
global.__OASIS_SHOW_HIVE__ = false;
|
||||||
|
// comentarios abiertos en las fichas: plegados, la caja de comentar iba tras un
|
||||||
|
// "✑(0)" sin texto y quien comentaba no veia el suyo al volver. Es la opcion del dev
|
||||||
|
// para ?comments=open (comments_view.setCommentsOpen). En el parlamento hay un bloque
|
||||||
|
// por propuesta en la lista: ahi se quedan plegados
|
||||||
|
if (ctx.method === 'GET' && !ctx.path.startsWith('/parliament')) {
|
||||||
|
try { require('../views/comments_view').setCommentsOpen(true); } catch (_) {}
|
||||||
|
}
|
||||||
|
// en Ajustes y en el perfil (adonde vuelve el dev tras /profile/edit) el nombre y el
|
||||||
|
// avatar se leen al momento; en el resto, como mucho cada minuto
|
||||||
|
if (!avatarAt || ctx.path === '/settings' || ctx.path === '/profile') await refreshAvatar();
|
||||||
|
else if (Date.now() - avatarAt > 60000) refreshAvatar();
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -17,4 +17,15 @@ const set = (feedId, name, ts) => {
|
||||||
const has = (feedId) => !!feedId && cache.has(String(feedId))
|
const has = (feedId) => !!feedId && cache.has(String(feedId))
|
||||||
const size = () => cache.size
|
const size = () => cache.size
|
||||||
|
|
||||||
module.exports = { get, set, has, size }
|
const images = new Map()
|
||||||
|
const NULL_IMAGE_RE = /^&0{43}=\.sha256$/
|
||||||
|
|
||||||
|
const image = (feedId) => (feedId && images.get(String(feedId))) || null
|
||||||
|
|
||||||
|
const setImage = (feedId, img) => {
|
||||||
|
const link = typeof img === 'string' ? img : (img && typeof img.link === 'string' ? img.link : null)
|
||||||
|
if (!feedId || !link || !link.startsWith('&') || NULL_IMAGE_RE.test(link)) return
|
||||||
|
images.set(String(feedId), link)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { get, set, has, size, image, setImage }
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const constructive = [
|
||||||
"rejected",
|
"rejected",
|
||||||
"needsSources",
|
"needsSources",
|
||||||
"wrong",
|
"wrong",
|
||||||
|
"outdated",
|
||||||
"lowQuality",
|
"lowQuality",
|
||||||
"confusing",
|
"confusing",
|
||||||
"misleading",
|
"misleading",
|
||||||
|
|
@ -46,5 +47,52 @@ const all = [...positive, ...constructive, ...moderation];
|
||||||
all.positive = positive;
|
all.positive = positive;
|
||||||
all.constructive = constructive;
|
all.constructive = constructive;
|
||||||
all.moderation = moderation;
|
all.moderation = moderation;
|
||||||
|
const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources", "outdated"];
|
||||||
|
const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"];
|
||||||
|
all.karma = {
|
||||||
|
positive,
|
||||||
|
neutral: karmaNeutral,
|
||||||
|
constructive: constructive.filter(c => !karmaNeutral.includes(c) && !karmaModeration.includes(c)),
|
||||||
|
moderation: karmaModeration
|
||||||
|
};
|
||||||
|
all.messageTypes = {
|
||||||
|
bookmark: 'bookmarkOpinion',
|
||||||
|
votes: 'votesOpinion',
|
||||||
|
poll: 'pollOpinion',
|
||||||
|
transfer: 'transferOpinion',
|
||||||
|
feed: 'feedOpinion',
|
||||||
|
post: 'blogOpinion',
|
||||||
|
image: 'imageOpinion',
|
||||||
|
audio: 'audioOpinion',
|
||||||
|
video: 'videoOpinion',
|
||||||
|
document: 'documentOpinion',
|
||||||
|
torrent: 'torrentOpinion',
|
||||||
|
file: 'fileOpinion',
|
||||||
|
industry: 'industryOpinion',
|
||||||
|
project: 'projectOpinion',
|
||||||
|
report: 'reportOpinion',
|
||||||
|
task: 'taskOpinion',
|
||||||
|
event: 'eventOpinion',
|
||||||
|
shopProduct: 'shopOpinion',
|
||||||
|
housing: 'housingOpinion',
|
||||||
|
market: 'marketOpinion',
|
||||||
|
schoolCourse: 'schoolOpinion',
|
||||||
|
podcast: 'podcastOpinion',
|
||||||
|
podcastEpisode: 'podcastOpinion',
|
||||||
|
campaign: 'campaignOpinion',
|
||||||
|
logisticsRoute: 'logisticsOpinion',
|
||||||
|
wikiPage: 'wikiOpinion',
|
||||||
|
map: 'mapOpinion',
|
||||||
|
industryBlueprint: 'blueprintOpinion',
|
||||||
|
job: 'jobOpinion'
|
||||||
|
};
|
||||||
|
all.opinable = (c) => !!c && typeof c === 'object' && !!all.messageTypes[c.type] && !c.tribeId && !c.encrypted && !c.encryptedPayload && !c.contentEncrypted
|
||||||
|
&& (c.type !== 'map' || String(c.mapType || '').toUpperCase() === 'OPEN')
|
||||||
|
&& (c.type !== 'job' || String(c.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN')
|
||||||
|
&& (c.type !== 'schoolCourse' || String(c.visibility || 'PUBLIC').toUpperCase() !== 'INVITE');
|
||||||
|
|
||||||
|
all.hasOpinions = (x) => Object.values((x && x.opinions) || {}).some(n => (Number(n) || 0) > 0);
|
||||||
|
all.MEDIA_LOCKED = 'Cannot replace the file after it has received opinions.';
|
||||||
|
all.assertMediaKept = (item, replacing) => { if (replacing && all.hasOpinions(item)) throw new Error(all.MEDIA_LOCKED); };
|
||||||
|
|
||||||
module.exports = all;
|
module.exports = all;
|
||||||
|
|
|
||||||
1477
src/backend/pdf.js
Normal file
176
src/backend/pdf_view.js
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
const WORKER_TAG = 'oasis-pdf-view';
|
||||||
|
const MUPDF_URL = pathToFileURL(path.join(__dirname, '..', 'server', 'node_modules', 'mupdf', 'dist', 'mupdf.js')).href;
|
||||||
|
const PDF_BASE_WIDTH = 1600;
|
||||||
|
const PDF_FIT_WIDTH = 1200;
|
||||||
|
const PDF_WEBP_EFFORT = 1;
|
||||||
|
const PDF_ZOOMS = [50, 75, 100, 125, 150, 200, 300];
|
||||||
|
const PDF_ZOOM_DEFAULT = 100;
|
||||||
|
const PDF_PAGE_MAX_PX = 4096;
|
||||||
|
const PDF_CALL_MS = 20000;
|
||||||
|
const PDF_DOCS_BYTES = 128 * 1024 * 1024;
|
||||||
|
const PDF_ROTATIONS = [0, 90, 180, 270];
|
||||||
|
const PDF_FRAME_ID_LEN = 12;
|
||||||
|
const PDF_PAGES_CACHE_BYTES = 64 * 1024 * 1024;
|
||||||
|
const PDF_WORKER_HEAP_MB = 256;
|
||||||
|
const PDF_WEBP_QUALITY = 85;
|
||||||
|
const PDF_MAGIC = Buffer.from('%PDF-');
|
||||||
|
const PDF_MAGIC_WINDOW = 1024;
|
||||||
|
|
||||||
|
const widthOf = (zoom) => (zoom <= PDF_ZOOM_DEFAULT ? PDF_FIT_WIDTH : Math.round(PDF_BASE_WIDTH * zoom / 100));
|
||||||
|
const PDF_WIDTHS = new Set(PDF_ZOOMS.map(widthOf));
|
||||||
|
const zoomOf = (value) => { const z = Number(value); return PDF_ZOOMS.includes(z) ? z : PDF_ZOOM_DEFAULT; };
|
||||||
|
const rotationOf = (value) => { const r = Number(value); return PDF_ROTATIONS.includes(r) ? r : 0; };
|
||||||
|
const looksLikePdf = (buf) => Buffer.isBuffer(buf) && buf.subarray(0, PDF_MAGIC_WINDOW).indexOf(PDF_MAGIC) >= 0;
|
||||||
|
|
||||||
|
if (!isMainThread && workerData === WORKER_TAG) {
|
||||||
|
const docs = new Map();
|
||||||
|
const sizes = new Map();
|
||||||
|
let openBytes = 0;
|
||||||
|
let mupdf = null;
|
||||||
|
const need = (key) => {
|
||||||
|
const doc = docs.get(key);
|
||||||
|
if (!doc) throw new Error('NO_DOC');
|
||||||
|
docs.delete(key);
|
||||||
|
docs.set(key, doc);
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
|
const describe = (doc) => ({ pages: doc.countPages() });
|
||||||
|
const ops = {
|
||||||
|
open({ key, data }) {
|
||||||
|
const doc = mupdf.Document.openDocument(Buffer.from(data), 'application/pdf');
|
||||||
|
if (doc.needsPassword()) { doc.destroy(); throw new Error('LOCKED'); }
|
||||||
|
docs.set(key, doc);
|
||||||
|
sizes.set(key, data.byteLength);
|
||||||
|
openBytes += data.byteLength;
|
||||||
|
while (openBytes > PDF_DOCS_BYTES && docs.size > 1) {
|
||||||
|
const [oldKey, oldDoc] = docs.entries().next().value;
|
||||||
|
docs.delete(oldKey);
|
||||||
|
openBytes -= sizes.get(oldKey) || 0;
|
||||||
|
sizes.delete(oldKey);
|
||||||
|
try { oldDoc.destroy(); } catch (_) {}
|
||||||
|
}
|
||||||
|
return describe(doc);
|
||||||
|
},
|
||||||
|
info({ key }) { return describe(need(key)); },
|
||||||
|
render({ key, page, width, rotate }) {
|
||||||
|
const doc = need(key);
|
||||||
|
const pg = doc.loadPage(page - 1);
|
||||||
|
try {
|
||||||
|
const [x0, y0, x1, y1] = pg.getBounds();
|
||||||
|
let scale = width / Math.max(1, x1 - x0);
|
||||||
|
scale = Math.min(scale, PDF_PAGE_MAX_PX / Math.max(1, y1 - y0), PDF_PAGE_MAX_PX / Math.max(1, x1 - x0));
|
||||||
|
const pix = pg.toPixmap(mupdf.Matrix.concat(mupdf.Matrix.scale(scale, scale), mupdf.Matrix.rotate(rotate || 0)), mupdf.ColorSpace.DeviceRGB, false, true);
|
||||||
|
try { return { jpeg: pix.asJPEG(PDF_WEBP_QUALITY, false) }; } finally { pix.destroy(); }
|
||||||
|
} finally { pg.destroy(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const debug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||||
|
const ready = import(MUPDF_URL).then((m) => {
|
||||||
|
mupdf = m;
|
||||||
|
mupdf.setLog(debug ? (message) => console.error(`[pdf] ${message}`) : () => {});
|
||||||
|
});
|
||||||
|
parentPort.on('message', async ({ id, op, args }) => {
|
||||||
|
try {
|
||||||
|
await ready;
|
||||||
|
if (!ops[op]) throw new Error('BAD_OP');
|
||||||
|
const { data, ...rest } = args || {};
|
||||||
|
if (data && !docs.has(rest.key)) ops.open({ key: rest.key, data });
|
||||||
|
const value = op === 'open' ? describe(need(rest.key)) : ops[op](rest);
|
||||||
|
parentPort.postMessage({ id, value });
|
||||||
|
} catch (e) {
|
||||||
|
parentPort.postMessage({ id, error: String((e && e.message) || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let worker = null;
|
||||||
|
let current = null;
|
||||||
|
const queue = [];
|
||||||
|
const stop = () => { const w = worker; worker = null; if (w) w.terminate().catch(() => {}); };
|
||||||
|
const settle = (outcome) => {
|
||||||
|
const job = current;
|
||||||
|
if (!job) return;
|
||||||
|
current = null;
|
||||||
|
clearTimeout(job.timer);
|
||||||
|
if (outcome.error) job.reject(new Error(outcome.error)); else job.resolve(outcome.value);
|
||||||
|
pump();
|
||||||
|
};
|
||||||
|
const start = () => {
|
||||||
|
const w = new Worker(__filename, { workerData: WORKER_TAG, resourceLimits: { maxOldGenerationSizeMb: PDF_WORKER_HEAP_MB } });
|
||||||
|
w.on('message', (msg) => { if (worker === w && current && msg.id === current.id) settle(msg); });
|
||||||
|
const lost = () => { if (worker === w) { worker = null; settle({ error: 'WORKER' }); } };
|
||||||
|
w.on('error', lost);
|
||||||
|
w.on('exit', lost);
|
||||||
|
w.unref();
|
||||||
|
worker = w;
|
||||||
|
};
|
||||||
|
let seq = 0;
|
||||||
|
const pump = () => {
|
||||||
|
if (current || !queue.length) return;
|
||||||
|
if (!worker) start();
|
||||||
|
current = queue.shift();
|
||||||
|
current.timer = setTimeout(() => { stop(); settle({ error: 'TIMEOUT' }); }, PDF_CALL_MS);
|
||||||
|
worker.postMessage({ id: current.id, op: current.op, args: current.args });
|
||||||
|
};
|
||||||
|
const call = (op, args) => new Promise((resolve, reject) => {
|
||||||
|
queue.push({ id: ++seq, op, args, resolve, reject, timer: null });
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
const loading = new Map();
|
||||||
|
const loadPdf = (key, load) => {
|
||||||
|
if (!loading.has(key)) {
|
||||||
|
loading.set(key, (async () => {
|
||||||
|
const data = await load();
|
||||||
|
if (!data) throw new Error('UNAVAILABLE');
|
||||||
|
if (!looksLikePdf(data)) throw new Error('NOT_PDF');
|
||||||
|
return data;
|
||||||
|
})().finally(() => loading.delete(key)));
|
||||||
|
}
|
||||||
|
return loading.get(key);
|
||||||
|
};
|
||||||
|
const withDoc = async (key, load, op, args = {}) => {
|
||||||
|
try { return await call(op, { key, ...args }); } catch (e) { if (e.message !== 'NO_DOC') throw e; }
|
||||||
|
return call(op, { key, ...args, data: await loadPdf(key, load) });
|
||||||
|
};
|
||||||
|
const pages = new Map();
|
||||||
|
let pagesBytes = 0;
|
||||||
|
const remember = (k, v) => {
|
||||||
|
pages.set(k, v);
|
||||||
|
pagesBytes += v.body.length;
|
||||||
|
for (const [old, entry] of pages) {
|
||||||
|
if (pagesBytes <= PDF_PAGES_CACHE_BYTES) break;
|
||||||
|
pages.delete(old);
|
||||||
|
pagesBytes -= entry.body.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const encode = async ({ jpeg }) => ({ body: Buffer.from(jpeg), type: 'image/jpeg' });
|
||||||
|
const drawing = new Map();
|
||||||
|
const keyOf = (text) => crypto.createHash('sha256').update(String(text)).digest('hex');
|
||||||
|
const frameIdOf = (src) => `pdf-${keyOf(src).slice(0, PDF_FRAME_ID_LEN)}`;
|
||||||
|
module.exports = {
|
||||||
|
PDF_BASE_WIDTH, PDF_ZOOMS, PDF_ZOOM_DEFAULT, PDF_ROTATIONS,
|
||||||
|
widthOf, zoomOf, rotationOf, looksLikePdf, keyOf, frameIdOf,
|
||||||
|
isFrameId: (v) => new RegExp(`^pdf-[0-9a-f]{${PDF_FRAME_ID_LEN}}$`).test(String(v || '')),
|
||||||
|
isPdfWidth: (w) => PDF_WIDTHS.has(Number(w)),
|
||||||
|
info: (key, load) => withDoc(key, load, 'info'),
|
||||||
|
async page(key, load, page, width, rotation) {
|
||||||
|
const r = rotationOf(rotation);
|
||||||
|
const cacheKey = `${key}|${page}|${width}|${r}`;
|
||||||
|
const hit = pages.get(cacheKey);
|
||||||
|
if (hit) { pages.delete(cacheKey); pages.set(cacheKey, hit); return hit; }
|
||||||
|
if (!drawing.has(cacheKey)) {
|
||||||
|
drawing.set(cacheKey, (async () => {
|
||||||
|
const out = await encode(await withDoc(key, load, 'render', { page: Number(page), width: Number(width), rotate: r }), r);
|
||||||
|
remember(cacheKey, out);
|
||||||
|
return out;
|
||||||
|
})().finally(() => drawing.delete(cacheKey)));
|
||||||
|
}
|
||||||
|
return drawing.get(cacheKey);
|
||||||
|
},
|
||||||
|
shutdown: () => { stop(); while (queue.length) queue.shift().reject(new Error('STOPPED')); if (current) settle({ error: 'STOPPED' }); }
|
||||||
|
};
|
||||||
|
}
|
||||||
51
src/backend/pm_policy.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
|
||||||
|
|
||||||
|
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
|
||||||
|
|
||||||
|
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
||||||
|
if (recipientId && viewerId && recipientId === viewerId) return true;
|
||||||
|
if (pmVisibility === 'mutuals') return isMutual(relationship);
|
||||||
|
if (pmVisibility === 'following') return !!(relationship && relationship.following);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const announceSeenPath = () => {
|
||||||
|
try { return require('../configs/state-manager').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 };
|
||||||
338
src/backend/renderStyledText.js
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea, object } = require("../server/node_modules/hyperaxe");
|
||||||
|
const i18nBase = require("../client/assets/translations/i18n");
|
||||||
|
const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model");
|
||||||
|
|
||||||
|
let zoomSeq = 0;
|
||||||
|
|
||||||
|
function getI18n() {
|
||||||
|
try {
|
||||||
|
const { i18n } = require("../views/main_views");
|
||||||
|
return i18n;
|
||||||
|
} catch (_) {
|
||||||
|
return i18nBase['en'] || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESCAPE_RE = /\\([\\*_~`#>\-\[\]!])/g;
|
||||||
|
const CODE_BLOCK_RE = /```([\s\S]*?)```[ \t]*(?:\n(?:[ \t]*\n)*|$)/g;
|
||||||
|
const RULE_RE = /^[ \t]*---[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||||
|
const HEADER_CLOSED_RE = /^[ \t]*(#{1,6})[ \t]*([^\n]{1,200}?)[ \t]*\1[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||||
|
const HEADER_SPACED_RE = /^[ \t]*(#{1,6})[ \t]+([^\n]{1,200}?)[ \t]*#*[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||||
|
const QUOTE_RE = /^[ \t]*>[ \t]?([^\n]*)(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||||
|
const BULLET_RE = /^([ \t]*)-[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||||
|
const NUMBER_RE = /^([ \t]*)(\d{1,3})([.)])[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||||
|
const BOLD_RE = /\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g;
|
||||||
|
const UNDERLINE_RE = /(?<![A-Za-z0-9])__(?!\s)((?:[^_\n]|_(?!_))+?)(?<!\s)__(?![A-Za-z0-9])/g;
|
||||||
|
const STRIKE_RE = /~~(?!\s)([^~\n]+?)(?<!\s)~~/g;
|
||||||
|
const ITALIC_RE = /\*(?!\s)((?:[^*\n]|\*\*)+?)(?<!\s)\*(?!\*)/g;
|
||||||
|
const INLINE_CODE_RE = /`([^`\n]+)`/g;
|
||||||
|
const BLOB_IMAGE_RE = /!\[([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||||
|
const BLOB_VIDEO_RE = /\[video:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||||
|
const BLOB_AUDIO_RE = /\[audio:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||||
|
const BLOB_PDF_RE = /\[pdf:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||||
|
const BLOB_TORRENT_RE = /\[torrent:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||||
|
const BLOB_FILE_RE = /(?<!!)\[(?!(?:video|audio|pdf|torrent):)([^\]\n]{1,160})\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||||
|
const MD_MENTION_RE = /\[@([^\]\[\n]{1,120})\]\(@?([A-Za-z0-9+/=.\-]{1,120}\.ed25519)\)/g;
|
||||||
|
const RAW_MENTION_RE = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
|
||||||
|
const BARE_MENTION_RE = /(^|[^\w\u0080-\uFFFF\[\]/@])@([\w\u0080-\uFFFF.\-]{1,64})(?=$|[^\w\u0080-\uFFFF/+=])/g;
|
||||||
|
let mentionResolver = null;
|
||||||
|
const setMentionResolver = (fn) => { mentionResolver = typeof fn === 'function' ? fn : null; };
|
||||||
|
const bareMentionName = (token) => (/\.ed25519$/i.test(token) ? '' : token.replace(/[.\-]+$/, ''));
|
||||||
|
const MSG_REF_RE = /%[A-Za-z0-9+/=]{44}\.sha256/g;
|
||||||
|
const MD_LINK_RE = /!?\[([^\]\n]{1,160})\]\((https?:\/\/[^)\s]+|\/(?![\/\\])[^)\s]*)\)/g;
|
||||||
|
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`]+/gi;
|
||||||
|
const EMAIL_RE = /\b[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,24}\b/g;
|
||||||
|
const HASHTAG_RE = /#[\w\u0080-\uFFFF]{1,32}(?![\w\u0080-\uFFFF])/g;
|
||||||
|
const URL_TAIL_RE = /[.,;:!?»"')\]}>]+$/;
|
||||||
|
const SELF_HOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i;
|
||||||
|
const INTERNAL_PATHS = [
|
||||||
|
'agenda','ai','audios','author','backup','banking','blockexplorer','blogs','bookmarks','calendars','campaigns',
|
||||||
|
'chats','cipher','courts','cv','dev','docs','emergencies','events','favorites','feed','files','forum','games','graphos',
|
||||||
|
'hashtag','housing','images','industry','inbox','inhabitants','invites','jobs','larp','logistics','logs','mailing',
|
||||||
|
'maps','market','melody','mentions','modules','multiverse','opinions','pads','parliament','rooms','peers','pixelia','pm',
|
||||||
|
'podcasts','polls','popular','profile','projects','publish','reports','school','search','settings','shops','spread',
|
||||||
|
'stats','tags','tasks','thread','torrents','transfers','trending','tribes','tribe','videos','votes','votations','wallet','wiki'
|
||||||
|
];
|
||||||
|
const INTERNAL_PATH_SET = new Set(INTERNAL_PATHS);
|
||||||
|
const SSB_ID_RE = /^(?:%[A-Za-z0-9+/=]{20,}\.sha256|@[A-Za-z0-9+/=]{20,}\.ed25519|&[A-Za-z0-9+/=]{20,}\.sha256)$/;
|
||||||
|
|
||||||
|
const internalTargetOf = (url) => {
|
||||||
|
let parsed = null;
|
||||||
|
try { parsed = new URL(url); } catch (_) { return null; }
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||||
|
const segments = parsed.pathname.split('/').filter(Boolean).map(seg => { try { return decodeURIComponent(seg); } catch (_) { return seg; } });
|
||||||
|
if (!segments.length || !INTERNAL_PATH_SET.has(segments[0].toLowerCase())) return null;
|
||||||
|
const sameHost = SELF_HOST_RE.test(url);
|
||||||
|
const hasSsbId = segments.slice(1).some(seg => SSB_ID_RE.test(seg));
|
||||||
|
if (!sameHost && !hasSsbId) return null;
|
||||||
|
const id = segments.slice(1).find(seg => SSB_ID_RE.test(seg)) || segments[1] || '';
|
||||||
|
return { href: `${parsed.pathname}${parsed.search}${parsed.hash}`, kind: segments[0].toLowerCase(), id };
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortInternalId = (id) => {
|
||||||
|
const value = String(id || '');
|
||||||
|
if (!value) return '';
|
||||||
|
const sigil = /^[%@&]/.test(value) ? value[0] : '';
|
||||||
|
const body = sigil ? value.slice(1) : value;
|
||||||
|
return body.length > 8 ? `${sigil}${body.slice(0, 8)}…` : `${sigil}${body}`;
|
||||||
|
};
|
||||||
|
const MAX_DEPTH = 8;
|
||||||
|
|
||||||
|
const headerLevel = (hashes) => Math.min(3, String(hashes || '#').length);
|
||||||
|
|
||||||
|
const indentLevel = (indent) => {
|
||||||
|
const width = String(indent || '').replace(/\t/g, ' ').length;
|
||||||
|
return Math.min(4, Math.floor(width / 2) + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockBody = (raw) => {
|
||||||
|
const s = String(raw);
|
||||||
|
const nl = s.indexOf('\n');
|
||||||
|
if (nl === -1) return s;
|
||||||
|
return s.slice(nl + 1).replace(/\n[ \t]*$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStyledText(value, opts = {}) {
|
||||||
|
if (typeof value !== 'string') return value === null || value === undefined ? [] : [String(value)];
|
||||||
|
const text = value.indexOf('\r') === -1 ? value : value.replace(/\r\n?/g, '\n');
|
||||||
|
const depth = Number(opts.depth) || 0;
|
||||||
|
if (depth > MAX_DEPTH) return [text];
|
||||||
|
const allMatches = [];
|
||||||
|
const push = (m, extra) => allMatches.push({ index: m.index, length: m[0].length, ...extra });
|
||||||
|
for (const m of text.matchAll(ESCAPE_RE)) push(m, { type: 'escaped', body: m[1] });
|
||||||
|
for (const m of text.matchAll(WIKILINK_RE)) push(m, { type: 'wikilink', target: linkTarget(m[1]), label: (m[2] || linkTarget(m[1])).trim() });
|
||||||
|
for (const m of text.matchAll(CODE_BLOCK_RE)) push(m, { type: 'code-block', body: codeBlockBody(m[1]) });
|
||||||
|
for (const m of text.matchAll(RULE_RE)) push(m, { type: 'rule' });
|
||||||
|
for (const m of text.matchAll(HEADER_CLOSED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
|
||||||
|
for (const m of text.matchAll(HEADER_SPACED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
|
||||||
|
for (const m of text.matchAll(QUOTE_RE)) push(m, { type: 'quote', body: m[1] });
|
||||||
|
for (const m of text.matchAll(BULLET_RE)) push(m, { type: 'bullet', level: indentLevel(m[1]), body: m[2] });
|
||||||
|
for (const m of text.matchAll(NUMBER_RE)) push(m, { type: 'number', level: indentLevel(m[1]), marker: `${m[2]}${m[3]}`, body: m[4] });
|
||||||
|
for (const m of text.matchAll(BLOB_IMAGE_RE)) push(m, { type: 'blob-image', name: m[1], blob: m[2] });
|
||||||
|
for (const m of text.matchAll(BLOB_VIDEO_RE)) push(m, { type: 'blob-video', name: m[1], blob: m[2] });
|
||||||
|
for (const m of text.matchAll(BLOB_AUDIO_RE)) push(m, { type: 'blob-audio', name: m[1], blob: m[2] });
|
||||||
|
for (const m of text.matchAll(BLOB_PDF_RE)) push(m, { type: 'blob-pdf', name: m[1], blob: m[2] });
|
||||||
|
for (const m of text.matchAll(BLOB_TORRENT_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
|
||||||
|
for (const m of text.matchAll(BLOB_FILE_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
|
||||||
|
for (const m of text.matchAll(MD_MENTION_RE)) push(m, { type: 'md-mention', name: m[1], feedId: m[2] });
|
||||||
|
for (const m of text.matchAll(RAW_MENTION_RE)) push(m, { type: 'raw-mention', feedId: m[1] });
|
||||||
|
for (const m of text.matchAll(BARE_MENTION_RE)) {
|
||||||
|
const name = bareMentionName(m[2]);
|
||||||
|
if (name) allMatches.push({ index: m.index + m[1].length, length: name.length + 1, type: 'bare-mention', name });
|
||||||
|
}
|
||||||
|
for (const m of text.matchAll(MSG_REF_RE)) push(m, { type: 'msg-ref', id: m[0] });
|
||||||
|
for (const m of text.matchAll(MD_LINK_RE)) push(m, { type: 'md-link', label: m[1], href: m[2] });
|
||||||
|
for (const m of text.matchAll(BOLD_RE)) push(m, { type: 'bold', body: m[1] });
|
||||||
|
for (const m of text.matchAll(UNDERLINE_RE)) push(m, { type: 'underline', body: m[1] });
|
||||||
|
for (const m of text.matchAll(STRIKE_RE)) push(m, { type: 'strike', body: m[1] });
|
||||||
|
for (const m of text.matchAll(ITALIC_RE)) push(m, { type: 'italic', body: m[1] });
|
||||||
|
for (const m of text.matchAll(INLINE_CODE_RE)) push(m, { type: 'inline-code', body: m[1] });
|
||||||
|
for (const m of text.matchAll(URL_RE)) {
|
||||||
|
const trimmed = m[0].replace(URL_TAIL_RE, '');
|
||||||
|
if (trimmed) allMatches.push({ index: m.index, length: trimmed.length, type: 'url', text: trimmed });
|
||||||
|
}
|
||||||
|
for (const m of text.matchAll(EMAIL_RE)) push(m, { type: 'email', text: m[0] });
|
||||||
|
for (const m of text.matchAll(HASHTAG_RE)) push(m, { type: 'hashtag', tag: m[0].slice(1) });
|
||||||
|
allMatches.sort((x, y) => x.index - y.index);
|
||||||
|
const filtered = [];
|
||||||
|
let lastEnd = 0;
|
||||||
|
for (const m of allMatches) {
|
||||||
|
if (m.index < lastEnd) continue;
|
||||||
|
filtered.push(m);
|
||||||
|
lastEnd = m.index + m.length;
|
||||||
|
}
|
||||||
|
const blobHref = (id) => String(id).startsWith('/blob/sealed/') ? String(id) : `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`;
|
||||||
|
const linksOn = opts.links !== false;
|
||||||
|
const internalOn = opts.internalLinks !== false;
|
||||||
|
const plain = (value) => opts.plainUrlClass ? span({ class: opts.plainUrlClass }, value) : value;
|
||||||
|
const inner = (body) => renderStyledText(body, { ...opts, depth: depth + 1 });
|
||||||
|
const result = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const m of filtered) {
|
||||||
|
if (cursor < m.index) result.push(text.slice(cursor, m.index));
|
||||||
|
if (m.type === 'escaped') {
|
||||||
|
result.push(m.body);
|
||||||
|
} else if (m.type === 'msg-ref') {
|
||||||
|
result.push(internalOn ? a({ href: `/thread/${encodeURIComponent(m.id)}`, class: 'styled-link' }, m.id.slice(0, 10) + '...') : m.id);
|
||||||
|
} else if (m.type === 'wikilink') {
|
||||||
|
result.push(typeof opts.wikiLink === 'function'
|
||||||
|
? opts.wikiLink(m.target, slugify(m.target), m.label)
|
||||||
|
: internalOn
|
||||||
|
? a({ href: `/wiki/${encodeURIComponent(slugify(m.target))}`, class: 'wiki-link' }, m.label)
|
||||||
|
: m.label);
|
||||||
|
} else if (m.type === 'code-block') {
|
||||||
|
result.push(span({ class: 'rt-code-block' }, m.body));
|
||||||
|
} else if (m.type === 'rule') {
|
||||||
|
result.push(span({ class: 'rt-rule' }));
|
||||||
|
} else if (m.type === 'header') {
|
||||||
|
result.push(span({ class: `rt-header rt-header-${m.level}` }, ...inner(m.body)));
|
||||||
|
} else if (m.type === 'quote') {
|
||||||
|
result.push(span({ class: 'rt-quote' }, ...inner(m.body)));
|
||||||
|
} else if (m.type === 'bullet') {
|
||||||
|
result.push(span({ class: `rt-item rt-item-${m.level}` }, ...inner(m.body)));
|
||||||
|
} else if (m.type === 'number') {
|
||||||
|
result.push(span({ class: `rt-item rt-item-number rt-item-${m.level}` }, `${m.marker} `, ...inner(m.body)));
|
||||||
|
} else if (m.type === 'bold') {
|
||||||
|
result.push(strong(...inner(m.body)));
|
||||||
|
} else if (m.type === 'underline') {
|
||||||
|
result.push(u(...inner(m.body)));
|
||||||
|
} else if (m.type === 'strike') {
|
||||||
|
result.push(strike(...inner(m.body)));
|
||||||
|
} else if (m.type === 'italic') {
|
||||||
|
result.push(em(...inner(m.body)));
|
||||||
|
} else if (m.type === 'inline-code') {
|
||||||
|
result.push(code({ class: 'rt-code' }, m.body));
|
||||||
|
} else if (m.type === 'blob-image') {
|
||||||
|
const imageSrc = blobHref(m.blob);
|
||||||
|
if (opts.zoomImages) {
|
||||||
|
zoomSeq = (zoomSeq + 1) % 1000000;
|
||||||
|
const zoomId = `rtzoom-${zoomSeq}-${String(m.blob).replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
|
||||||
|
result.push(a({ href: `#${zoomId}`, id: `${zoomId}-src`, class: 'zoom-link' }, img({ src: imageSrc, alt: m.name || '', class: 'post-image' })));
|
||||||
|
result.push(span({ id: zoomId, class: 'lightbox' },
|
||||||
|
a({ href: `${imageSrc}${imageSrc.includes('?') ? '&' : '?'}download=1`, class: 'lightbox-download', title: getI18n().fileShareDownload || 'Download' }, '\u2913'),
|
||||||
|
a({ href: `#${zoomId}-src`, class: 'lightbox-close' }, '\u00d7'),
|
||||||
|
img({ src: imageSrc, alt: m.name || '', class: 'lightbox-image' })
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
result.push(img({ src: imageSrc, alt: m.name || '', class: 'post-image' }));
|
||||||
|
}
|
||||||
|
} else if (m.type === 'blob-video') {
|
||||||
|
result.push(video({ controls: true, class: 'post-video', src: blobHref(m.blob) }));
|
||||||
|
} else if (m.type === 'blob-audio') {
|
||||||
|
result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) }));
|
||||||
|
} else if (m.type === 'blob-pdf') {
|
||||||
|
const i18n = getI18n();
|
||||||
|
const pdfLabel = m.name || i18n.pdfFallbackLabel || 'PDF';
|
||||||
|
const clearnet = String(opts.blobPrefix || '').startsWith('/c/');
|
||||||
|
let lang = '';
|
||||||
|
try { lang = clearnet ? ((require('../models/typed_log').requestScope.getStore() || {}).cnLang || '') : ''; } catch (_) {}
|
||||||
|
const fid = require('./pdf_view').frameIdOf(blobHref(m.blob));
|
||||||
|
const query = new URLSearchParams({ src: blobHref(m.blob), title: String(m.name || ''), ...(lang ? { lang } : {}), fid });
|
||||||
|
const holder = (src, cls) => span({ class: 'pdf-holder' },
|
||||||
|
object({ id: fid, data: src, type: 'text/html', class: cls, title: pdfLabel, 'aria-label': pdfLabel }, ''),
|
||||||
|
a({ href: '#', class: 'pdf-unmax', title: i18n.pdfClose || 'Close' }, '✕'));
|
||||||
|
result.push(clearnet
|
||||||
|
? holder(`/c/pdf/embed?${query}`, 'cn-pdf-frame')
|
||||||
|
: opts.blobPrefix
|
||||||
|
? a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, pdfLabel)
|
||||||
|
: holder(`/pdf/embed?${query}`, 'pdf-frame'));
|
||||||
|
} else if (m.type === 'blob-file') {
|
||||||
|
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || m.blob.slice(1, 9)));
|
||||||
|
} else if (m.type === 'md-mention') {
|
||||||
|
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
|
||||||
|
} else if (m.type === 'raw-mention') {
|
||||||
|
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...') : '@' + m.feedId.slice(0, 8) + '...');
|
||||||
|
} else if (m.type === 'bare-mention') {
|
||||||
|
const feedId = internalOn && mentionResolver ? mentionResolver(m.name) : null;
|
||||||
|
result.push(feedId ? a({ href: `/author/${encodeURIComponent(feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
|
||||||
|
} else if (m.type === 'md-link') {
|
||||||
|
const external = /^https?:\/\//.test(m.href) && !SELF_HOST_RE.test(m.href);
|
||||||
|
result.push(external
|
||||||
|
? (linksOn ? a({ href: m.href, class: 'styled-link', target: '_blank', rel: 'noopener noreferrer' }, ...inner(m.label)) : plain(m.label))
|
||||||
|
: (internalOn ? a({ href: m.href, class: 'styled-link' }, ...inner(m.label)) : m.label));
|
||||||
|
} else if (m.type === 'url') {
|
||||||
|
const schemeMatch = m.text.match(/^(https?):\/\//i);
|
||||||
|
const href = schemeMatch ? `${schemeMatch[1].toLowerCase()}://${m.text.slice(schemeMatch[0].length)}` : `https://${m.text}`;
|
||||||
|
const internalTarget = internalTargetOf(href);
|
||||||
|
if (!linksOn) result.push(plain(m.text));
|
||||||
|
else if (internalTarget) {
|
||||||
|
result.push(internalOn
|
||||||
|
? a({ href: internalTarget.href, class: 'internal-link' },
|
||||||
|
span({ class: 'internal-link-kind' }, internalTarget.kind.toUpperCase()),
|
||||||
|
internalTarget.id ? span({ class: 'internal-link-id' }, shortInternalId(internalTarget.id)) : null
|
||||||
|
)
|
||||||
|
: plain(internalTarget.href));
|
||||||
|
}
|
||||||
|
else if (SELF_HOST_RE.test(href)) result.push(a({ href }, m.text));
|
||||||
|
else result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
|
||||||
|
} else if (m.type === 'email') {
|
||||||
|
result.push(linksOn ? a({ href: `mailto:${m.text}` }, m.text) : plain(m.text));
|
||||||
|
} else if (m.type === 'hashtag') {
|
||||||
|
const tagHref = typeof opts.hashtagHref === 'function' ? opts.hashtagHref(m.tag) : (internalOn ? `/search?query=%23${encodeURIComponent(m.tag)}` : null);
|
||||||
|
result.push(tagHref ? a({ href: tagHref, class: 'tag-link tag-inline' }, `#${m.tag}`) : `#${m.tag}`);
|
||||||
|
}
|
||||||
|
cursor = m.index + m.length;
|
||||||
|
}
|
||||||
|
if (cursor < text.length) result.push(text.slice(cursor));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeExternalHref = (url) => {
|
||||||
|
const value = String(url || '').trim();
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:')) return value;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
function renderStyledHtml(text, opts = {}) {
|
||||||
|
if (text === null || text === undefined || text === '') return '';
|
||||||
|
return renderStyledText(String(text), opts)
|
||||||
|
.map(node => (node && typeof node === 'object' && node.outerHTML) ? node.outerHTML : escapeHtml(node))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainText(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
return String(text)
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/^[ \t]*>[ \t]?/gm, '')
|
||||||
|
.replace(/^[ \t]*---[ \t]*$/gm, '')
|
||||||
|
.replace(/^[ \t]*#{1,6}[ \t]*/gm, '')
|
||||||
|
.replace(/[ \t]*#{1,6}[ \t]*$/gm, '')
|
||||||
|
.replace(/^[ \t]*- /gm, '')
|
||||||
|
.replace(/^[ \t]*\d{1,3}[.)] /gm, '')
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||||
|
.replace(/__(.*?)__/g, '$1')
|
||||||
|
.replace(/~~(.*?)~~/g, '$1')
|
||||||
|
.replace(/\*(.*?)\*/g, '$1')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/!?\[[^\]\n]{0,160}\]\(\s*&[^)\s\n]{1,120}\.sha256\s*\)/g, '')
|
||||||
|
.replace(/\[(?:video|audio|pdf):[^\]\n]{0,160}\]\([^)\n]{0,200}\)/g, '')
|
||||||
|
.replace(/\[\[([^\]|\n]{1,120})(?:\|([^\]\n]{1,120}))?\]\]/g, (m, target, label) => label || target)
|
||||||
|
.replace(/!?\[([^\]\n]{1,160})\]\(([^)\s\n]{0,200})\)/g, (m, label, href) => href ? `${label} (${href})` : label)
|
||||||
|
.replace(/\\([\\*_~`#>\-\[\]!])/g, '$1')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextPreview(text, maxLength = 220) {
|
||||||
|
const preview = plainText(text).replace(/\n+/g, ' ').trim();
|
||||||
|
return preview.length > maxLength ? preview.slice(0, maxLength) + '...' : preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_MARKS = [
|
||||||
|
{ mark: '**bold**', style: 'bold' },
|
||||||
|
{ mark: '*italic*', style: 'italic' },
|
||||||
|
{ mark: '__underline__', style: 'underline' },
|
||||||
|
{ mark: '~~strike~~', style: 'strike' },
|
||||||
|
{ mark: '`code`', style: 'code' },
|
||||||
|
{ mark: '### title ###', style: 'header' },
|
||||||
|
{ mark: '- list', style: 'item' },
|
||||||
|
{ mark: '> quote', style: 'quote' },
|
||||||
|
{ mark: '[[wiki]]', style: 'wiki' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderFormatBar() {
|
||||||
|
const i18n = getI18n();
|
||||||
|
const marks = [];
|
||||||
|
FORMAT_MARKS.forEach((m, i) => {
|
||||||
|
if (i) marks.push(span({ class: 'rt-sep' }, '|'));
|
||||||
|
marks.push(span({ class: `rt-mark rt-mark-${m.style}` }, m.mark));
|
||||||
|
});
|
||||||
|
return div({ class: 'rt-toolbar' },
|
||||||
|
span({ class: 'rt-toolbar-label' }, `${i18n.formatBarLabel || 'Format'}:`),
|
||||||
|
...marks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function richTextarea(attrs, ...children) {
|
||||||
|
return [renderFormatBar(), textarea(attrs, ...children)];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderStyledText, renderStyledHtml, renderTextPreview, plainText, escapeHtml, safeExternalHref, renderFormatBar, richTextarea, setMentionResolver, INTERNAL_PATHS };
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
const i18nBase = require("../client/assets/translations/i18n");
|
|
||||||
|
|
||||||
function getI18n() {
|
|
||||||
try {
|
|
||||||
const { i18n } = require("../views/main_views");
|
|
||||||
return i18n;
|
|
||||||
} catch (_) {
|
|
||||||
return i18nBase['en'] || {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTextPreview(text, maxLength = 220) {
|
|
||||||
if (!text) return ''
|
|
||||||
|
|
||||||
let preview = String(text)
|
|
||||||
|
|
||||||
preview = preview
|
|
||||||
.replace(/```[\s\S]*?```/g, '')
|
|
||||||
.replace(/^>.*$/gm, '')
|
|
||||||
.replace(/^#{1,6}\s*/gm, '')
|
|
||||||
.replace(/^- /gm, '')
|
|
||||||
.replace(/^\d+\. /gm, '')
|
|
||||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
||||||
.replace(/\*(.*?)\*/g, '$1')
|
|
||||||
.replace(/`([^`]+)`/g, '$1')
|
|
||||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
|
||||||
.replace(/\[.*?\]\(.*?\)/g, '')
|
|
||||||
.replace(/\n+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
if (preview.length > maxLength) {
|
|
||||||
preview = preview.slice(0, maxLength) + '...'
|
|
||||||
}
|
|
||||||
|
|
||||||
return preview
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTextWithStyles(text) {
|
|
||||||
if (!text) return ''
|
|
||||||
const i18n = getI18n()
|
|
||||||
|
|
||||||
let html = String(text)
|
|
||||||
|
|
||||||
html = html
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
|
|
||||||
html = html
|
|
||||||
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
|
|
||||||
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
|
|
||||||
.replace(/^---$/gim, '<hr/>')
|
|
||||||
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
|
|
||||||
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
|
|
||||||
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
|
|
||||||
|
|
||||||
html = html
|
|
||||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
|
||||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
|
||||||
.replace(/`([^`]+)`/gim, '<code>$1</code>')
|
|
||||||
|
|
||||||
html = html
|
|
||||||
.replace(/!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
|
|
||||||
`<img src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" alt="${alt}" class="post-image" />`
|
|
||||||
)
|
|
||||||
.replace(/\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
|
||||||
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></video>`
|
|
||||||
)
|
|
||||||
.replace(/\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
|
||||||
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></audio>`
|
|
||||||
)
|
|
||||||
.replace(/\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
|
|
||||||
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
|
|
||||||
)
|
|
||||||
|
|
||||||
html = html
|
|
||||||
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
|
|
||||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
|
|
||||||
)
|
|
||||||
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
|
|
||||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
|
|
||||||
)
|
|
||||||
|
|
||||||
const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''')
|
|
||||||
html = html
|
|
||||||
.replace(/#(\w+)/g, (_, tag) =>
|
|
||||||
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
|
|
||||||
)
|
|
||||||
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
|
|
||||||
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
|
|
||||||
)
|
|
||||||
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
|
|
||||||
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
|
|
||||||
)
|
|
||||||
|
|
||||||
const lines = html.split('\n')
|
|
||||||
let result = ''
|
|
||||||
let inUL = false
|
|
||||||
let inOL = false
|
|
||||||
|
|
||||||
for (let line of lines) {
|
|
||||||
if (/^- /.test(line)) {
|
|
||||||
if (!inUL) {
|
|
||||||
result += '<ul>'
|
|
||||||
inUL = true
|
|
||||||
}
|
|
||||||
result += `<li>${line.replace(/^- /, '')}</li>`
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\d+\. /.test(line)) {
|
|
||||||
if (!inOL) {
|
|
||||||
result += '<ol>'
|
|
||||||
inOL = true
|
|
||||||
}
|
|
||||||
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inUL) {
|
|
||||||
result += '</ul>'
|
|
||||||
inUL = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inOL) {
|
|
||||||
result += '</ol>'
|
|
||||||
inOL = false
|
|
||||||
}
|
|
||||||
|
|
||||||
result += line + '<br>'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inUL) result += '</ul>'
|
|
||||||
if (inOL) result += '</ol>'
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { renderTextWithStyles, renderTextPreview }
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
const { a, img, video, audio } = require("../server/node_modules/hyperaxe");
|
|
||||||
const i18nBase = require("../client/assets/translations/i18n");
|
|
||||||
|
|
||||||
function getI18n() {
|
|
||||||
try {
|
|
||||||
const { i18n } = require("../views/main_views");
|
|
||||||
return i18n;
|
|
||||||
} catch (_) {
|
|
||||||
return i18nBase['en'] || {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUrl(text) {
|
|
||||||
if (typeof text !== 'string') return [text];
|
|
||||||
const blobImageRegex = /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
|
||||||
const blobVideoRegex = /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
|
||||||
const blobAudioRegex = /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
|
||||||
const blobPdfRegex = /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
|
||||||
const mdMentionRegex = /\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
|
|
||||||
const rawMentionRegex = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
|
|
||||||
const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
|
|
||||||
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
||||||
const allMatches = [];
|
|
||||||
for (const m of text.matchAll(blobImageRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-image', name: m[1], blob: m[2] });
|
|
||||||
}
|
|
||||||
for (const m of text.matchAll(blobVideoRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-video', name: m[1], blob: m[2] });
|
|
||||||
}
|
|
||||||
for (const m of text.matchAll(blobAudioRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-audio', name: m[1], blob: m[2] });
|
|
||||||
}
|
|
||||||
for (const m of text.matchAll(blobPdfRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-pdf', name: m[1], blob: m[2] });
|
|
||||||
}
|
|
||||||
for (const m of text.matchAll(mdMentionRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'md-mention', name: m[1], feedId: m[2] });
|
|
||||||
}
|
|
||||||
for (const m of text.matchAll(rawMentionRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'raw-mention', feedId: m[1] });
|
|
||||||
}
|
|
||||||
for (const m of text.matchAll(urlRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'url', text: m[0] });
|
|
||||||
}
|
|
||||||
for (const m of text.matchAll(emailRegex)) {
|
|
||||||
allMatches.push({ index: m.index, length: m[0].length, type: 'email', text: m[0] });
|
|
||||||
}
|
|
||||||
allMatches.sort((a, b) => a.index - b.index);
|
|
||||||
const filtered = [];
|
|
||||||
let lastEnd = 0;
|
|
||||||
for (const m of allMatches) {
|
|
||||||
if (m.index < lastEnd) continue;
|
|
||||||
filtered.push(m);
|
|
||||||
lastEnd = m.index + m.length;
|
|
||||||
}
|
|
||||||
const result = [];
|
|
||||||
let cursor = 0;
|
|
||||||
for (const m of filtered) {
|
|
||||||
if (cursor < m.index) {
|
|
||||||
result.push(text.slice(cursor, m.index));
|
|
||||||
}
|
|
||||||
if (m.type === 'blob-image') {
|
|
||||||
result.push(img({ src: `/blob/${encodeURIComponent(m.blob)}`, alt: m.name || '', class: 'post-image' }));
|
|
||||||
} else if (m.type === 'blob-video') {
|
|
||||||
result.push(video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(m.blob)}` }));
|
|
||||||
} else if (m.type === 'blob-audio') {
|
|
||||||
result.push(audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(m.blob)}` }));
|
|
||||||
} else if (m.type === 'blob-pdf') {
|
|
||||||
const i18n = getI18n();
|
|
||||||
const label = m.name || i18n.pdfFallbackLabel || 'PDF';
|
|
||||||
result.push(a({ href: `/blob/${encodeURIComponent(m.blob)}`, class: 'post-pdf', target: '_blank' }, label));
|
|
||||||
} else if (m.type === 'md-mention') {
|
|
||||||
const feedWithAt = '@' + m.feedId;
|
|
||||||
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.name));
|
|
||||||
} else if (m.type === 'raw-mention') {
|
|
||||||
const feedWithAt = '@' + m.feedId;
|
|
||||||
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...'));
|
|
||||||
} else if (m.type === 'url') {
|
|
||||||
const href = m.text.startsWith('http') ? m.text : `https://${m.text}`;
|
|
||||||
result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
|
|
||||||
} else if (m.type === 'email') {
|
|
||||||
result.push(a({ href: `mailto:${m.text}` }, m.text));
|
|
||||||
}
|
|
||||||
cursor = m.index + m.length;
|
|
||||||
}
|
|
||||||
if (cursor < text.length) {
|
|
||||||
result.push(text.slice(cursor));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { renderUrl };
|
|
||||||
102
src/backend/request_guards.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
const MSG_KEY_RE = /^%[A-Za-z0-9+/]{43}=\.sha256$/;
|
||||||
|
|
||||||
|
const isMsgKey = (value) => typeof value === 'string' && MSG_KEY_RE.test(value);
|
||||||
|
|
||||||
|
const pickMsgKeys = (value) => {
|
||||||
|
const list = Array.isArray(value) ? value : (value == null ? [] : [value]);
|
||||||
|
return Array.from(new Set(list.filter(isMsgKey)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocalPath = (value) => {
|
||||||
|
if (typeof value !== 'string' || !value.startsWith('/')) return false;
|
||||||
|
if (value.startsWith('//') || value.startsWith('/\\')) return false;
|
||||||
|
if (/[\r\n\0]/.test(value)) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHARED_RETURN_PAGES = ['/activity', '/agenda', '/search', '/trending', '/favorites', '/opinions', '/mentions', '/melody', '/author', '/inhabitant', '/tribe'];
|
||||||
|
const isSharedReturnPage = (rt) => SHARED_RETURN_PAGES.some(p => rt === p || (rt.startsWith(p) && /^[?#/]/.test(rt.slice(p.length))));
|
||||||
|
const safeReturnTo = (ctx, fallback, allowedPrefixes) => {
|
||||||
|
const rt = ctx?.request?.body?.returnTo || ctx?.query?.returnTo;
|
||||||
|
if (!isLocalPath(rt)) return fallback;
|
||||||
|
if (isSharedReturnPage(rt)) return rt;
|
||||||
|
return Array.isArray(allowedPrefixes) && allowedPrefixes.some(p => rt.startsWith(p)) ? rt : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refererPath = (referer, host) => {
|
||||||
|
if (!referer) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(referer);
|
||||||
|
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== host) return null;
|
||||||
|
const target = u.pathname + u.search + u.hash;
|
||||||
|
return isLocalPath(target) ? target : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeRefererRedirect = (ctx, fallback = '/') => {
|
||||||
|
const target = refererPath(ctx.request.header.referer, ctx.host);
|
||||||
|
ctx.redirect(target || fallback);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isClearnetPath = (request) => {
|
||||||
|
const url = String((request && request.url) || '');
|
||||||
|
return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTrustedRequest = (request, validHosts) => {
|
||||||
|
if (isClearnetPath(request)) return request.method === 'GET';
|
||||||
|
if (!Array.isArray(validHosts) || !validHosts.includes(request.hostname)) return false;
|
||||||
|
if (request.method === 'GET') return true;
|
||||||
|
const origin = request.header && request.header.origin;
|
||||||
|
const source = origin && origin !== 'null' ? origin : (request.header && request.header.referer);
|
||||||
|
if (source == null) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(source);
|
||||||
|
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || !validHosts.includes(u.hostname)) return false;
|
||||||
|
if (u.host !== String(request.host || '')) return false;
|
||||||
|
if (u.pathname.startsWith('/blob/')) return false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicModeGuard = ({ isPublic, onBlocked }) => async (ctx, next) => {
|
||||||
|
if (isPublic && ctx.method !== 'GET') { onBlocked(ctx); return; }
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCsp = (isClearnet, { frames = '', framed = false, objects = '' } = {}) => (isClearnet
|
||||||
|
? [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
frames ? `frame-src ${frames}` : "frame-src 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
objects ? `object-src ${objects}` : "object-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'"
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'self'",
|
||||||
|
"img-src 'self'",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
frames ? `frame-src ${frames}` : "frame-src 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
objects ? `object-src ${objects}` : "object-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'"
|
||||||
|
]).join('; ');
|
||||||
|
|
||||||
|
const IMAGE_SIZE_MIN = 16;
|
||||||
|
const IMAGE_SIZE_MAX = 1024;
|
||||||
|
const isImageSize = (value) => { const n = Number(value); return Number.isInteger(n) && n >= IMAGE_SIZE_MIN && n <= IMAGE_SIZE_MAX; };
|
||||||
|
|
||||||
|
module.exports = { IMAGE_SIZE_MIN, IMAGE_SIZE_MAX, isImageSize, isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };
|
||||||
|
|
@ -1,14 +1,32 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const SAFE_SRC = /^\/(?:blob|image)\/[^\s"'<>]+$/i;
|
||||||
|
const SAFE_HREF = /^(?:https?:\/\/|\/(?!\/)|#|mailto:)/i;
|
||||||
|
const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|tag-inline|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/;
|
||||||
|
let purifier = null;
|
||||||
|
const getPurify = () => {
|
||||||
|
if (purifier) return purifier;
|
||||||
const { JSDOM } = require('../server/node_modules/jsdom');
|
const { JSDOM } = require('../server/node_modules/jsdom');
|
||||||
const DOMPurify = require('../server/node_modules/dompurify');
|
const DOMPurify = require('../server/node_modules/dompurify');
|
||||||
const window = new JSDOM('').window;
|
const purify = DOMPurify(new JSDOM('').window);
|
||||||
const purify = DOMPurify(window);
|
purify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||||
|
const name = String(data.attrName || '').toLowerCase();
|
||||||
|
const value = String(data.attrValue || '').trim();
|
||||||
|
if (name === 'src' && !SAFE_SRC.test(value)) data.keepAttr = false;
|
||||||
|
if (name === 'href' && !SAFE_HREF.test(value)) data.keepAttr = false;
|
||||||
|
if (name === 'class') {
|
||||||
|
const kept = value.split(/\s+/).filter(c => SAFE_CLASS.test(c));
|
||||||
|
if (kept.length) data.attrValue = kept.join(' '); else data.keepAttr = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
purifier = purify;
|
||||||
|
return purifier;
|
||||||
|
};
|
||||||
|
const dropControlChars = (html) => String(html).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
||||||
|
|
||||||
const stripDangerousTags = (input) => {
|
const stripDangerousTags = (input) => {
|
||||||
if (typeof input !== 'string') return '';
|
if (typeof input !== 'string') return '';
|
||||||
return purify.sanitize(input, {
|
return dropControlChars(getPurify().sanitize(input, {
|
||||||
USE_PROFILES: { html: true },
|
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
'p', 'br',
|
'p', 'br',
|
||||||
'b', 'strong', 'i', 'em', 'u',
|
'b', 'strong', 'i', 'em', 'u',
|
||||||
|
|
@ -19,13 +37,12 @@ const stripDangerousTags = (input) => {
|
||||||
ALLOWED_ATTR: [],
|
ALLOWED_ATTR: [],
|
||||||
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
|
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
|
||||||
FORBID_ATTR: ['style']
|
FORBID_ATTR: ['style']
|
||||||
});
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeHtml = (input) => {
|
const sanitizeHtml = (input) => {
|
||||||
if (typeof input !== 'string') return '';
|
if (typeof input !== 'string') return '';
|
||||||
return purify.sanitize(input, {
|
return dropControlChars(getPurify().sanitize(input, {
|
||||||
USE_PROFILES: { html: true },
|
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
'p', 'br', 'hr',
|
'p', 'br', 'hr',
|
||||||
'b', 'strong', 'i', 'em', 'u', 's', 'del',
|
'b', 'strong', 'i', 'em', 'u', 's', 'del',
|
||||||
|
|
@ -39,7 +56,7 @@ const sanitizeHtml = (input) => {
|
||||||
ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
|
ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
|
||||||
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
|
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
|
||||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
|
||||||
});
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { stripDangerousTags, sanitizeHtml };
|
module.exports = { stripDangerousTags, sanitizeHtml };
|
||||||
|
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
|
||||||
|
|
||||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
|
||||||
|
|
||||||
const wrap = (txt, max = 82) => {
|
|
||||||
const out = [];
|
|
||||||
for (const raw of String(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;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildSmartContractPdf({ transfer, block, viewerId }) {
|
|
||||||
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 t = transfer || {};
|
|
||||||
const b = block || {};
|
|
||||||
const fmt = v => (v === undefined || v === null) ? '' : String(v);
|
|
||||||
const fmtAmount = () => {
|
|
||||||
const cat = String(t.category || 'ECONOMIC').toUpperCase();
|
|
||||||
const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
|
|
||||||
return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
|
|
||||||
};
|
|
||||||
const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
|
|
||||||
const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
|
|
||||||
const required = t.from === t.to ? 1 : 2;
|
|
||||||
const confirmedCount = confirmedBy.length;
|
|
||||||
const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
|
|
||||||
|
|
||||||
const sections = [];
|
|
||||||
sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
|
|
||||||
sections.push({ kind: 'blank' });
|
|
||||||
|
|
||||||
sections.push({ kind: 'section', text: 'OASIS IDs' });
|
|
||||||
sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) });
|
|
||||||
sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) });
|
|
||||||
sections.push({ kind: 'blank' });
|
|
||||||
|
|
||||||
sections.push({ kind: 'section', text: 'TERMS' });
|
|
||||||
sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
|
|
||||||
if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
|
|
||||||
sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
|
|
||||||
if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
|
|
||||||
if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
|
|
||||||
sections.push({ kind: 'blank' });
|
|
||||||
|
|
||||||
sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
|
|
||||||
sections.push({ kind: 'kv', label: 'Required', value: String(required) });
|
|
||||||
sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
|
|
||||||
if (confirmedBy.length) {
|
|
||||||
for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
|
|
||||||
}
|
|
||||||
sections.push({ kind: 'blank' });
|
|
||||||
|
|
||||||
sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
|
|
||||||
if (b && b.id) {
|
|
||||||
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
|
|
||||||
if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
|
|
||||||
if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
|
|
||||||
if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
|
|
||||||
if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
|
|
||||||
} else {
|
|
||||||
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
|
|
||||||
}
|
|
||||||
sections.push({ kind: 'blank' });
|
|
||||||
|
|
||||||
sections.push({ kind: 'section', text: 'METADATA' });
|
|
||||||
sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
|
|
||||||
if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
|
|
||||||
if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
|
|
||||||
|
|
||||||
const lines = [];
|
|
||||||
for (const s of sections) {
|
|
||||||
if (s.kind === 'kv') {
|
|
||||||
const txt = `${s.label}: ${s.value}`;
|
|
||||||
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
|
|
||||||
} else {
|
|
||||||
lines.push(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 >>');
|
|
||||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
|
||||||
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('OASIS - Smart Contract')}) Tj\nET`);
|
|
||||||
const prefix = '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(viewerId || ''))}) 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)} >>\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 = { buildSmartContractPdf };
|
|
||||||
|
|
@ -1,12 +1,50 @@
|
||||||
const fetch = require('../server/node_modules/node-fetch');
|
|
||||||
const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
|
const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
|
||||||
const { join } = require('path');
|
const { join } = require('path');
|
||||||
|
|
||||||
const localpackage = join(__dirname, '../server/package.json');
|
const localpackage = join(__dirname, '../server/package.json');
|
||||||
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json'; // Official SNH-Oasis
|
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json';
|
||||||
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis
|
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json';
|
||||||
|
|
||||||
let printed = false;
|
let printed = false;
|
||||||
|
const repoRoot = join(__dirname, '..', '..');
|
||||||
|
const updateFlagFile = join(__dirname, '../server/.update_required');
|
||||||
|
|
||||||
|
const git = (args, cwd = repoRoot) => new Promise((resolve) => {
|
||||||
|
require('child_process').execFile('git', args, { cwd, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (err, out) => resolve(err ? '' : String(out || '').trim()));
|
||||||
|
});
|
||||||
|
const commitOf = (line) => {
|
||||||
|
const [hash, date, ...rest] = String(line || '').split('\t');
|
||||||
|
return hash ? { hash, date: date || '', subject: rest.join('\t') } : null;
|
||||||
|
};
|
||||||
|
const cleanOrigin = (url) => String(url || '').replace(/\/\/[^@\/]*@/, '//').replace(/\.git$/, '');
|
||||||
|
|
||||||
|
async function collectDetails(root = repoRoot) {
|
||||||
|
if (!existsSync(join(root, '.git'))) return null;
|
||||||
|
const installed = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s'], root));
|
||||||
|
await git(['fetch', '--quiet'], root);
|
||||||
|
const upstream = (await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], root)) || 'origin/master';
|
||||||
|
const available = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s', upstream], root));
|
||||||
|
const commits = (await git(['log', '--format=%h%x09%cI%x09%s', '-n', '10', `HEAD..${upstream}`], root)).split('\n').map(commitOf).filter(Boolean);
|
||||||
|
const count = Number(await git(['rev-list', '--count', `HEAD..${upstream}`], root)) || commits.length;
|
||||||
|
const stat = await git(['diff', '--shortstat', 'HEAD', upstream], root);
|
||||||
|
const num = (re) => { const m = stat.match(re); return m ? Number(m[1]) : 0; };
|
||||||
|
return {
|
||||||
|
installed, available, commits, count,
|
||||||
|
files: num(/(\d+) files? changed/), insertions: num(/(\d+) insertions?/), deletions: num(/(\d+) deletions?/),
|
||||||
|
origin: cleanOrigin(await git(['remote', 'get-url', 'origin'], root))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
exports.collectDetails = collectDetails;
|
||||||
|
|
||||||
|
exports.readUpdateInfo = () => {
|
||||||
|
if (!existsSync(updateFlagFile)) return null;
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(readFileSync(updateFlagFile, 'utf8'));
|
||||||
|
return info && info.required ? info : { required: true };
|
||||||
|
} catch (_) {
|
||||||
|
return { required: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function extractVersionFromText(text) {
|
async function extractVersionFromText(text) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -33,11 +71,22 @@ async function diffVersion(body, callback) {
|
||||||
const updateFlagPath = join(__dirname, "../server/.update_required");
|
const updateFlagPath = join(__dirname, "../server/.update_required");
|
||||||
|
|
||||||
if (remoteVersion !== localVersion) {
|
if (remoteVersion !== localVersion) {
|
||||||
writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
|
const alreadyKnown = existsSync(updateFlagPath);
|
||||||
|
writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion }));
|
||||||
|
collectDetails().then((details) => {
|
||||||
|
if (details && existsSync(updateFlagPath)) writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion, details }));
|
||||||
|
}).catch(() => {});
|
||||||
|
if (!alreadyKnown) {
|
||||||
|
try {
|
||||||
|
const { notify, i18nNow } = require('./desktopNotify');
|
||||||
|
const i18n = i18nNow();
|
||||||
|
notify(i18n.notifyUpdateLabel, `${i18n.updateBannerText} (${remoteVersion})`);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
callback("required");
|
callback("required");
|
||||||
} else {
|
} else {
|
||||||
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
|
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
|
||||||
callback(""); // no updates required
|
callback("");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error comparing versions:", error.message);
|
console.error("Error comparing versions:", error.message);
|
||||||
|
|
@ -49,15 +98,8 @@ async function checkMirror(callback) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(remoteUrl2, {
|
const response = await fetch(remoteUrl2, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: { 'Accept': 'application/json, text/plain, */*' },
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
signal: AbortSignal.timeout(20000)
|
||||||
'Accept': 'application/json, text/plain, */*',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Referer': 'https://raw.githubusercontent.com',
|
|
||||||
'Origin': 'https://raw.githubusercontent.com'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -72,20 +114,154 @@ async function checkMirror(callback) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const networkAllowed = () => {
|
||||||
|
if (process.argv.includes('--offline') || process.env.OASIS_NETWORK_PAUSED === '1') return false;
|
||||||
|
try { if (require('../server/ssb_config').offline === true) return false; } catch (_) {}
|
||||||
|
try { if (require('../configs/config-manager.js').getConfig().networkPaused === true) return false; } catch (_) {}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOBILE_RELEASE_SOURCES = [
|
||||||
|
{ url: 'https://code.03c8.net/KrakensLab/oasis/releases', type: 'html' },
|
||||||
|
{ url: 'https://api.github.com/repos/epsylon/oasis/releases?per_page=20', type: 'json' }
|
||||||
|
];
|
||||||
|
const MOBILE_APK_RE = /^oasis-v(\d+\.\d+\.\d+)\.apk$/i;
|
||||||
|
const MOBILE_APK_HTML_RE = /oasis-v(\d+\.\d+\.\d+)\.apk/i;
|
||||||
|
const ECOIN_RELEASES_API = 'https://api.github.com/repos/epsylon/ecoin/releases?per_page=20';
|
||||||
|
const ECOIN_RELEASES_PAGE = 'https://github.com/epsylon/ecoin/releases';
|
||||||
|
const ECOIN_APK_RE = /^ecoin-wallet-v(\d+(?:\.\d+)*)\.apk$/i;
|
||||||
|
const MOBILE_FETCH_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9'
|
||||||
|
};
|
||||||
|
|
||||||
|
let ecoinWalletApk = '';
|
||||||
|
|
||||||
|
const versionParts = (v) => String(v || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||||
|
|
||||||
|
const isNewerVersion = (remote, local) => {
|
||||||
|
const a = versionParts(remote), b = versionParts(local);
|
||||||
|
const len = Math.max(a.length, b.length, 3);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localMobileVersion = () => {
|
||||||
|
const fromEnv = String(process.env.OASIS_ANDROID_VERSION || '').split('-')[0].trim();
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(localpackage, 'utf8')).version || '';
|
||||||
|
} catch (error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickReleaseFromJson = (releases) => {
|
||||||
|
for (const rel of Array.isArray(releases) ? releases : []) {
|
||||||
|
if (!rel || rel.draft) continue;
|
||||||
|
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
|
||||||
|
const match = MOBILE_APK_RE.exec(String((asset && asset.name) || ''));
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
version: match[1],
|
||||||
|
page: String(rel.html_url || ''),
|
||||||
|
apk: String(asset.browser_download_url || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickReleaseFromHtml = (html, pageUrl) => {
|
||||||
|
const match = MOBILE_APK_HTML_RE.exec(String(html || ''));
|
||||||
|
return match ? { version: match[1], page: pageUrl, apk: '' } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMobileRelease = async (source) => {
|
||||||
|
const isHtml = source.type === 'html';
|
||||||
|
const response = await fetch(source.url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': isHtml ? 'text/html,application/xhtml+xml' : 'application/json, text/plain, */*' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
return isHtml
|
||||||
|
? pickReleaseFromHtml(await response.text(), source.url)
|
||||||
|
: pickReleaseFromJson(await response.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshEcoinWalletApk = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(ECOIN_RELEASES_API, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': 'application/json, text/plain, */*' }
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const releases = await response.json();
|
||||||
|
let best = null;
|
||||||
|
for (const rel of Array.isArray(releases) ? releases : []) {
|
||||||
|
if (!rel || rel.draft) continue;
|
||||||
|
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
|
||||||
|
const match = ECOIN_APK_RE.exec(String((asset && asset.name) || ''));
|
||||||
|
if (!match || !asset.browser_download_url) continue;
|
||||||
|
if (!best || isNewerVersion(match[1], best.version)) best = { version: match[1], apk: String(asset.browser_download_url) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) ecoinWalletApk = best.apk;
|
||||||
|
} catch (error) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.ecoinWalletDownloadUrl = () => {
|
||||||
|
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return ECOIN_RELEASES_PAGE;
|
||||||
|
if (!ecoinWalletApk) refreshEcoinWalletApk();
|
||||||
|
return ecoinWalletApk || ECOIN_RELEASES_PAGE;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getMobileRemoteVersion = async () => {
|
||||||
|
await refreshEcoinWalletApk();
|
||||||
|
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return;
|
||||||
|
const localVersion = localMobileVersion();
|
||||||
|
if (!localVersion) return;
|
||||||
|
const updateFlagPath = join(__dirname, '../server/.update_required');
|
||||||
|
const found = [];
|
||||||
|
for (const source of MOBILE_RELEASE_SOURCES) {
|
||||||
|
try {
|
||||||
|
const candidate = await fetchMobileRelease(source);
|
||||||
|
if (candidate) found.push(candidate);
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
if (!found.length) return;
|
||||||
|
const top = found.reduce((best, r) => (isNewerVersion(r.version, best.version) ? r : best), found[0]);
|
||||||
|
const sameVersion = found.filter(r => r.version === top.version);
|
||||||
|
const withApk = sameVersion.find(r => r.apk);
|
||||||
|
const release = { version: top.version, page: sameVersion[0].page, apk: withApk ? withApk.apk : '' };
|
||||||
|
if (isNewerVersion(release.version, localVersion)) {
|
||||||
|
try {
|
||||||
|
writeFileSync(updateFlagPath, JSON.stringify({
|
||||||
|
required: true,
|
||||||
|
version: release.version,
|
||||||
|
installedVersion: localVersion,
|
||||||
|
page: release.page || release.apk,
|
||||||
|
apk: release.apk
|
||||||
|
}));
|
||||||
|
} catch (error) {}
|
||||||
|
console.log(`\noasis@version: Oasis ${release.version} is available at ${release.apk || release.page}\n`);
|
||||||
|
} else if (existsSync(updateFlagPath)) {
|
||||||
|
try { unlinkSync(updateFlagPath); } catch (error) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
exports.getRemoteVersion = async () => {
|
exports.getRemoteVersion = async () => {
|
||||||
if (existsSync('../../.git')) {
|
if (process.env.OASIS_MOBILE === '1') return exports.getMobileRemoteVersion();
|
||||||
|
if (!networkAllowed()) return;
|
||||||
|
if (existsSync(join(__dirname, '..', '..', '.git'))) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(remoteUrl, {
|
const response = await fetch(remoteUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: { 'Accept': 'application/json, text/plain, */*' },
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
signal: AbortSignal.timeout(20000)
|
||||||
'Accept': 'application/json, text/plain, */*',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Referer': 'https://code.03c8.net',
|
|
||||||
'Origin': 'https://code.03c8.net'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,119 @@
|
||||||
const buildVoteTally = (messages) => {
|
const INSTITUTIONAL_VOTE_TAG = /^(gov|govMethod|courtsCase|courtsMethod):/;
|
||||||
const nodes = new Map();
|
|
||||||
const ballots = [];
|
|
||||||
|
|
||||||
for (const m of messages) {
|
const msOf = (v) => {
|
||||||
|
const t = Date.parse(v);
|
||||||
|
return Number.isFinite(t) ? t : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLater = (a, b) => a.ts > b.ts || (a.ts === b.ts && a.seq > b.seq);
|
||||||
|
|
||||||
|
const legacyChoice = (kid, parent, options) => {
|
||||||
|
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters : [];
|
||||||
|
const pVoters = Array.isArray(parent.content.voters) ? parent.content.voters : [];
|
||||||
|
if (!kVoters.includes(kid.author) || pVoters.includes(kid.author)) return null;
|
||||||
|
const kVotes = kid.content.votes || {};
|
||||||
|
const pVotes = parent.content.votes || {};
|
||||||
|
let choice = null;
|
||||||
|
for (const o of options) {
|
||||||
|
const d = (Number(kVotes[o]) || 0) - (Number(pVotes[o]) || 0);
|
||||||
|
if (d === 0) continue;
|
||||||
|
if (d !== 1 || choice !== null) return null;
|
||||||
|
choice = o;
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildVoteResults = (messages) => {
|
||||||
|
const nodes = new Map();
|
||||||
|
const ballotsByTarget = new Map();
|
||||||
|
|
||||||
|
for (const m of messages || []) {
|
||||||
const v = m && m.value;
|
const v = m && m.value;
|
||||||
const c = v && v.content;
|
const c = v && v.content;
|
||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
if (c.type === 'votesVote') { if (c.target) ballots.push({ target: c.target, author: v.author, choice: c.choice }); continue; }
|
const ts = Number(v.timestamp || m.timestamp || 0) || 0;
|
||||||
|
const seq = Number(v.sequence || 0) || 0;
|
||||||
|
if (c.type === 'votesVote') {
|
||||||
|
if (typeof c.target !== 'string' || !v.author) continue;
|
||||||
|
if (!ballotsByTarget.has(c.target)) ballotsByTarget.set(c.target, []);
|
||||||
|
ballotsByTarget.get(c.target).push({ author: v.author, choice: c.choice, ts, seq });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (c.type !== 'votes') continue;
|
if (c.type !== 'votes') continue;
|
||||||
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
|
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts, seq });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = new Map();
|
const prev = new Map();
|
||||||
for (const [k, n] of nodes) {
|
for (const [k, n] of nodes) {
|
||||||
const t = n.content.replaces;
|
const t = n.content.replaces;
|
||||||
if (typeof t === 'string' && nodes.has(t)) prev.set(k, t);
|
if (typeof t === 'string' && t !== k && nodes.has(t)) prev.set(k, t);
|
||||||
}
|
|
||||||
const childrenOf = new Map();
|
|
||||||
for (const [k, p] of prev) {
|
|
||||||
if (!childrenOf.has(p)) childrenOf.set(p, []);
|
|
||||||
childrenOf.get(p).push(k);
|
|
||||||
}
|
}
|
||||||
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
|
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
|
||||||
|
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
|
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
|
||||||
|
|
||||||
const tallyByKey = new Map();
|
const results = new Map();
|
||||||
for (const [root, keys] of groups.entries()) {
|
for (const [root, keys] of groups.entries()) {
|
||||||
const rootNode = nodes.get(root);
|
const rootNode = nodes.get(root);
|
||||||
if (!rootNode) continue;
|
if (!rootNode) continue;
|
||||||
const options = Array.isArray(rootNode.content.options) ? rootNode.content.options : [];
|
const creator = rootNode.author;
|
||||||
let votesMap = Object.assign({}, rootNode.content.votes || {});
|
const tags = Array.isArray(rootNode.content.tags) ? rootNode.content.tags : [];
|
||||||
let voters = Array.isArray(rootNode.content.voters) ? rootNode.content.voters.slice() : [];
|
const frozen = tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t)));
|
||||||
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
|
let current = rootNode;
|
||||||
|
if (!frozen) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const n = nodes.get(k);
|
||||||
|
if (n.author === creator && isLater(n, current)) current = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const options = Array.isArray(current.content.options) ? current.content.options.filter(o => typeof o === 'string') : [];
|
||||||
|
const deadline = typeof current.content.deadline === 'string' ? current.content.deadline : null;
|
||||||
|
const opensAt = rootNode.ts;
|
||||||
|
const closesAt = deadline ? msOf(deadline) : null;
|
||||||
|
|
||||||
let cur = root;
|
const cast = [];
|
||||||
let g = 0;
|
for (const k of keys) {
|
||||||
let creatorChoice = null;
|
for (const b of ballotsByTarget.get(k) || []) cast.push(b);
|
||||||
let creatorCounted = false;
|
const n = nodes.get(k);
|
||||||
while (g++ < 100000) {
|
if (n.author === creator || !prev.has(k)) continue;
|
||||||
const kids = (childrenOf.get(cur) || []).map(k => nodes.get(k)).filter(Boolean).sort((a, b) => a.ts - b.ts);
|
const choice = legacyChoice(n, nodes.get(prev.get(k)), options);
|
||||||
if (!kids.length) break;
|
if (choice !== null) cast.push({ author: n.author, choice, ts: n.ts, seq: n.seq });
|
||||||
let advanced = false;
|
|
||||||
for (const kid of kids) {
|
|
||||||
const kVotes = Object.assign({}, kid.content.votes || {});
|
|
||||||
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters.slice() : [];
|
|
||||||
for (const o of options) if (!(o in kVotes)) kVotes[o] = 0;
|
|
||||||
if (kid.author === rootNode.author) {
|
|
||||||
if (!creatorCounted && kVoters.includes(rootNode.author) && !voters.includes(rootNode.author)) {
|
|
||||||
creatorCounted = true;
|
|
||||||
let inc = null;
|
|
||||||
let incCount = 0;
|
|
||||||
for (const o of options) {
|
|
||||||
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
|
|
||||||
if (d >= 1) { inc = o; incCount++; }
|
|
||||||
}
|
|
||||||
if (incCount === 1) creatorChoice = inc;
|
|
||||||
}
|
|
||||||
votesMap = kVotes;
|
|
||||||
voters = kVoters;
|
|
||||||
cur = kid.key;
|
|
||||||
advanced = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (voters.includes(kid.author)) continue;
|
|
||||||
if (kVoters.length !== voters.length + 1) continue;
|
|
||||||
const added = kVoters.filter(x => !voters.includes(x));
|
|
||||||
if (added.length !== 1 || added[0] !== kid.author) continue;
|
|
||||||
let deltas = 0;
|
|
||||||
let deltaOk = true;
|
|
||||||
for (const o of options) {
|
|
||||||
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
|
|
||||||
if (d === 0) continue;
|
|
||||||
if (d === 1) { deltas++; continue; }
|
|
||||||
deltaOk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!deltaOk || deltas !== 1) continue;
|
|
||||||
votesMap = kVotes;
|
|
||||||
voters = kVoters;
|
|
||||||
cur = kid.key;
|
|
||||||
advanced = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!advanced) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (voters.includes(rootNode.author)) {
|
const latest = new Map();
|
||||||
voters = voters.filter(x => x !== rootNode.author);
|
for (const b of cast) {
|
||||||
if (creatorChoice && (Number(votesMap[creatorChoice]) || 0) > 0) votesMap[creatorChoice] = votesMap[creatorChoice] - 1;
|
if (!b.author || b.author === creator) continue;
|
||||||
}
|
|
||||||
|
|
||||||
const voterSet = new Set(voters);
|
|
||||||
let totalVotes = voters.length;
|
|
||||||
const chainKeys = new Set(keys);
|
|
||||||
for (const b of ballots) {
|
|
||||||
if (!chainKeys.has(b.target)) continue;
|
|
||||||
if (b.author === rootNode.author) continue;
|
|
||||||
if (!b.author || voterSet.has(b.author)) continue;
|
|
||||||
if (!options.includes(b.choice)) continue;
|
if (!options.includes(b.choice)) continue;
|
||||||
voterSet.add(b.author);
|
if (b.ts < opensAt) continue;
|
||||||
|
if (closesAt !== null && b.ts > closesAt) continue;
|
||||||
|
const known = latest.get(b.author);
|
||||||
|
if (!known || isLater(b, known)) latest.set(b.author, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const votes = {};
|
||||||
|
for (const o of options) votes[o] = 0;
|
||||||
|
const voters = [];
|
||||||
|
for (const b of [...latest.values()].sort((a, b) => a.ts - b.ts || a.seq - b.seq)) {
|
||||||
|
votes[b.choice] = (votes[b.choice] || 0) + 1;
|
||||||
voters.push(b.author);
|
voters.push(b.author);
|
||||||
votesMap[b.choice] = (votesMap[b.choice] || 0) + 1;
|
|
||||||
totalVotes += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tally = { votes: votesMap, voters, totalVotes };
|
const result = { votes, voters, totalVotes: voters.length, creator, rootId: root, deadline, options };
|
||||||
for (const k of keys) tallyByKey.set(k, tally);
|
for (const k of keys) results.set(k, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildVoteTally = (messages) => {
|
||||||
|
const shared = new Map();
|
||||||
|
const tallyByKey = new Map();
|
||||||
|
for (const [k, r] of buildVoteResults(messages)) {
|
||||||
|
if (!shared.has(r)) shared.set(r, { votes: r.votes, voters: r.voters, totalVotes: r.totalVotes });
|
||||||
|
tallyByKey.set(k, shared.get(r));
|
||||||
|
}
|
||||||
return tallyByKey;
|
return tallyByKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { buildVoteTally };
|
module.exports = { buildVoteTally, buildVoteResults };
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const STORAGE_DIR = path.join(__dirname, "..", "configs");
|
|
||||||
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
|
|
||||||
|
|
||||||
function ensure() {
|
|
||||||
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
|
||||||
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
|
|
||||||
}
|
|
||||||
function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
|
|
||||||
function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
|
|
||||||
|
|
||||||
async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
|
|
||||||
async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
|
|
||||||
|
|
||||||
module.exports = { getAddress, setAddress };
|
|
||||||
1
src/client/assets/images/mobile-ico-actualizar.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 17v3h16v-3"/></svg>
|
||||||
|
After Width: | Height: | Size: 227 B |
1
src/client/assets/images/mobile-ico-cifrado.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>
|
||||||
|
After Width: | Height: | Size: 242 B |
1
src/client/assets/images/mobile-ico-compartir.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M8 7l4-4 4 4"/><path d="M5 12v8h14v-8"/></svg>
|
||||||
|
After Width: | Height: | Size: 226 B |
1
src/client/assets/images/mobile-ico-enviar.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l16-8-6 16-3-7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 189 B |
1
src/client/assets/images/mobile-ico-licencia.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M9 12h6M9 16h6"/></svg>
|
||||||
|
After Width: | Height: | Size: 232 B |
1
src/client/assets/images/mobile-ico-mutuos.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><path d="M3 20a6 6 0 0 1 12 0"/><circle cx="17" cy="9" r="2.5"/><path d="M16 14a5 5 0 0 1 5 5"/></svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
1
src/client/assets/images/mobile-ico-publico.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/></svg>
|
||||||
|
After Width: | Height: | Size: 260 B |
1
src/client/assets/images/mobile-ico-red.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 11l7.4-3.8M8.3 13l7.4 3.8"/></svg>
|
||||||
|
After Width: | Height: | Size: 296 B |
1
src/client/assets/images/mobile-ico-responder.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7L4 12l5 5"/><path d="M4 12h10a6 6 0 0 1 6 6v1"/></svg>
|
||||||
|
After Width: | Height: | Size: 218 B |
1
src/client/assets/images/mobile-ico-sugerencia.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M10 8l4 4-4 4"/></svg>
|
||||||
|
After Width: | Height: | Size: 213 B |
1
src/client/assets/images/mobile-ico-tiempo.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
|
||||||
|
After Width: | Height: | Size: 211 B |
1
src/client/assets/images/mobile-ico-tribu.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4L3 20h18z"/><path d="M12 4v16"/><path d="M9 20l3-5 3 5"/></svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
1
src/client/assets/images/mobile-phone-dnd.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/><path d="M22 2L2 22"/></svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
1
src/client/assets/images/mobile-phone.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 260 B |
BIN
src/client/assets/images/theme-clear-snh.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src/client/assets/images/theme-dark-snh.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src/client/assets/images/theme-matrix-snh.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src/client/assets/images/theme-purple-snh.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/client/assets/images/ux-ainav.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/client/assets/images/ux-blocks.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/client/assets/images/ux-chats.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
1
src/client/assets/images/ux-empty.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13l3-8h12l3 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M3 13h5l1 3h6l1-3h5"/></svg>
|
||||||
|
After Width: | Height: | Size: 251 B |
BIN
src/client/assets/images/ux-feed.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/client/assets/images/ux-phone.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/client/assets/images/ux-rooms.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 132 KiB |
522
src/client/assets/styles/activista.css
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
/* Lanzador, barras, tarjetas e identidades del skin Activista (rama movil).
|
||||||
|
*
|
||||||
|
* Va detras de themes/Activista.css y pinta las piezas propias de la rama con las
|
||||||
|
* variables --a26-* (las de serie estan en el tema; Colores las cambia). Hasta el
|
||||||
|
* 20/10/2026 era hive_acampada.css, del skin Acampada. */
|
||||||
|
|
||||||
|
body { background: var(--a26-bg) !important; }
|
||||||
|
|
||||||
|
/* ---------- barra de arriba ---------- */
|
||||||
|
.oasis-mobile-topbar {
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-bottom: var(--a26-bd) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.oasis-mobile-topbar .omt-menus { background: transparent !important; border: 0 !important; box-shadow: none !important; gap: 10px; }
|
||||||
|
.oasis-mobile-topbar .omt-menus .oasis-menu-btn {
|
||||||
|
font-family: var(--a26-tit) !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 20px !important;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
min-height: 44px !important;
|
||||||
|
padding: 4px 16px !important;
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.oasis-mobile-topbar .omt-menus .oasis-menu-btn span { color: var(--a26-fg) !important; }
|
||||||
|
.oasis-mobile-topbar .omt-menus .oasis-menu-btn.active { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
.oasis-mobile-topbar .omt-menus .oasis-menu-btn:active { box-shadow: none !important; }
|
||||||
|
.oasis-mobile-topbar .omt-logo img, .oasis-mobile-topbar .omt-avatar img {
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* la cabecera de escritorio de upstream no pinta nada en el movil */
|
||||||
|
.header { background: transparent !important; border: 0 !important; box-shadow: none !important; padding: 0 !important; }
|
||||||
|
.header .top-bar-left, .header .top-bar-right { display: none !important; }
|
||||||
|
|
||||||
|
/* ---------- lanzador de modulos (views/hive_views.js) ---------- */
|
||||||
|
.launcher, .ln-group, .ln-grid { background: transparent !important; border: 0 !important; box-shadow: none !important; }
|
||||||
|
.header .launcher { margin: 18px auto 10px !important; }
|
||||||
|
.ln-group + .ln-group { margin-top: 22px !important; }
|
||||||
|
.ln-title {
|
||||||
|
font-family: var(--a26-tit) !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
font-size: 17px !important;
|
||||||
|
letter-spacing: .03em !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border-bottom: var(--a26-bd) !important;
|
||||||
|
padding: 0 0 4px !important;
|
||||||
|
margin: 0 2px 14px !important;
|
||||||
|
}
|
||||||
|
.ln-grid { gap: 16px 10px !important; }
|
||||||
|
.ln-ico {
|
||||||
|
width: 58px !important; height: 58px !important;
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
font-size: 24px !important;
|
||||||
|
transition: transform .12s ease-out, box-shadow .12s ease-out;
|
||||||
|
}
|
||||||
|
.ln-app:active .ln-ico, .ln-app:focus-visible .ln-ico {
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: var(--a26-neon) !important;
|
||||||
|
border-color: var(--a26-edge) !important; color: var(--a26-on-neon) !important;
|
||||||
|
}
|
||||||
|
.ln-app:hover, .ln-app:focus-visible { background: transparent !important; }
|
||||||
|
.ln-lbl { color: var(--a26-fg) !important; font-size: 12px !important; font-weight: 600 !important; }
|
||||||
|
|
||||||
|
/* ---------- barra de abajo ---------- */
|
||||||
|
.oasis-bottombar {
|
||||||
|
background: var(--a26-bar, var(--a26-bg)) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-top: var(--a26-bar-bd, var(--a26-bd)) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.oasis-bottombar { padding: 6px 4px calc(6px + var(--inset-bottom)) !important; gap: 2px !important; }
|
||||||
|
.oasis-bottombar .bb-item { gap: 2px !important; }
|
||||||
|
.oasis-bottombar .bb-ico { color: var(--a26-fg) !important; font-size: 21px !important; }
|
||||||
|
.oasis-bottombar .bb-lbl {
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
font-size: 10.5px !important;
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.oasis-bottombar a.bb-item.current .bb-ico { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
.oasis-bottombar a.bb-item.current .bb-lbl { color: var(--a26-fg) !important; }
|
||||||
|
.oasis-bottombar .bb-badge { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; border: 1px solid var(--a26-edge) !important; }
|
||||||
|
/* Nodos e Invitaciones: juntos y estrechos {
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
border: var(--a26-bd-fino) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
padding: 2px !important;
|
||||||
|
gap: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- personalizar la barra ---------- */
|
||||||
|
.bb-manage-btn, .bb-pick-btn {
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
font-family: var(--a26-txt) !important;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
.bb-manage-ico, .bb-manage-x, .bb-pick-ico { color: var(--a26-fg) !important; }
|
||||||
|
.bb-manage-lbl, .bb-pick-lbl { color: var(--a26-fg) !important; font-weight: 600; }
|
||||||
|
.bb-manage h3, .bb-manage-add h3 { color: var(--a26-fg) !important; }
|
||||||
|
.bb-manage-empty, .bb-manage-full { color: var(--a26-muted) !important; }
|
||||||
|
|
||||||
|
/* ---------- menu desplegable de upstream (si se abre) ---------- */
|
||||||
|
.oasis-nav-header { background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; }
|
||||||
|
.oasis-nav-list li a, .oasis-nav-list li a .emoji { color: var(--a26-fg) !important; }
|
||||||
|
.oasis-nav-list li a.current { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
|
||||||
|
/* ---------- foro y chat: los colores de hive.css pensados para fondo negro ---------- */
|
||||||
|
.forum-comment.level-1, .forum-comment.level-2, .forum-comment.level-3, .forum-comment.level-4 { border-left: 1px solid var(--a26-edge) !important; }
|
||||||
|
.forum-comment.highlighted-reply { background: var(--a26-tint) !important; border-left: 5px solid var(--a26-neon) !important; }
|
||||||
|
.chat-message-form .filter-btn::before, .forum-send-btn::before { color: var(--a26-fg) !important; }
|
||||||
|
|
||||||
|
/* ---------- lo que mobile.css de upstream pinta en amarillo con !important ---------- */
|
||||||
|
/* mobile.css va detras del tema, asi que estas reglas tienen que ir aqui */
|
||||||
|
.create-button, button.create-button, a.create-button {
|
||||||
|
background-color: var(--a26-neon) !important;
|
||||||
|
border-color: var(--a26-neon) !important;
|
||||||
|
color: var(--a26-on-neon) !important;
|
||||||
|
}
|
||||||
|
.filter-btn.active { background-color: var(--a26-neon) !important; border-color: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
.peers-conn-actions button, .filter-box__button { background-color: var(--a26-bg) !important; color: var(--a26-fg) !important; }
|
||||||
|
.oasis-id-copy { background: var(--a26-soft) !important; }
|
||||||
|
.gov-overview tbody tr { border: var(--a26-bd-fino) !important; }
|
||||||
|
button, input[type="submit"], input[type="button"], .filter-btn, .create-button { min-height: 44px !important; font-size: 16px !important; text-transform: none !important; }
|
||||||
|
|
||||||
|
/* aviso de bienvenida: el boton de empezar con el resalte y la X sin caja */
|
||||||
|
.welcome-banner .welcome-banner-start {
|
||||||
|
background: var(--a26-neon) !important;
|
||||||
|
color: var(--a26-on-neon) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
}
|
||||||
|
.welcome-banner .welcome-banner-close-btn {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
font-size: 22px !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pie: gris y subrayado, sin amarillos */
|
||||||
|
.oasis-footer .oasis-footer-center a, .oasis-footer .oasis-footer-center span,
|
||||||
|
.oasis-footer .oasis-footer-center a span { color: var(--a26-muted) !important; }
|
||||||
|
|
||||||
|
/* la columna de contenido sin marco: el marco lo llevan las tarjetas */
|
||||||
|
.main-column { border: 0 !important; box-shadow: none !important; border-radius: 0 !important; }
|
||||||
|
|
||||||
|
/* la sombra de Personal/Social no se corta contra la raya de la barra */
|
||||||
|
.oasis-mobile-topbar { padding-bottom: 12px !important; }
|
||||||
|
|
||||||
|
/* ---------- asistente de bienvenida: botones grandes de un toque ---------- */
|
||||||
|
.welcome-step { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; }
|
||||||
|
.welcome-banner .welcome-banner-start { font-size: 20px !important; min-height: 48px !important; padding: 8px 22px !important; }
|
||||||
|
|
||||||
|
/* mobile.css pinta en naranja los botones sin clase y todos los .filter-btn */
|
||||||
|
button:not([class]), input[type="submit"]:not([class]), input[type="button"]:not([class]),
|
||||||
|
.filter-btn, a.filter-btn, .conn-actions button, .peers-conn-actions button, .filter-box__button {
|
||||||
|
background-color: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
font-size: 18px !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 48px !important;
|
||||||
|
}
|
||||||
|
.filter-btn.active, a.filter-btn.active { background-color: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
|
||||||
|
/* pantallas estrechas (360px): Nodos e Invitaciones solo con icono, para que
|
||||||
|
los cuatro atajos no se corten */
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.oasis-bottombar .bb-pair .bb-lbl { display: none !important; }
|
||||||
|
.oasis-bottombar .bb-pair .bb-ico { font-size: 22px !important; }
|
||||||
|
.oasis-bottombar .bb-pair .bb-item { padding: 2px 6px !important; }
|
||||||
|
.oasis-bottombar .bb-lbl { font-size: 10px !important; }
|
||||||
|
.welcome-banner .update-banner-icon { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- identidades (views/identities_view.js): una tarjeta por identidad ---------- */
|
||||||
|
.identities .identity-list { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.identities .identity-row { display: flex; flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 14px; }
|
||||||
|
.identities .identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; border: 0; background: transparent; padding: 0; margin: 0; box-shadow: none; }
|
||||||
|
.identities .identity-feed { font-family: monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.identities .identity-warn { font-size: 12px; }
|
||||||
|
.identities .identity-row form { margin: 0; }
|
||||||
|
.identities .identity-row button { width: 100%; margin: 0; }
|
||||||
|
.identities .identity-active { text-align: center; font-weight: bold; padding: 10px 12px; border-radius: var(--a26-radius); }
|
||||||
|
.identities .identity-create { display: flex; flex-direction: column; gap: 10px; margin: 0 0 16px; }
|
||||||
|
.identities .identity-create input { width: 100%; box-sizing: border-box; margin: 0; }
|
||||||
|
.identities .identity-create button { width: 100%; margin: 0; }
|
||||||
|
.identities .identity-actions { display: flex; flex-direction: column; gap: 12px; align-items: stretch; border: 0; background: transparent; padding: 0; }
|
||||||
|
.identities .identity-actions form { margin: 0; }
|
||||||
|
.identities .identity-actions button { width: 100%; }
|
||||||
|
.identities .identity-legacy { text-align: center; text-decoration: underline; }
|
||||||
|
.identities .identity-row { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; background: var(--a26-bg) !important; }
|
||||||
|
.identities .identity-name { font-family: var(--a26-tit) !important; font-weight: 400 !important; text-transform: none; font-size: 22px !important; }
|
||||||
|
.identities .identity-feed { color: var(--a26-muted) !important; }
|
||||||
|
.identities .identity-active { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
.identities .identity-warn { color: var(--a26-danger-text) !important; }
|
||||||
|
|
||||||
|
/* las barras de filtros de cada modulo: upstream las encoge (.75em) y les pone
|
||||||
|
borde amarillo con selectores compuestos; aqui, del mismo tamaño que el resto */
|
||||||
|
.tribe-mode-buttons .create-button, .ui-toolbar--filters .create-button, .mode-buttons .create-button,
|
||||||
|
.mode-buttons-row .create-button, .filters .create-button, .filter-group .create-button,
|
||||||
|
.activity-sub-filter .create-button, .activity-filter-grid .create-button {
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
border-color: var(--a26-neon) !important;
|
||||||
|
background: var(--a26-neon) !important;
|
||||||
|
color: var(--a26-on-neon) !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
min-height: 48px !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
}
|
||||||
|
.tribe-mode-buttons .filter-btn, .ui-toolbar--filters .filter-btn, .mode-buttons .filter-btn,
|
||||||
|
.mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .filters .filter-btn,
|
||||||
|
.filter-group .filter-btn, .activity-sub-filter .filter-btn, .activity-filter-grid .filter-btn {
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 6px 14px !important;
|
||||||
|
min-height: 44px !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- lo que tiene que seguir siendo pequeño ---------- */
|
||||||
|
/* los iconos de accion de cada tarjeta (borrar, fijar, enlace...): en fila y redondos */
|
||||||
|
.content-actions {
|
||||||
|
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||||
|
justify-content: flex-end !important; align-items: center !important; gap: 6px !important;
|
||||||
|
}
|
||||||
|
.main-column .content-actions form, .content-actions .content-action-form { display: inline-flex !important; margin: 0 !important; width: auto !important; }
|
||||||
|
.btn-singleview, button.btn-singleview, a.btn-singleview {
|
||||||
|
width: 34px !important; height: 34px !important; min-width: 34px !important; min-height: 34px !important;
|
||||||
|
padding: 0 !important; margin: 0 !important;
|
||||||
|
display: inline-flex !important; align-items: center !important; justify-content: center !important;
|
||||||
|
font-family: var(--a26-txt) !important; font-size: 15px !important; text-transform: none !important;
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd-fino) !important; border-radius: 50% !important; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.btn-singleview.btn-delete { color: var(--a26-danger-text) !important; border-color: var(--a26-danger-text) !important; }
|
||||||
|
|
||||||
|
/* barras de filtros: chips en fila que se reparten el ancho, no un boton por linea */
|
||||||
|
.mode-buttons, .mode-buttons-row, .mode-buttons-cols, .tribe-mode-buttons, .ui-toolbar--filters, .filters {
|
||||||
|
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||||
|
justify-content: center !important; gap: 8px !important; margin: 0 0 14px !important;
|
||||||
|
}
|
||||||
|
.mode-buttons form, .mode-buttons-row form, .mode-buttons-cols form, .tribe-mode-buttons form, .filters > form:not(.filter-box) {
|
||||||
|
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||||
|
justify-content: center !important; gap: 8px !important; width: auto !important; max-width: 100% !important; margin: 0 !important;
|
||||||
|
}
|
||||||
|
.mode-buttons .filter-btn, .mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .tribe-mode-buttons .filter-btn,
|
||||||
|
.ui-toolbar--filters .filter-btn, .filters .filter-btn, .activity-filter-grid .filter-btn, .activity-sub-filter .filter-btn {
|
||||||
|
width: auto !important; flex: 0 0 auto !important;
|
||||||
|
min-height: 38px !important; height: auto !important;
|
||||||
|
padding: 4px 12px !important; margin: 0 !important;
|
||||||
|
font-size: 15px !important; box-shadow: none !important;
|
||||||
|
border: var(--a26-bd-fino) !important;
|
||||||
|
}
|
||||||
|
.mode-buttons .create-button, .mode-buttons-row .create-button, .tribe-mode-buttons .create-button, .filters .create-button {
|
||||||
|
flex: 1 1 100% !important; width: 100% !important; margin-top: 4px !important;
|
||||||
|
}
|
||||||
|
/* chips de la actividad (activity-filter-*) */
|
||||||
|
.activity-filter-chips button, .activity-filter-grid button, .activity-sub-filter button, .activity-filter-col button {
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important;
|
||||||
|
}
|
||||||
|
.activity-filter-chips button.active, .activity-filter-grid button.active, .activity-sub-filter button.active, .activity-filter-col button.active {
|
||||||
|
background: var(--a26-neon) !important; color: var(--a26-on-neon) !important;
|
||||||
|
}
|
||||||
|
/* fechas y autores al pie de cada tarjeta: texto, no botones */
|
||||||
|
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link {
|
||||||
|
font-family: var(--a26-txt) !important; text-transform: none !important; letter-spacing: 0 !important;
|
||||||
|
font-size: 13px !important; box-shadow: none !important; border: 0 !important;
|
||||||
|
background: transparent !important; padding: 0 !important; min-height: 0 !important;
|
||||||
|
}
|
||||||
|
a.user-link { font-weight: 700 !important; text-decoration: underline !important; }
|
||||||
|
.card-footer { display: flex !important; flex-wrap: wrap; gap: 10px; align-items: center; color: var(--a26-muted) !important; }
|
||||||
|
/* buscador de cada modulo */
|
||||||
|
.filter-box { display: flex !important; gap: 8px !important; align-items: stretch !important; }
|
||||||
|
.filter-box__input::placeholder, input::placeholder, textarea::placeholder { color: var(--a26-muted) !important; }
|
||||||
|
.filter-box__button { font-size: 15px !important; padding: 4px 14px !important; box-shadow: none !important; }
|
||||||
|
/* el pie de cada pagina y el id del perfil, en gris */
|
||||||
|
.main-column [class*="feed-id"], .profile .feed-id, code { color: var(--a26-muted) !important; }
|
||||||
|
|
||||||
|
/* una sola caja por tarjeta: lo de dentro (cabecera, titulo, texto) sin marco */
|
||||||
|
.main-column section, .main-column [class*="-card-"], .main-column .card-section, .main-column .card-header,
|
||||||
|
.main-column .card-meta-row, .main-column .shop-title-row, .main-column [class*="-grid"],
|
||||||
|
.main-column .feed-container, .main-column [class$="-card"] [class$="-card"] {
|
||||||
|
border: 0 !important; box-shadow: none !important; background: transparent !important; border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
.main-column [class$="-card"], .main-column [class*="-card "] {
|
||||||
|
border: var(--a26-card-bd, var(--a26-bd)) !important; border-radius: var(--a26-radius) !important;
|
||||||
|
box-shadow: var(--a26-card-shadow, none) !important;
|
||||||
|
background: var(--a26-card, var(--a26-bg)) !important; padding: 12px !important; margin: 0 0 14px !important;
|
||||||
|
}
|
||||||
|
.main-column .card-header { padding: 0 0 8px !important; border-bottom: var(--a26-bd-fino) !important; margin-bottom: 8px !important; }
|
||||||
|
.main-column [class$="-card"] .card-footer { border-top: var(--a26-bd-fino) !important; padding-top: 8px !important; margin-top: 8px !important; }
|
||||||
|
.main-column hr { border: 0 !important; border-top: var(--a26-bd-fino) !important; }
|
||||||
|
|
||||||
|
/* titulos de las tarjetas: enlaces en negro */
|
||||||
|
.main-column h1 a, .main-column h2 a, .main-column h3 a, .main-column [class*="title"] a {
|
||||||
|
color: var(--a26-fg) !important; text-decoration: none !important;
|
||||||
|
}
|
||||||
|
.main-column [class*="-card"] .tribe-card-title, .main-column [class*="-card"] h2 { font-size: 22px !important; margin: 4px 0 8px !important; }
|
||||||
|
|
||||||
|
/* la etiqueta del tipo (IMAGENES, FOROS...) y los chips de estado */
|
||||||
|
.pm-exposition-chip, .card-chips-row > span {
|
||||||
|
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
|
||||||
|
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||||
|
}
|
||||||
|
.pm-exposition-chip *, .card-chips-row > span * { color: var(--a26-bg) !important; }
|
||||||
|
.pm-exposition-text { font-family: var(--a26-tit) !important; letter-spacing: .04em; font-size: 13px !important; }
|
||||||
|
.content-actions .btn-singleview.btn-content, .content-actions a.btn-content { border-color: var(--a26-edge) !important; color: var(--a26-fg) !important; }
|
||||||
|
|
||||||
|
/* opiniones y votos */
|
||||||
|
.opinions-summary, .vote-btn, .score-btn {
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd-fino) !important; border-radius: 10px !important; box-shadow: none !important;
|
||||||
|
font-family: var(--a26-txt) !important; text-transform: none !important; font-size: 14px !important;
|
||||||
|
}
|
||||||
|
.post-image { border: var(--a26-bd-fino) !important; border-radius: 10px; }
|
||||||
|
|
||||||
|
/* tablas de detalle (fecha, estado, lugar...): filas claras, la etiqueta en gris */
|
||||||
|
.main-column table, .main-column table tr, .main-column table td, .main-column table th, .main-column table tbody {
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border-color: var(--a26-edge) !important;
|
||||||
|
}
|
||||||
|
.main-column table { border: var(--a26-bd-fino) !important; border-radius: 10px !important; }
|
||||||
|
.main-column table tr + tr td { border-top: 1px solid var(--a26-line) !important; }
|
||||||
|
.main-column table td:first-child, .main-column table th, .main-column [class*="-info-label"] {
|
||||||
|
color: var(--a26-muted) !important; font-weight: 700 !important; font-size: 13px !important;
|
||||||
|
text-transform: none; letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* perfil: los datos del habitante como lineas claras, sin cajas negras */
|
||||||
|
.profile-sensors-box, .profile-sensors-box > span, .karma-line, .inhabitant-last-activity, .gpg-line,
|
||||||
|
.inhabitant-stats-box, .inhabitant-stat, .profile-side-relationship .status {
|
||||||
|
background: transparent !important; color: var(--a26-fg) !important; border: 0 !important; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.profile-sensors-box { display: flex !important; flex-direction: column !important; gap: 4px !important; align-items: center !important; }
|
||||||
|
.profile-sensors-box strong, .profile-sensors-box a, .device-mobile { color: var(--a26-fg) !important; }
|
||||||
|
.inhabitant-stat { border: var(--a26-bd-fino) !important; border-radius: 10px !important; padding: 4px 10px !important; }
|
||||||
|
.profile-side-relationship .status { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 10px !important; color: var(--a26-on-neon) !important; }
|
||||||
|
.main-column [class*="feed-id"], .main-column a[href^="/author/"]:not(.user-link) { color: var(--a26-muted) !important; }
|
||||||
|
.profile img.avatar, .inhabitant-photo { border: var(--a26-bd) !important; }
|
||||||
|
|
||||||
|
/* etiquetas (#asamblea) y la tasa ECO: chips claros */
|
||||||
|
.card-tags { background: transparent !important; border: 0 !important; padding: 0 !important; display: flex !important; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.card-tags .tag-link, a.tag-link {
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||||
|
text-decoration: none !important; font-weight: 700;
|
||||||
|
}
|
||||||
|
.eco-tax-chip, [class*="eco-tax-chip-"] {
|
||||||
|
background: var(--a26-soft) !important; color: var(--a26-muted) !important;
|
||||||
|
border: 1px solid var(--a26-line) !important; border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* los filtros de la actividad son enlaces .activity-chip */
|
||||||
|
.activity-chip {
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd-fino) !important; border-radius: 10px !important;
|
||||||
|
padding: 4px 10px !important; font-weight: 700 !important; text-decoration: none !important;
|
||||||
|
}
|
||||||
|
.activity-chip.active { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
.activity-chip:hover { color: var(--a26-fg) !important; border-color: var(--a26-edge) !important; }
|
||||||
|
|
||||||
|
/* ---------- fichas de filtro de 1.3.3 (OasisMobile.css las pinta en amarillo y gris) ---------- */
|
||||||
|
/* mismo selector que alli: gana por ir detras */
|
||||||
|
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) {
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
padding: 4px 0 !important;
|
||||||
|
}
|
||||||
|
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
|
||||||
|
font-family: var(--a26-tit) !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
letter-spacing: .02em !important;
|
||||||
|
padding: 4px 12px !important;
|
||||||
|
min-height: 38px !important;
|
||||||
|
border: var(--a26-bd-fino) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn.active:not(.create-button) {
|
||||||
|
background: var(--a26-neon) !important;
|
||||||
|
border-color: var(--a26-edge) !important;
|
||||||
|
color: var(--a26-on-neon) !important;
|
||||||
|
}
|
||||||
|
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .create-button { min-height: 48px !important; }
|
||||||
|
|
||||||
|
/* acciones de la tarjeta: iconos SVG en negro dentro del circulo */
|
||||||
|
.main-column .content-actions .btn-singleview { width: 36px !important; height: 36px !important; }
|
||||||
|
.btn-singleview .btn-icon { width: 18px !important; height: 18px !important; }
|
||||||
|
.btn-singleview.btn-pin-on { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
|
||||||
|
/* hilos del foro (lista tipo archivo de 1.3.3): tarjeta negra sin la raya naranja */
|
||||||
|
.main-column .mailing-archive-item {
|
||||||
|
border: var(--a26-bd-fino) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
padding: 12px !important;
|
||||||
|
margin: 0 0 14px !important;
|
||||||
|
}
|
||||||
|
.main-column .mailing-archive-item a { color: var(--a26-fg) !important; }
|
||||||
|
.main-column a.forum-category {
|
||||||
|
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
|
||||||
|
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||||
|
font-family: var(--a26-tit) !important; text-decoration: none !important; letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* restos de naranja de style.css y mobile.css */
|
||||||
|
.oasis-id-copy, input.oasis-id-copy, input.oasis-id-copy[readonly] {
|
||||||
|
background: var(--a26-soft) !important; border: var(--a26-bd-fino) !important; color: var(--a26-muted) !important;
|
||||||
|
}
|
||||||
|
.oasis-menu-btn.oasis-menu-open, .oasis-menu-btn.oasis-menu-close {
|
||||||
|
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important; color: var(--a26-fg) !important;
|
||||||
|
}
|
||||||
|
img.inhabitant-photo-details { border: var(--a26-bd) !important; }
|
||||||
|
.welcome-action { color: var(--a26-fg) !important; }
|
||||||
|
label.welcome-ux-option {
|
||||||
|
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important;
|
||||||
|
border-radius: var(--a26-radius) !important; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
label.welcome-ux-option:has(input:checked) { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
|
||||||
|
/* chat: burbujas con marco negro y las reacciones como chips pequeños
|
||||||
|
(el estilo general de botones de Acampada las hacia de 48px a ancho completo) */
|
||||||
|
.chat-bubble-row .chat-message {
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd-fino) !important; border-radius: 14px 14px 14px 4px !important;
|
||||||
|
}
|
||||||
|
.chat-bubble-row-self .chat-message { background: var(--a26-tint) !important; border-radius: 14px 14px 4px 14px !important; }
|
||||||
|
.chat-bubble-sender, .chat-bubble-sender-owner { color: var(--a26-fg) !important; }
|
||||||
|
.chat-bubble-time { color: var(--a26-muted) !important; }
|
||||||
|
.main-column .chat-msg-actions { display: flex !important; flex-wrap: wrap !important; gap: 4px !important; }
|
||||||
|
.main-column .chat-msg-actions form, .main-column form.chat-react-form { display: inline-flex !important; width: auto !important; margin: 0 !important; }
|
||||||
|
.main-column button.chat-react-btn, button.chat-react-btn {
|
||||||
|
width: auto !important; min-height: 0 !important; height: auto !important;
|
||||||
|
padding: 1px 8px !important; margin: 0 !important;
|
||||||
|
font-size: 13px !important; line-height: 1.4 !important;
|
||||||
|
font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif !important;
|
||||||
|
text-transform: none !important; letter-spacing: 0 !important;
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||||
|
border: 1px solid var(--a26-edge) !important; border-radius: 10px !important; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
/* el 📌 va en la esquina: el texto deja su hueco (el padding general de la burbuja lo pisaba) */
|
||||||
|
.chat-bubble-row .chat-message.chat-has-pin-toggle { position: relative !important; padding-right: 44px !important; }
|
||||||
|
.chat-day-chip { background: var(--a26-fg) !important; color: var(--a26-bg) !important; border: 0 !important; }
|
||||||
|
.chat-reply-cancel,
|
||||||
|
.chat-message-meta .chat-reply-btn { color: var(--a26-fg) !important; }
|
||||||
|
|
||||||
|
/* ---------- skins en Ajustes ---------- */
|
||||||
|
.skin-card {
|
||||||
|
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.skin-card .skin-shot { border: var(--a26-bd-fino); }
|
||||||
|
.skin-card .skin-name {
|
||||||
|
font-family: var(--a26-tit) !important; font-weight: 400 !important;
|
||||||
|
text-transform: none; letter-spacing: .02em; font-size: 21px !important;
|
||||||
|
}
|
||||||
|
.skin-card.is-current { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; border: var(--a26-bd) !important; }
|
||||||
|
.skin-card:active { box-shadow: none !important; background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
|
||||||
|
/* ---------- letra y botones mas grandes, para leer sin gafas ---------- */
|
||||||
|
body { font-size: 18px; }
|
||||||
|
.ln-ico { width: 64px !important; height: 64px !important; font-size: 28px !important; }
|
||||||
|
.ln-lbl { font-size: 13px !important; font-weight: 700 !important; white-space: normal !important; overflow-wrap: normal !important; word-break: normal !important; line-height: 1.15 !important; letter-spacing: -.02em; overflow: visible !important; text-overflow: clip !important; max-width: calc(100% + 10px) !important; }
|
||||||
|
.ln-title { font-size: 20px !important; }
|
||||||
|
.ln-grid { gap: 20px 8px !important; }
|
||||||
|
.oasis-bottombar .bb-ico { font-size: 25px !important; }
|
||||||
|
.oasis-bottombar .bb-lbl { font-size: 12.5px !important; font-weight: 700 !important; }
|
||||||
|
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
|
||||||
|
font-size: 18px !important; min-height: 46px !important; padding: 6px 16px !important;
|
||||||
|
}
|
||||||
|
.activity-chip { font-size: 16px !important; padding: 6px 12px !important; }
|
||||||
|
.main-column .content-actions .btn-singleview { width: 44px !important; height: 44px !important; min-width: 44px !important; }
|
||||||
|
.btn-singleview .btn-icon { width: 22px !important; height: 22px !important; }
|
||||||
|
.main-column button.chat-react-btn, button.chat-react-btn { font-size: 17px !important; padding: 3px 10px !important; }
|
||||||
|
.oasis-mobile-topbar .omt-menus .oasis-menu-btn { font-size: 22px !important; min-height: 48px !important; }
|
||||||
|
.tags-header p { font-size: 16px !important; }
|
||||||
|
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link { font-size: 15px !important; }
|
||||||
|
input, textarea, select { font-size: 18px !important; min-height: 50px; }
|
||||||
|
|
||||||
|
/* ---------- piezas del dev con fondo oscuro fijo (pruebas del 20/10) ---------- */
|
||||||
|
/* style.css pinta .time-chip y .pref-pill en gris casi negro y Acampada pone el texto
|
||||||
|
en negro: no se leian. Se pasan a los colores del tema */
|
||||||
|
.time-chip { background: var(--a26-soft) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; }
|
||||||
|
.pref-pill { background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; border-radius: 10px !important; min-height: 44px; }
|
||||||
|
/* con especificidad alta: la regla de etiquetas de formulario de mobile_ux.css le
|
||||||
|
ponia el color del texto normal encima del resalte */
|
||||||
|
.main-column label.pref-pill:has(.pref-pill-input:checked) { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
.pref-pill.pref-pill-forced { background: #c0392b !important; border-color: #c0392b !important; color: #fff !important; }
|
||||||
|
/* el plegable de mensajes anteriores del buzon: gris claro sobre blanco */
|
||||||
|
summary.pm-thread-toggle { color: var(--a26-muted) !important; }
|
||||||
|
.pm-thread-icon { font-size: 12px !important; }
|
||||||
|
|
||||||
|
/* fichas de la actividad y de filtros: mobile.css del dev las pone en mayusculas */
|
||||||
|
.activity-chip, a.activity-chip, .filter-btn, a.filter-btn { text-transform: none !important; }
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
.hljs-comment,
|
|
||||||
.hljs-quote {
|
|
||||||
color: var(--base03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-variable,
|
|
||||||
.hljs-template-variable,
|
|
||||||
.hljs-tag,
|
|
||||||
.hljs-name,
|
|
||||||
.hljs-selector-id,
|
|
||||||
.hljs-selector-class,
|
|
||||||
.hljs-regexp,
|
|
||||||
.hljs-deletion {
|
|
||||||
color: var(--base08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-number,
|
|
||||||
.hljs-built_in,
|
|
||||||
.hljs-builtin-name,
|
|
||||||
.hljs-literal,
|
|
||||||
.hljs-type,
|
|
||||||
.hljs-params,
|
|
||||||
.hljs-meta,
|
|
||||||
.hljs-link {
|
|
||||||
color: var(--base09);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-attribute {
|
|
||||||
color: var(--base0A);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-string,
|
|
||||||
.hljs-symbol,
|
|
||||||
.hljs-bullet,
|
|
||||||
.hljs-addition {
|
|
||||||
color: var(--base0B);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-title,
|
|
||||||
.hljs-section {
|
|
||||||
color: var(--base0D);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-keyword,
|
|
||||||
.hljs-selector-tag {
|
|
||||||
color: var(--base0E);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs {
|
|
||||||
display: block;
|
|
||||||
overflow-x: auto;
|
|
||||||
color: var(--base05);
|
|
||||||
padding: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-emphasis {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-strong {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-string, .hljs-link {
|
|
||||||
background: #FFA500;
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
1011
src/client/assets/styles/mobile_base.css
Normal file
1326
src/client/assets/styles/mobile_ux.css
Normal file
190
src/client/assets/themes/Activista.css
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
/* Skin Activista (rama movil): el unico del movil, lo mas simple posible.
|
||||||
|
*
|
||||||
|
* Letra del sistema, titulos en negrita sin mayusculas, bordes de 1px, sin sombras y
|
||||||
|
* el boton principal relleno con el color de resalte. Los colores salen de las
|
||||||
|
* variables --a26-* (el nombre viene del skin Acampada, que se quito el 20/10/2026):
|
||||||
|
* aqui van los de serie y Colores en Ajustes los cambia (mobile_settings.js, hoja
|
||||||
|
* /mobile-colors.css, que va la ultima).
|
||||||
|
*
|
||||||
|
* La maqueta de movil es styles/mobile_base.css, que main_views.js carga justo antes;
|
||||||
|
* aqui solo se cambian colores, letra y formas encima. Por eso casi todo lleva
|
||||||
|
* !important: la maqueta tambien lo usa. Las piezas propias de la rama (lanzador,
|
||||||
|
* barras) se ajustan en styles/activista.css. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--a26-bg: #fff;
|
||||||
|
--a26-fg: #111;
|
||||||
|
--a26-neon: #00e5ff;
|
||||||
|
--a26-soft: #f2f2f2;
|
||||||
|
--a26-muted: #555;
|
||||||
|
--a26-on-neon: #000;
|
||||||
|
--a26-tint: #e5fcff;
|
||||||
|
--a26-line: #d6d6d6;
|
||||||
|
--a26-edge: #8a8a8a;
|
||||||
|
--a26-danger-text: #c4003b;
|
||||||
|
--a26-bd: 1px solid var(--a26-edge);
|
||||||
|
--a26-bd-fino: 1px solid var(--a26-edge);
|
||||||
|
--a26-radius: 12px;
|
||||||
|
--a26-tit: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--a26-txt: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- fondo y texto --- */
|
||||||
|
html, body {
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
font-family: var(--a26-txt) !important;
|
||||||
|
}
|
||||||
|
body { font-size: 17px; line-height: 1.5; }
|
||||||
|
|
||||||
|
header, footer, .header, .main-content, .main-column, .sidebar-left, .sidebar-right,
|
||||||
|
section, article, div, table, .profile {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border-color: var(--a26-line) !important;
|
||||||
|
}
|
||||||
|
div { border: 0 !important; box-shadow: none !important; }
|
||||||
|
|
||||||
|
span, p, li, td, th, label, small, strong, em, h4, h5, h6 { color: inherit !important; }
|
||||||
|
|
||||||
|
a { color: var(--a26-fg) !important; }
|
||||||
|
a:hover, a:focus-visible { color: var(--a26-on-neon) !important; background-color: var(--a26-neon); }
|
||||||
|
|
||||||
|
/* --- titulos: negrita, sin mayusculas --- */
|
||||||
|
h1, h2, h3, .card-label, .tags-header h2, .oasis-nav-header {
|
||||||
|
font-family: var(--a26-tit) !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
color: var(--a26-title, var(--a26-fg)) !important;
|
||||||
|
}
|
||||||
|
h1 { font-size: 26px !important; }
|
||||||
|
h2 { font-size: 22px !important; }
|
||||||
|
h3 { font-size: 18px !important; }
|
||||||
|
.card-label { font-size: 14px !important; }
|
||||||
|
|
||||||
|
/* cabecera de cada modulo: titulo y una raya fina debajo */
|
||||||
|
.tags-header {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-bottom: 1px solid var(--a26-line) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
padding: 0 0 8px !important;
|
||||||
|
margin: 0 0 16px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
.tags-header p { color: var(--a26-muted) !important; font-size: 15px !important; margin: 4px 0 0 !important; }
|
||||||
|
|
||||||
|
/* --- tarjetas: borde fino, sin sombra --- */
|
||||||
|
.main-column article, .main-column [class$="-card"], .main-column [class*="-card "],
|
||||||
|
.main-column .card, .forum-comment, .feed-row {
|
||||||
|
background: var(--a26-card, var(--a26-bg)) !important;
|
||||||
|
border: var(--a26-card-bd, 1px solid var(--a26-line)) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
box-shadow: var(--a26-card-shadow, none) !important;
|
||||||
|
}
|
||||||
|
.main-column > section { border: 0 !important; padding: 0 !important; }
|
||||||
|
|
||||||
|
/* --- botones: contorno fino; el elegido y el principal, rellenos --- */
|
||||||
|
button, input[type="submit"], input[type="button"],
|
||||||
|
.filter-btn, .create-button, .buy-btn, .delete-btn,
|
||||||
|
a.user-link, button.user-link, .date-link, .welcome-banner-start, .update-download-link {
|
||||||
|
font-family: var(--a26-txt) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
letter-spacing: 0;
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd) !important;
|
||||||
|
border-radius: var(--a26-radius) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
button, input[type="submit"], input[type="button"], .filter-btn, .create-button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
button:active, input[type="submit"]:active, input[type="button"]:active,
|
||||||
|
.filter-btn:active, a.user-link:active {
|
||||||
|
background: var(--a26-soft) !important;
|
||||||
|
}
|
||||||
|
.filter-btn.active, .mode-buttons .filter-btn.active, .mode-buttons-row .filter-btn.active,
|
||||||
|
.tribe-mode-buttons .filter-btn.active, .filters .filter-btn.active,
|
||||||
|
.activity-filter-grid .filter-btn.active, .activity-sub-filter .filter-btn.active,
|
||||||
|
.bank-range-tabs .filter-btn.active {
|
||||||
|
background: var(--a26-neon) !important;
|
||||||
|
border-color: var(--a26-neon) !important;
|
||||||
|
color: var(--a26-on-neon) !important;
|
||||||
|
}
|
||||||
|
/* el principal de cada pantalla (crear, escribir): relleno con el resalte */
|
||||||
|
.create-button, .mode-buttons .create-button, .mode-buttons-row .create-button,
|
||||||
|
.tribe-mode-buttons .create-button, .filters .create-button {
|
||||||
|
background: var(--a26-neon) !important;
|
||||||
|
border-color: var(--a26-neon) !important;
|
||||||
|
color: var(--a26-on-neon) !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
min-height: 48px !important;
|
||||||
|
}
|
||||||
|
.create-button:active { filter: brightness(.92); }
|
||||||
|
.delete-btn { border-color: var(--a26-danger-text) !important; color: var(--a26-danger-text) !important; }
|
||||||
|
|
||||||
|
/* --- formularios --- */
|
||||||
|
input, textarea, select {
|
||||||
|
background: var(--a26-bg) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border: var(--a26-bd-fino) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
font-family: var(--a26-txt) !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: 2px solid var(--a26-neon) !important;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
input[type="checkbox"], input[type="radio"] { min-height: 0; accent-color: var(--a26-neon); }
|
||||||
|
|
||||||
|
/* --- tablas --- */
|
||||||
|
table { border: 1px solid var(--a26-line) !important; border-radius: 10px; overflow: hidden; }
|
||||||
|
table th { background: var(--a26-soft) !important; color: var(--a26-fg) !important; }
|
||||||
|
table td { border-color: var(--a26-line) !important; }
|
||||||
|
|
||||||
|
/* --- avisos de upstream (bienvenida, actualizaciones) --- */
|
||||||
|
.update-banner, .welcome-banner {
|
||||||
|
background: var(--a26-tint) !important;
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-left: 4px solid var(--a26-neon) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
margin: 8px 10px !important;
|
||||||
|
}
|
||||||
|
.update-banner-link, .update-banner-text { color: var(--a26-fg) !important; }
|
||||||
|
.welcome-banner-close-btn {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
min-height: 44px; width: 44px; padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- colores sueltos de OasisMobile que se quedaban en amarillo o naranja --- */
|
||||||
|
.avatar, .article img, .post-preview img { border-color: var(--a26-line) !important; }
|
||||||
|
.profile .name, .snh-invite-code, .stats-kpi-label, .stats-kpi-value, .carbon-bar-formula,
|
||||||
|
a.hashtag, .chat-topic-title, .chat-topic-active .chat-topic-title,
|
||||||
|
.chat-topic-meta, .chat-topic-last-author, .chat-workspace-details > .tribe-side .tribe-card-members {
|
||||||
|
color: var(--a26-fg) !important;
|
||||||
|
}
|
||||||
|
.chat-topic-active { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
|
||||||
|
a.hashtag { text-decoration: underline !important; }
|
||||||
|
.pdf-maximized { background: var(--a26-bg) !important; }
|
||||||
|
|
||||||
|
/* --- pie --- */
|
||||||
|
.oasis-footer, .oasis-footer * { color: var(--a26-muted) !important; background: transparent !important; }
|
||||||
|
.oasis-footer { border-top: 1px solid var(--a26-line) !important; margin-top: 32px; }
|
||||||
|
.oasis-footer a { text-decoration: underline !important; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb { background-color: var(--a26-edge) !important; }
|
||||||
|
::-webkit-scrollbar-track { background-color: var(--a26-soft) !important; }
|
||||||