Compare commits

..

7 commits

Author SHA1 Message Date
s1to
9eb5123c03 Load Oasis-Mobile-UX theme directly on mobile
Drops the conditional/selectable-theme loading that broke the CSS;
matches the known-working build.
2026-07-26 14:18:42 +02:00
s1to
8aaba6585a Add Oasis-Mobile-UX theme with hexagon navigation
- Move mobile styles into a selectable Oasis-Mobile-UX theme; mobile.css kept intact
- Hexagon category launcher on home (10 categories with expandable modules)
- Customizable bottom bar (up to 4 shortcuts, Peers/Invites fixed)
- Gold-outlined hexagons
2026-07-17 12:12:55 +02:00
s1to
6c816bab94 Fix vertical scroll on mobile 2026-07-11 15:09:23 +02:00
s1to
c7419a44d5 Bottom-bar module picker routes
- Add GET/POST /settings/bottombar to choose the pinned module.
2026-07-09 16:44:17 +02:00
s1to
bc0f55e914 Empty states and collapsible activity filters
- Activity and search show an empty state with a call to action.
- Selecting an activity type collapses the filters and adds a back arrow to change it.
2026-07-09 16:44:17 +02:00
s1to
9c100ed773 Configurable bottom bar and empty-state helper
- Bottom bar: replace Activity with a + slot to pin a module; hide slots for disabled modules.
- Add a reusable empty-state helper and the inbox empty state.
- Mobile styles and translations for the above.
2026-07-09 16:44:17 +02:00
s1to
851042e576 keylock: password-based at-rest encryption
- Pre-boot password gate (main.js) with language selector; backend starts only after unlock.
- keylock.js: scrypt KEK + AES-256-GCM wrap/unwrap of the SSB secret and keyrings.
- crypto.js: keyrings encrypted/decrypted with the KEK.
- ssb_config.js: decrypted keys in memory (no plaintext secret on disk).
2026-07-09 09:56:30 +02:00
195 changed files with 17445 additions and 29344 deletions

View file

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

400
README.md
View file

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

7
android/.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

252
android/gradlew vendored
View file

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

94
android/gradlew.bat vendored
View file

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

View file

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

View file

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

View file

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

148
main.js
View file

@ -67,9 +67,153 @@ console.log('[Oasis] Launching Oasis Social Network Utopia...');
console.log('[Oasis] Backend path:', backendPath);
console.log('[Oasis] HOME:', process.env.HOME);
try {
const http = require('http');
const keylock = require('./src/models/keylock');
const configPath = path.join(process.env.HOME, '.ssb');
let backendStarted = false;
function startBackend() {
if (backendStarted) return;
backendStarted = true;
try {
require(backendPath);
console.log('[Oasis] Backend started on port 3000');
} catch (error) {
} catch (error) {
console.error('[Oasis] Error starting backend:', error);
}
}
function unlockAndStart(keys) {
global.__OASIS_KEYS__ = keys;
for (const s of sockets) { try { s.destroy(); } catch (_) {} }
try { lockServer.close(() => startBackend()); } catch (_) { startBackend(); }
}
const MODE = keylock.isConfigured(configPath) ? 'unlock' : 'setup';
const LOCK_LANGS = ['en','es','fr','eu','de','it','pt','zh','ar','hi','ru'];
const LOCK_I18N = {
en: { name:"English", t_setup:"Create your password", t_unlock:"Unlock Oasis", s_setup:"It encrypts your identity and keys on this device. <b>If you forget it, you lose the account</b> (no recovery).", s_unlock:"Enter your password to decrypt your keys and start Oasis.", ph:"Password", ph2:"Repeat password", b_setup:"Create and enter", b_unlock:"Enter", e_min:"Minimum 6 characters", e_mism:"Passwords do not match", e_wrong:"Incorrect password", e_pre:"Error: ", m_unlock:"Unlocking…" },
es: { name:"Español", t_setup:"Crea tu contraseña", t_unlock:"Desbloquea Oasis", s_setup:"Cifrará tu identidad y tus claves en este dispositivo. <b>Si la olvidas, pierdes la cuenta</b> (no hay recuperación).", s_unlock:"Introduce tu contraseña para descifrar tus claves y arrancar Oasis.", ph:"Contraseña", ph2:"Repite la contraseña", b_setup:"Crear y entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"Las contraseñas no coinciden", e_wrong:"Contraseña incorrecta", e_pre:"Error: ", m_unlock:"Desbloqueando…" },
fr: { name:"Français", t_setup:"Créez votre mot de passe", t_unlock:"Déverrouiller Oasis", s_setup:"Il chiffre votre identité et vos clés sur cet appareil. <b>Si vous l'oubliez, vous perdez le compte</b> (aucune récupération).", s_unlock:"Saisissez votre mot de passe pour déchiffrer vos clés et démarrer Oasis.", ph:"Mot de passe", ph2:"Répétez le mot de passe", b_setup:"Créer et entrer", b_unlock:"Entrer", e_min:"6 caractères minimum", e_mism:"Les mots de passe ne correspondent pas", e_wrong:"Mot de passe incorrect", e_pre:"Erreur : ", m_unlock:"Déverrouillage…" },
eu: { name:"Euskara", t_setup:"Sortu zure pasahitza", t_unlock:"Desblokeatu Oasis", s_setup:"Zure identitatea eta gakoak gailu honetan zifratzen ditu. <b>Ahazten baduzu, kontua galtzen duzu</b> (ez dago berreskuratzerik).", s_unlock:"Sartu zure pasahitza gakoak deszifratu eta Oasis abiarazteko.", ph:"Pasahitza", ph2:"Errepikatu pasahitza", b_setup:"Sortu eta sartu", b_unlock:"Sartu", e_min:"Gutxienez 6 karaktere", e_mism:"Pasahitzak ez datoz bat", e_wrong:"Pasahitz okerra", e_pre:"Errorea: ", m_unlock:"Desblokeatzen…" },
de: { name:"Deutsch", t_setup:"Erstelle dein Passwort", t_unlock:"Oasis entsperren", s_setup:"Es verschlüsselt deine Identität und Schlüssel auf diesem Gerät. <b>Wenn du es vergisst, verlierst du das Konto</b> (keine Wiederherstellung).", s_unlock:"Gib dein Passwort ein, um deine Schlüssel zu entschlüsseln und Oasis zu starten.", ph:"Passwort", ph2:"Passwort wiederholen", b_setup:"Erstellen und eintreten", b_unlock:"Eintreten", e_min:"Mindestens 6 Zeichen", e_mism:"Passwörter stimmen nicht überein", e_wrong:"Falsches Passwort", e_pre:"Fehler: ", m_unlock:"Entsperren…" },
it: { name:"Italiano", t_setup:"Crea la tua password", t_unlock:"Sblocca Oasis", s_setup:"Cifra la tua identità e le tue chiavi su questo dispositivo. <b>Se la dimentichi, perdi l'account</b> (nessun recupero).", s_unlock:"Inserisci la password per decifrare le chiavi e avviare Oasis.", ph:"Password", ph2:"Ripeti la password", b_setup:"Crea ed entra", b_unlock:"Entra", e_min:"Minimo 6 caratteri", e_mism:"Le password non coincidono", e_wrong:"Password errata", e_pre:"Errore: ", m_unlock:"Sblocco…" },
pt: { name:"Português", t_setup:"Crie a sua palavra-passe", t_unlock:"Desbloquear o Oasis", s_setup:"Cifra a sua identidade e chaves neste dispositivo. <b>Se a esquecer, perde a conta</b> (sem recuperação).", s_unlock:"Introduza a sua palavra-passe para decifrar as chaves e iniciar o Oasis.", ph:"Palavra-passe", ph2:"Repita a palavra-passe", b_setup:"Criar e entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"As palavras-passe não coincidem", e_wrong:"Palavra-passe incorreta", e_pre:"Erro: ", m_unlock:"A desbloquear…" },
zh: { name:"中文", t_setup:"创建你的密码", t_unlock:"解锁 Oasis", s_setup:"它会在此设备上加密你的身份和密钥。<b>如果忘记,你将失去账户</b>(无法恢复)。", s_unlock:"输入密码以解密你的密钥并启动 Oasis。", ph:"密码", ph2:"重复密码", b_setup:"创建并进入", b_unlock:"进入", e_min:"至少 6 个字符", e_mism:"密码不一致", e_wrong:"密码错误", e_pre:"错误:", m_unlock:"正在解锁…" },
ar: { name:"العربية", t_setup:"أنشئ كلمة المرور", t_unlock:"فتح Oasis", s_setup:"يشفّر هويتك ومفاتيحك على هذا الجهاز. <b>إذا نسيتها، تفقد الحساب</b> (لا استعادة).", s_unlock:"أدخل كلمة المرور لفك تشفير مفاتيحك وتشغيل Oasis.", ph:"كلمة المرور", ph2:"أعد كلمة المرور", b_setup:"إنشاء ودخول", b_unlock:"دخول", e_min:"6 أحرف على الأقل", e_mism:"كلمتا المرور غير متطابقتين", e_wrong:"كلمة مرور غير صحيحة", e_pre:"خطأ: ", m_unlock:"جارٍ الفتح…" },
hi: { name:"हिन्दी", t_setup:"अपना पासवर्ड बनाएं", t_unlock:"Oasis अनलॉक करें", s_setup:"यह इस डिवाइस पर आपकी पहचान और कुंजियाँ एन्क्रिप्ट करता है। <b>भूल गए तो खाता खो देंगे</b> (कोई रिकवरी नहीं)।", s_unlock:"अपनी कुंजियाँ डिक्रिप्ट करने और Oasis शुरू करने के लिए पासवर्ड दर्ज करें।", ph:"पासवर्ड", ph2:"पासवर्ड दोहराएँ", b_setup:"बनाएँ और प्रवेश करें", b_unlock:"प्रवेश करें", e_min:"कम से कम 6 अक्षर", e_mism:"पासवर्ड मेल नहीं खाते", e_wrong:"गलत पासवर्ड", e_pre:"त्रुटि: ", m_unlock:"अनलॉक हो रहा है…" },
ru: { name:"Русский", t_setup:"Создайте пароль", t_unlock:"Разблокировать Oasis", s_setup:"Он шифрует вашу личность и ключи на этом устройстве. <b>Если забудете, потеряете аккаунт</b> (без восстановления).", s_unlock:"Введите пароль, чтобы расшифровать ключи и запустить Oasis.", ph:"Пароль", ph2:"Повторите пароль", b_setup:"Создать и войти", b_unlock:"Войти", e_min:"Минимум 6 символов", e_mism:"Пароли не совпадают", e_wrong:"Неверный пароль", e_pre:"Ошибка: ", m_unlock:"Разблокировка…" },
};
function lockPage(mode) {
const confirmField = mode === 'setup'
? '<input id="c" type="password" inputmode="text" autocomplete="new-password" />'
: '';
return '<!doctype html><!-- OASIS_LOCK_MARKER --><html lang="en"><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
+ '<title>Oasis</title><style>'
+ '*{box-sizing:border-box}body{margin:0;background:#121212;color:#eee;font-family:system-ui,sans-serif;'
+ 'min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}'
+ '.card{width:100%;max-width:360px;text-align:center}h1{color:#FFD700;font-size:22px;margin:0 0 8px}'
+ 'p{color:#aaa;font-size:14px;line-height:1.4;margin:0 0 20px}'
+ 'input{width:100%;padding:14px;margin:8px 0;border-radius:10px;border:1px solid #333;'
+ 'background:#1c1c1c;color:#eee;font-size:16px}'
+ 'button[type=submit]{width:100%;padding:14px;margin-top:12px;border:0;border-radius:10px;background:#FFD700;'
+ 'color:#111;font-size:16px;font-weight:600}button[type=submit]:disabled{opacity:.5}'
+ '#err{color:#ff6b6b;font-size:13px;min-height:18px;margin-top:8px}#msg{color:#FFD700;font-size:14px}'
+ '#langbtn{position:fixed;top:calc(env(safe-area-inset-top,0px) + 12px);right:12px;width:42px;height:42px;'
+ 'border-radius:50%;border:1px solid #333;background:#1c1c1c;color:#FFD700;font-size:13px;font-weight:700;'
+ 'padding:0;z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center}'
+ '.langmenu{position:fixed;top:calc(env(safe-area-inset-top,0px) + 60px);right:12px;background:#1c1c1c;'
+ 'border:1px solid #333;border-radius:12px;padding:6px;z-index:11;max-height:70vh;overflow:auto;min-width:150px;'
+ 'box-shadow:0 6px 20px rgba(0,0,0,.5)}'
+ '.langmenu button{display:block;width:100%;text-align:left;background:transparent;color:#eee;border:0;'
+ 'border-radius:8px;padding:11px 12px;font-size:15px;margin:0;font-weight:400;cursor:pointer}'
+ '.langmenu button.cur{color:#FFD700;font-weight:700;background:#262626}'
+ '[dir=rtl] #langbtn,[dir=rtl] .langmenu{right:auto;left:12px}[dir=rtl] .langmenu button{text-align:right}'
+ '</style></head><body>'
+ '<button id="langbtn" type="button" aria-label="Language">EN</button>'
+ '<div id="langmenu" class="langmenu" hidden></div>'
+ '<div class="card"><h1 id="title"></h1><p id="sub"></p>'
+ '<form id="f"><input id="p" type="password" inputmode="text" autocomplete="' + (mode === 'setup' ? 'new-password' : 'current-password') + '" autofocus />'
+ confirmField
+ '<button type="submit" id="btn"></button>'
+ '<div id="err"></div><div id="msg"></div></form></div>'
+ '<script>'
+ 'var MODE="' + mode + '";var I18N=' + JSON.stringify(LOCK_I18N) + ';var LANGS=' + JSON.stringify(LOCK_LANGS) + ';'
+ 'var $=function(id){return document.getElementById(id);};'
+ 'function getLang(){try{var s=localStorage.getItem("oasisLockLang");if(s&&I18N[s])return s;}catch(e){}return "en";}'
+ 'var LANG=getLang();'
+ 'function setCookie(l){try{document.cookie="language="+l+";path=/;max-age=31536000";}catch(e){}}'
+ 'try{if(localStorage.getItem("oasisLockLang"))setCookie(LANG);}catch(e){}'
+ 'function apply(){var t=I18N[LANG]||I18N.en;'
+ 'document.documentElement.lang=LANG;document.documentElement.dir=(LANG==="ar")?"rtl":"ltr";'
+ '$("title").innerHTML=(MODE==="setup")?t.t_setup:t.t_unlock;'
+ '$("sub").innerHTML=(MODE==="setup")?t.s_setup:t.s_unlock;'
+ '$("p").placeholder=t.ph;var c=$("c");if(c)c.placeholder=t.ph2;'
+ '$("btn").textContent=(MODE==="setup")?t.b_setup:t.b_unlock;'
+ '$("langbtn").textContent=LANG.toUpperCase();}'
+ 'function buildMenu(){var m=$("langmenu");m.innerHTML="";LANGS.forEach(function(l){'
+ 'var b=document.createElement("button");b.type="button";b.textContent=(I18N[l]&&I18N[l].name)||l;'
+ 'if(l===LANG)b.className="cur";b.onclick=function(){LANG=l;try{localStorage.setItem("oasisLockLang",l);}catch(e){}'
+ 'setCookie(l);apply();buildMenu();m.hidden=true;};m.appendChild(b);});}'
+ '$("langbtn").onclick=function(){var m=$("langmenu");m.hidden=!m.hidden;};'
+ 'document.addEventListener("click",function(e){var m=$("langmenu");if(!m.hidden&&e.target!==$("langbtn")&&!m.contains(e.target))m.hidden=true;});'
+ 'buildMenu();apply();'
+ 'var f=$("f"),err=$("err"),msg=$("msg");'
+ 'f.addEventListener("submit",async function(e){e.preventDefault();var t=I18N[LANG]||I18N.en;err.textContent="";'
+ 'var p=$("p").value,c=$("c");'
+ 'if(MODE==="setup"){if(p.length<6){err.textContent=t.e_min;return;}if(c&&c.value!==p){err.textContent=t.e_mism;return;}}'
+ 'var btn=$("btn");btn.disabled=true;'
+ 'try{var r=await fetch(MODE==="setup"?"/__keylock/setup":"/__keylock/unlock",{method:"POST",'
+ 'headers:{"Content-Type":"application/json"},body:JSON.stringify({password:p})});'
+ 'if(r.ok){msg.textContent=t.m_unlock;waitReload();}else{err.textContent=t.e_wrong;btn.disabled=false;}}'
+ 'catch(e2){err.textContent=t.e_pre+e2.message;btn.disabled=false;}});'
+ 'async function waitReload(){for(var i=0;i<120;i++){try{var r=await fetch("/?_k="+Date.now(),{cache:"no-store"});'
+ 'var t=await r.text();if(t.indexOf("OASIS_LOCK_MARKER")===-1){location.reload();return;}}catch(e){}'
+ 'await new Promise(function(s){setTimeout(s,500);});}location.reload();}'
+ '</script></body></html>';
}
function readBody(req) {
return new Promise((resolve) => {
let b = '';
req.on('data', (d) => { b += d; if (b.length > 1e5) req.destroy(); });
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (_) { resolve({}); } });
req.on('error', () => resolve({}));
});
}
const sockets = new Set();
const lockServer = http.createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/__keylock/setup') {
const { password } = await readBody(req);
if (!password || String(password).length < 6) { res.writeHead(400, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
keylock.setPassword(configPath, password);
const keys = keylock.loadOrCreateSecret(configPath);
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
res.end('{"ok":true}');
return unlockAndStart(keys);
}
if (req.method === 'POST' && req.url === '/__keylock/unlock') {
const { password } = await readBody(req);
const kek = password ? keylock.verifyPassword(configPath, password) : null;
if (!kek) { res.writeHead(401, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
const keys = keylock.loadOrCreateSecret(configPath);
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
res.end('{"ok":true}');
return unlockAndStart(keys);
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
res.end(lockPage(MODE));
} catch (e) {
res.writeHead(500, { 'Connection': 'close' });
res.end('{"ok":false,"error":"' + String(e && e.message).replace(/"/g, '') + '"}');
}
});
lockServer.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
lockServer.listen(3000, '127.0.0.1', () => {
console.log('[Oasis] keylock gate escuchando en 127.0.0.1:3000 (modo: ' + MODE + ')');
});

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,74 +0,0 @@
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
};

View file

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

View file

@ -3,7 +3,7 @@ const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const { escapePdf } = require('./pdfDocument');
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;
@ -94,8 +94,8 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
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) {
@ -156,7 +156,7 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,777 @@
html { overflow-x: clip !important; overflow-y: visible !important; height: auto !important; }
body { overflow-x: clip !important; overflow-y: visible !important; }
.oasis-bottombar {
display: flex !important;
position: fixed !important;
left: 0 !important; right: 0 !important; bottom: 0 !important;
z-index: 1000 !important;
justify-content: space-around !important;
align-items: center !important;
gap: 4px !important;
background: #1F1F1F !important;
border-top: 1px solid #333 !important;
padding: 8px 6px calc(8px + env(safe-area-inset-bottom, 0px)) !important;
}
.oasis-bottombar .bb-item {
position: relative !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 3px !important;
flex: 1 1 0 !important;
min-width: 0 !important;
padding: 2px 0 !important;
text-decoration: none !important;
}
.oasis-bottombar .bb-ico { font-size: 18px !important; line-height: 1 !important; color: #FFA500 !important; }
.oasis-bottombar .bb-lbl { font-size: 9px !important; color: #ccc !important; white-space: nowrap !important; }
.oasis-bottombar .bb-badge {
position: absolute !important;
top: -4px !important;
left: calc(50% + 6px) !important;
min-width: 15px !important; height: 15px !important;
padding: 0 3px !important;
border-radius: 8px !important;
background: #FFA500 !important;
color: #121212 !important;
font-size: 9px !important; font-weight: bold !important;
display: flex !important; align-items: center !important; justify-content: center !important;
}
body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)) !important; }
.oasis-mobile-topbar {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
width: 100% !important;
padding: 8px 12px !important;
background: transparent !important;
box-shadow: none !important; border: 0 !important;
}
.oasis-mobile-topbar .omt-logo img,
.oasis-mobile-topbar .omt-avatar img {
width: 32px !important; height: 32px !important;
border-radius: 50% !important;
border: 2px solid #FFA500 !important;
object-fit: cover !important; display: block !important;
}
.oasis-mobile-topbar .omt-title {
flex: 1 1 auto !important;
text-align: center !important;
font-size: 26px !important; font-weight: bold !important;
letter-spacing: 10px !important; text-indent: 10px !important;
color: #FFD700 !important; text-decoration: none !important;
white-space: nowrap !important;
background: transparent !important; border: 0 !important; box-shadow: none !important; padding: 0 !important;
}
.header .top-bar-left > a.logo-icon { display: none !important; }
.oasis-mobile-menus { display: flex !important; gap: 8px !important; padding: 0 10px 6px !important; }
.oasis-mobile-menus .oasis-menu-ico { display: none !important; }
.oasis-mobile-menus .oasis-menu-btn {
flex: 1 1 0 !important;
display: flex !important; align-items: center !important; justify-content: center !important;
padding: 12px !important;
border: 1.5px solid #333 !important; border-radius: 10px !important;
background: #121212 !important; color: #ccc !important;
font-weight: bold !important; letter-spacing: 1px !important; cursor: pointer !important;
}
#nav-left-toggle:checked ~ .header .oasis-menu-btn[for="nav-left-toggle"] {
background: #FFA500 !important; color: #121212 !important; border-color: #FFA500 !important;
}
#nav-right-toggle:checked ~ .header .oasis-menu-btn[for="nav-right-toggle"] {
background: #FFD700 !important; color: #121212 !important; border-color: #FFD700 !important;
}
:root {
--space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px; --space-5: 32px;
--fs-caption: 12px; --fs-body: 14px; --fs-body-lg: 16px; --fs-title: 20px;
--lh-body: 1.5; --measure: 66ch;
--c-muted: #8c8c8c; --radius-md: 12px;
}
.oasis-bottombar .bb-fab .bb-ico {
width: 46px !important; height: 46px !important;
margin-top: -22px !important;
border-radius: 50% !important;
background: #FFA500 !important; color: #121212 !important;
display: flex !important; align-items: center !important; justify-content: center !important;
font-size: 22px !important;
box-shadow: 0 2px 8px rgba(0,0,0,.4) !important;
}
.oasis-bottombar .bb-fab .bb-lbl { color: #FFA500 !important; font-weight: bold !important; }
.main-column, .main-column p, .main-column li, .main-column a,
.main-column span, .main-column td, .main-column h1, .main-column h2, .main-column h3 {
overflow-wrap: anywhere !important;
word-break: break-word !important;
}
.main-column { padding: var(--space-3) !important; }
.main-column p { line-height: var(--lh-body) !important; }
.main-column > * + * { margin-top: var(--space-3) !important; }
.main-column img, .main-column video, .main-column table, .main-column pre, .main-column iframe {
max-width: 100% !important; height: auto !important;
}
.main-column pre { white-space: pre-wrap !important; }
.oasis-nav-group {
list-style: none !important;
background: #1c1c1c !important;
border: 1px solid #333 !important;
border-radius: var(--radius-md) !important;
overflow: hidden !important;
margin-bottom: var(--space-2) !important;
}
.oasis-nav-header {
display: flex !important; align-items: center !important; gap: var(--space-2) !important;
min-height: 48px !important; padding: 0 var(--space-3) !important;
cursor: pointer !important;
color: #FFD700 !important; font-weight: bold !important;
text-transform: uppercase !important; letter-spacing: .04em !important;
font-size: var(--fs-caption) !important;
}
.oasis-nav-header .nav-text { flex: 1 1 auto !important; }
.oasis-nav-header .oasis-nav-arrow { color: var(--c-muted) !important; transition: transform 150ms ease !important; }
.oasis-nav-toggle:checked + .oasis-nav-header .oasis-nav-arrow { transform: rotate(180deg) !important; }
.oasis-nav-header:active { background: #262626 !important; }
.oasis-nav-list { list-style: none !important; padding: 0 !important; margin: 0 !important; }
.oasis-nav-list li a {
display: flex !important; align-items: center !important; gap: var(--space-3) !important;
min-height: 48px !important; padding: 0 var(--space-3) !important;
color: #e8e8e8 !important; text-decoration: none !important;
border-top: 1px solid #262626 !important;
}
.oasis-nav-list li a .emoji { flex: 0 0 auto !important; width: 22px !important; text-align: center !important; color: #FFA500 !important; }
.oasis-nav-list li a .nav-text { font-size: var(--fs-body) !important; }
.oasis-nav-list li a:active { background: #262626 !important; }
.oasis-nav-list li a.current { color: #FFA500 !important; font-weight: bold !important; }
.main-column h1 { font-size: var(--fs-title) !important; font-weight: 700 !important; line-height: 1.25 !important; margin: 0 0 var(--space-2) !important; }
.main-column h2 { font-size: 18px !important; font-weight: 700 !important; line-height: 1.3 !important; margin: 0 0 var(--space-2) !important; }
.main-column h3 { font-size: var(--fs-body-lg) !important; font-weight: 600 !important; margin: 0 0 var(--space-1) !important; }
.main-column p { font-size: var(--fs-body-lg) !important; }
.main-column .created-at, .main-column .small, .main-column .time,
.main-column .meta, .main-column .author-meta, .main-column .subtitle {
color: var(--c-muted) !important; font-size: var(--fs-caption) !important;
}
.main-column article,
.main-column [class*="-card"] {
background: #1c1c1c !important;
border: 1px solid #333 !important;
border-radius: var(--radius-md) !important;
padding: var(--space-3) !important;
}
.main-column article:active,
.main-column [class*="-card"]:active { background: #202020 !important; }
.btn, button, input[type="submit"], input[type="button"], .submit-button,
.filter-btn, .create-button, .edit-btn, .delete-btn, .join-btn, .leave-btn, .buy-btn {
min-height: 48px !important;
}
button:active, input[type="submit"]:active, .submit-button:active,
.filter-btn:active, .create-button:active, .edit-btn:active,
.delete-btn:active, .join-btn:active, .leave-btn:active, .buy-btn:active {
filter: brightness(.85) !important;
}
.main-column a:active { opacity: .6 !important; }
* { -webkit-tap-highlight-color: transparent !important; }
a, button, label, .tap, .oasis-menu-btn, .bb-item, .oasis-nav-header, .oasis-nav-list li a {
touch-action: manipulation !important;
}
body { overscroll-behavior: none !important; }
.main-content, .main-column, .header,
.sidebar-left, .sidebar-right { overflow: visible !important; }
.sidebar-left, .sidebar-right { display: none !important; }
#nav-left-toggle:checked ~ .main-content .sidebar-left { display: block !important; }
#nav-right-toggle:checked ~ .main-content .sidebar-right { display: block !important; }
.bb-manage { margin: 8px 0 16px !important; }
.bb-manage h3, .bb-manage-add h3 {
font-size: 13px !important; text-transform: uppercase !important; letter-spacing: .05em !important;
color: #FFA500 !important; margin: 14px 0 8px !important;
}
.bb-manage-list { display: flex !important; flex-direction: column !important; gap: 8px !important; }
.bb-manage-form { margin: 0 !important; width: 100% !important; }
.bb-manage-btn {
width: 100% !important;
display: flex !important; align-items: center !important; gap: 12px !important;
min-height: 52px !important; padding: 0 14px !important;
background: #1c1c1c !important; border: 1px solid #333 !important; border-radius: 10px !important;
color: #eee !important; cursor: pointer !important; text-align: left !important;
}
.bb-manage-ico { font-size: 20px !important; color: #FFA500 !important; width: 26px !important; text-align: center !important; }
.bb-manage-lbl { flex: 1 1 auto !important; font-size: 15px !important; }
.bb-manage-x { font-size: 16px !important; color: #ff6b6b !important; font-weight: bold !important; }
.bb-manage-empty, .bb-manage-full { color: #999 !important; font-size: 14px !important; padding: 8px 2px !important; }
.bb-manage-done {
display: block !important; text-align: center !important;
margin-top: 16px !important; min-height: 48px !important; line-height: 48px !important;
}
.hive-nav { --hx: min(64px, calc((100vw - 40px) / 4)); --hxm: min(58px, calc((100vw - 36px) / 5)); margin: 8px 0 16px !important; overflow: visible !important; background: transparent !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; }
.hive-radio { position: absolute !important; opacity: 0 !important; width: 0 !important; height: 0 !important; pointer-events: none !important; }
.hive-grid { display: flex !important; flex-direction: column !important; align-items: center !important; overflow: visible !important; background: transparent !important; padding: 0 !important; margin: 0 !important; box-shadow: none !important; }
.hive-row { display: flex !important; justify-content: center !important; gap: 3px !important; overflow: visible !important; background: transparent !important; padding: 0 !important; margin: 0 !important; box-shadow: none !important; }
.hive-cats .hive-row + .hive-row { margin-top: calc(var(--hx) * -0.30) !important; }
.hex-cat, .hex-mod {
position: relative !important;
-webkit-clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; gap: 2px !important;
text-decoration: none !important; cursor: pointer !important; box-sizing: border-box !important;
background: #D9A400 !important;
filter: drop-shadow(0 1px 2px rgba(0,0,0,.6)) !important;
}
.hex-cat::before, .hex-mod::before {
content: '' !important; position: absolute !important; inset: 1.5px !important; z-index: 0 !important;
-webkit-clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
background: #161616 !important;
}
.hex-cat { width: var(--hx) !important; height: calc(var(--hx) * 1.15) !important; }
.hex-mod { width: var(--hxm) !important; height: calc(var(--hxm) * 1.15) !important; }
.hex-cat .hx-ico, .hex-mod .hx-ico { color: #FFA500 !important; line-height: 1 !important; position: relative !important; z-index: 1 !important; }
.hex-cat .hx-ico { font-size: 17px !important; }
.hex-mod .hx-ico { font-size: 16px !important; }
.hex-cat .hx-lbl, .hex-mod .hx-lbl { color: #d8d8d8 !important; text-align: center !important; line-height: 1 !important; font-weight: bold !important; overflow: hidden !important; position: relative !important; z-index: 1 !important; }
.hex-cat .hx-lbl { font-size: 8px !important; max-width: 88% !important; }
.hex-mod .hx-lbl { font-size: 8px !important; max-width: 92% !important; }
.hex-cat:active::before, .hex-mod:active::before { background: #242424 !important; }
#hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"],
#hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"],
#hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"],
#hive-economy:checked ~ .hive-grid .hex-cat[for="hive-economy"],
#hive-tools:checked ~ .hive-grid .hex-cat[for="hive-tools"],
#hive-network:checked ~ .hive-grid .hex-cat[for="hive-network"],
#hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"],
#hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"],
#hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"],
#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"] { background: #FFA500 !important; }
#hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"]::before,
#hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"]::before,
#hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"]::before,
#hive-economy:checked ~ .hive-grid .hex-cat[for="hive-economy"]::before,
#hive-tools:checked ~ .hive-grid .hex-cat[for="hive-tools"]::before,
#hive-network:checked ~ .hive-grid .hex-cat[for="hive-network"]::before,
#hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"]::before,
#hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"]::before,
#hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"]::before,
#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"]::before { background: #FFA500 !important; }
#hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"] :is(.hx-ico,.hx-lbl),
#hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"] :is(.hx-ico,.hx-lbl),
#hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"] :is(.hx-ico,.hx-lbl),
#hive-economy:checked ~ .hive-grid .hex-cat[for="hive-economy"] :is(.hx-ico,.hx-lbl),
#hive-tools:checked ~ .hive-grid .hex-cat[for="hive-tools"] :is(.hx-ico,.hx-lbl),
#hive-network:checked ~ .hive-grid .hex-cat[for="hive-network"] :is(.hx-ico,.hx-lbl),
#hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"] :is(.hx-ico,.hx-lbl),
#hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"] :is(.hx-ico,.hx-lbl),
#hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"] :is(.hx-ico,.hx-lbl),
#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"] :is(.hx-ico,.hx-lbl) { color: #121212 !important; }
.hive-panel { display: none !important; margin: 12px auto 0 !important; max-width: 340px !important; background: #101010 !important; border: 1px solid #242424 !important; border-radius: 16px !important; padding: 12px 8px !important; overflow: visible !important; box-shadow: none !important; }
#hive-personal:checked ~ #hive-panel-personal,
#hive-governance:checked ~ #hive-panel-governance,
#hive-office:checked ~ #hive-panel-office,
#hive-economy:checked ~ #hive-panel-economy,
#hive-tools:checked ~ #hive-panel-tools,
#hive-network:checked ~ #hive-panel-network,
#hive-blogs:checked ~ #hive-panel-blogs,
#hive-creative:checked ~ #hive-panel-creative,
#hive-media:checked ~ #hive-panel-media,
#hive-fediverse:checked ~ #hive-panel-fediverse { display: block !important; }
.hive-mods { display: flex !important; flex-wrap: wrap !important; justify-content: center !important; gap: 4px !important; margin: 0 !important; padding: 0 !important; background: transparent !important; overflow: visible !important; }
.header .top-bar-left nav,
.header .top-bar-right nav { display: none !important; }
.oasis-mobile-topbar { padding-top: calc(env(safe-area-inset-top, 0px) + 10px) !important; }
.oasis-bottombar .bb-pair {
display: flex !important; align-items: center !important;
flex: 0 0 auto !important;
gap: 2px !important; padding: 3px !important;
background: #262626 !important;
border: 1px solid #3a3a3a !important;
border-radius: 999px !important;
}
.oasis-bottombar .bb-pair .bb-item { flex: 0 0 auto !important; padding: 2px 8px !important; }
.oasis-bottombar .bb-pair .bb-lbl { font-size: 8px !important; }
.oasis-bottombar a.bb-item.current .bb-ico {
background: #FFA500 !important; color: #121212 !important;
border-radius: 999px !important; padding: 4px 12px !important;
}
.oasis-bottombar a.bb-item.current .bb-lbl { color: #FFA500 !important; font-weight: bold !important; }
.oasis-bottombar a.bb-item.current .bb-badge { background: #121212 !important; color: #FFA500 !important; }
.main-column, .main-column p, .main-column li, .main-column a,
.main-column span, .main-column td, .main-column th, .main-column label,
.main-column h1, .main-column h2, .main-column h3, .main-column h4,
.main-column strong, .main-column em, .main-column small,
.created-at, .post-meta, .feed-post-meta,
.feed-row .small, .feed-row .time, .feed-row .created-at,
.profile-sensors-box > *,
.card-field .card-value, .card-field .card-label {
overflow-wrap: break-word !important;
word-break: normal !important;
-webkit-hyphens: none !important;
hyphens: none !important;
}
.main-column pre, .main-column code, kbd, samp,
.peer-key, .user-link.peer-key, .oasis-id-copy, .search-oasis-id,
.pm-sent-key-value, .kpi__value,
.block-info-table td .peer-key, .block-info-table td .user-link,
.pm-info-table td .user-link,
.main-column a[href*="ed25519"], .main-column a[href^="ssb:"],
.main-column a[href^="/author/"] {
overflow-wrap: anywhere !important;
word-break: break-all !important;
}
button, input[type="submit"], input[type="button"],
.filter-btn, a.filter-btn, .create-button, .edit-btn, .delete-btn,
.join-btn, .leave-btn, .buy-btn, .submit-button, .btn,
.conn-actions button, .peers-conn-actions button,
.mode-buttons button, .mode-buttons .filter-btn,
.filter-group button, .filter-group .filter-btn,
.main-column button, .main-column input[type="submit"] {
white-space: normal !important;
word-break: normal !important;
overflow-wrap: break-word !important;
-webkit-hyphens: none !important;
hyphens: none !important;
}
.conn-actions, .peers-conn-actions {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: stretch !important;
gap: 8px !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
.conn-actions > form, .peers-conn-actions > form {
flex: 0 1 auto !important;
width: auto !important;
min-width: 0 !important;
margin: 0 !important;
display: flex !important;
}
.pm-info-table, .pm-info-table tbody,
.block-info-table, .block-info-table tbody {
display: block !important;
width: 100% !important;
max-width: 100% !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
table-layout: auto !important;
}
.pm-info-table thead, .block-info-table thead { display: none !important; }
.pm-info-table tr, .block-info-table tr {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
border: 0 !important;
border-bottom: 1px solid rgba(255,165,0,0.18) !important;
border-radius: 0 !important;
margin: 0 0 8px 0 !important;
padding: 6px 0 !important;
background: transparent !important;
}
.pm-info-table td, .block-info-table td {
display: block !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
border: 0 !important;
background: transparent !important;
padding: 1px 0 !important;
text-align: left !important;
overflow-wrap: break-word !important;
word-break: normal !important;
}
.pm-info-table td.card-label, .block-info-table td.card-label {
color: var(--c-muted, #8c8c8c) !important;
font-size: 11px !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: .03em !important;
margin-bottom: 2px !important;
padding-bottom: 0 !important;
}
.pm-info-table td.card-value, .block-info-table td.card-value {
font-size: 15px !important;
line-height: 1.5 !important;
margin-bottom: 6px !important;
}
.block-info-table tr:first-child:has(> td.card-label) { display: none !important; }
.block-info-table td[data-label]:not([data-label=""])::before {
content: attr(data-label);
display: block !important;
color: var(--c-muted, #8c8c8c) !important;
font-size: 10px !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: .03em !important;
margin-bottom: 1px !important;
white-space: nowrap !important;
word-break: normal !important;
}
.block-info-table td[data-label=""]::before { content: none !important; }
.peers-list .block-info-table td,
.peers-technical-block .block-info-table td,
.gov-overview tbody td {
word-break: normal !important;
overflow-wrap: break-word !important;
}
.pm-info-table td .user-link, .pm-info-table td .peer-key, .pm-info-table td a[href],
.block-info-table td .peer-key, .block-info-table td .user-link, .block-info-table td a[href] {
display: inline-block !important;
max-width: 100% !important;
overflow-wrap: anywhere !important;
word-break: break-all !important;
}
.pm-info-table td form, .block-info-table td form,
.pm-info-table td button, .block-info-table td button {
width: 100% !important;
margin: 0 !important;
}
.main-content { width: 100% !important; max-width: 100% !important; }
.main-column {
width: 100% !important; max-width: 100% !important;
padding: 16px !important; box-sizing: border-box !important;
}
.main-column .card-field {
flex-direction: column !important;
align-items: stretch !important;
gap: 2px !important;
}
.main-column .card-field .card-value {
width: 100% !important; max-width: 100% !important; font-size: 15px !important;
}
.main-column .card-field .card-label {
color: var(--c-muted, #8c8c8c) !important; font-size: 11px !important;
}
.main-column img, .main-column video, .main-column iframe, .main-column canvas {
max-width: 100% !important; height: auto !important;
}
.main-column hr {
width: 100% !important; border: 0 !important;
border-top: 1px solid #333 !important; margin: 16px 0 !important;
}
.activity-filter-acc { width: 100% !important; margin-bottom: 8px !important; }
.activity-quick-modes {
display: flex !important; flex-wrap: nowrap !important; gap: 6px !important; margin-bottom: 8px !important;
}
.activity-quick-modes form { flex: 1 1 0 !important; min-width: 0 !important; margin: 0 !important; display: flex !important; }
.activity-quick-modes .filter-btn {
flex: 1 1 0 !important; width: 100% !important; min-height: 44px !important;
white-space: nowrap !important; word-break: keep-all !important; overflow-wrap: normal !important;
padding-left: 6px !important; padding-right: 6px !important; font-size: 14px !important;
}
.activity-groups { list-style: none !important; padding: 0 !important; margin: 0 !important; }
.activity-groups .oasis-nav-header .emoji { display: none !important; }
.activity-groups .oasis-nav-header .nav-text { flex: 1 1 auto !important; }
.activity-groups .act-count {
flex: 0 0 auto !important; min-width: 20px !important; text-align: center !important;
font-size: 11px !important; font-weight: 700 !important; color: #121212 !important;
background: #FFA500 !important; border-radius: 999px !important; padding: 1px 7px !important;
margin: 0 8px 0 0 !important;
}
.activity-groups .oasis-nav-list li { list-style: none !important; margin: 0 !important; padding: 0 !important; }
.activity-groups .oasis-nav-list form { margin: 0 !important; display: block !important; width: 100% !important; }
.activity-groups .oasis-nav-list .filter-btn {
display: block !important; width: 100% !important; text-align: left !important;
min-height: 44px !important; margin: 2px 0 !important; box-sizing: border-box !important;
}
.activity-groups .oasis-nav-list .filter-btn.active {
outline: 2px solid #FFA500 !important; outline-offset: -2px !important;
}
.message-list { display: flex !important; flex-direction: column !important; gap: 4px !important; width: 100% !important; }
.message-list .pm-thread { display: flex !important; flex-direction: column !important; width: 100% !important; align-self: stretch !important; }
.pm-thread-replies { display: flex !important; flex-direction: column !important; gap: 4px !important; }
.pm-card.pm-bubble {
max-width: 82% !important; width: fit-content !important; min-width: 0 !important;
box-sizing: border-box !important; padding: 8px 12px !important; margin: 2px 0 !important;
border-radius: 16px !important; border: 1px solid #333 !important;
background: #1e1e1e !important; box-shadow: none !important; cursor: default !important;
}
.pm-card.pm-bubble.pm-recv {
align-self: flex-start !important; margin-left: 0 !important; margin-right: auto !important;
border-bottom-left-radius: 4px !important; background: #1e1e1e !important; border-color: #343434 !important;
}
.pm-card.pm-bubble.pm-sent {
align-self: flex-end !important; margin-left: auto !important; margin-right: 0 !important;
border-bottom-right-radius: 4px !important; background: #2a2413 !important; border-color: #5c4c1f !important;
}
.pm-bubble .pm-bubble-sender { font-size: 11px !important; font-weight: 700 !important; color: #FFA500 !important; margin-bottom: 3px !important; }
.pm-bubble .pm-bubble-sender a { color: #FFA500 !important; }
.pm-bubble .message-text { font-size: 15px !important; line-height: 1.42 !important; color: #ececec !important; margin: 0 !important; }
.pm-bubble .message-text a { color: #FFD700 !important; }
.pm-bubble .pm-bubble-meta {
display: flex !important; flex-wrap: wrap !important; align-items: center !important;
gap: 6px !important; margin-top: 4px !important; font-size: 10px !important; color: var(--c-muted, #8c8c8c) !important;
}
.pm-card.pm-sent .pm-bubble-meta { justify-content: flex-end !important; }
.pm-card.pm-recv .pm-bubble-meta { justify-content: flex-start !important; }
.pm-bubble .pm-bubble-subject { font-style: italic !important; opacity: .75 !important; max-width: 50% !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; }
.pm-bubble .pm-bubble-lock { opacity: .85 !important; }
.pm-bubble .pm-bubble-eco { font-size: 9px !important; opacity: .9 !important; }
.pm-bubble .pm-bubble-eco * { font-size: 9px !important; }
.pm-bubble .pm-actions { margin-top: 6px !important; display: flex !important; gap: 6px !important; }
.pm-card.pm-sent .pm-actions { justify-content: flex-end !important; }
.pm-bubble .pm-actions .pm-btn { font-size: 10px !important; padding: 3px 8px !important; min-height: 28px !important; }
.pm-bubble .pm-actions form { margin: 0 !important; }
.pm-bubble .pm-info-table { display: none !important; }
.pm-thread .pm-thread-details { width: 100% !important; }
.pm-thread .pm-thread-toggle { font-size: 12px !important; color: var(--c-muted, #8c8c8c) !important; padding: 4px 0 !important; cursor: pointer !important; }
.main-column h1, .main-column h2, .main-column h3, .main-column h4,
.tags-header h2, .tags-header h3, .title-with-chip h2, .pm-title, h2.pm-title,
.cn-name, h1.cn-name, .cn-section, h2.cn-section, .profile-side-name, h2.profile-side-name {
text-wrap: balance !important;
overflow-wrap: break-word !important;
word-break: keep-all !important;
-webkit-hyphens: none !important; hyphens: none !important;
text-align: left !important;
}
.main-column h1, .tags-header h2, .cn-name {
font-size: var(--fs-title, 20px) !important; font-weight: 700 !important;
line-height: 1.22 !important; letter-spacing: -0.01em !important;
margin: 0 0 var(--space-2, 8px) !important;
}
.main-column h2, .pm-title, .title-with-chip h2, .cn-section {
font-size: 18px !important; font-weight: 700 !important;
line-height: 1.28 !important; margin: var(--space-1, 4px) 0 var(--space-2, 8px) !important;
}
.main-column h3 {
font-size: var(--fs-body-lg, 16px) !important; font-weight: 600 !important;
line-height: 1.35 !important; margin: var(--space-1, 4px) 0 !important;
}
.main-column h1, .main-column h2, .main-column h3,
.tags-header h2, .pm-title, .cn-name, .cn-section {
max-width: 40ch !important; width: auto !important; min-width: 0 !important;
}
.main-column h1 code, .main-column h2 code, .main-column h3 code,
.tags-header h2 code, .pm-title code,
.main-column h1 a[href*="ed25519"], .main-column h2 a[href*="ed25519"],
.main-column h1 a[href^="ssb:"], .main-column h2 a[href^="ssb:"] {
word-break: break-all !important; overflow-wrap: anywhere !important;
}
.main-column .tags-header {
display: flex !important; flex-direction: column !important; align-items: flex-start !important;
gap: var(--space-1, 4px) !important; width: 100% !important; max-width: 100% !important;
margin: 0 0 var(--space-2, 8px) !important;
}
.main-column .tags-header h2 { margin: 0 !important; }
.main-column .title-with-chip {
display: flex !important; flex-wrap: wrap !important; align-items: center !important;
gap: var(--space-2, 8px) !important; width: 100% !important; max-width: 100% !important;
}
.main-column .title-with-chip h2 { flex: 1 1 auto !important; min-width: 0 !important; margin: 0 !important; max-width: none !important; }
.main-column .title-with-chip > :not(h2) { flex: 0 0 auto !important; }
.main-column p, .main-column li,
.tags-header p, .tags-header .prefs-help, .indexing-note, .indexing-percent,
.error-page-message, .welcome-text, .message-text, .pm-bubble .message-text {
text-wrap: pretty !important;
line-height: 1.55 !important;
max-width: min(66ch, 100%) !important;
overflow-wrap: anywhere !important;
word-break: normal !important;
min-width: 0 !important;
-webkit-hyphens: none !important; hyphens: none !important;
}
.main-column p + p { margin-top: var(--space-3, 16px) !important; }
.tags-header p, .tags-header .prefs-help, .indexing-note { line-height: 1.5 !important; margin: 4px 0 0 0 !important; }
.pm-bubble .message-text { line-height: 1.45 !important; max-width: 100% !important; }
[style*="auto-fill"],
[style*="minmax(240px"], [style*="minmax(280px"], [style*="minmax(300px"],
[style*="minmax( 240px"], [style*="minmax( 280px"], [style*="minmax( 300px"],
.trending-container, .cn-hub-grid, .cn-products {
display: grid !important; grid-template-columns: 1fr !important; gap: 12px !important;
width: 100% !important; max-width: 100% !important;
}
.trending-container > *, .games-grid > *, .market-grid > *, .tribe-grid > *,
.feed-container, .feed-card, .trending-card,
.main-column article, .main-column [class*="-card"], .main-column [class*="-item"],
.main-column [class*="-entry"], .main-column .card {
width: 100% !important; max-width: 100% !important; min-width: 0 !important;
box-sizing: border-box !important; margin-left: 0 !important; margin-right: 0 !important;
}
.feed-card .feed-text, .trending-card .feed-text,
.main-column article p, .main-column [class*="-card"] p { max-width: var(--measure, 66ch) !important; }
.main-column form, .comment-form, .comment-form-wrapper, .market-form,
.new-message-form, .project-control-form, .filter-box {
display: block !important; width: 100% !important; max-width: 100% !important;
min-width: 0 !important; box-sizing: border-box !important;
}
.main-column input:not([type="checkbox"]):not([type="radio"]):not(.oasis-nav-toggle):not(.oasis-nav-side-toggle),
.main-column textarea, .main-column select {
width: 100% !important; max-width: 100% !important; min-width: 0 !important; box-sizing: border-box !important;
}
.main-column form > button:only-of-type, .main-column form > .create-button,
.comment-form button[type="submit"], .market-form button[type="submit"],
.new-message-form button[type="submit"] {
display: block !important; width: 100% !important; max-width: 100% !important;
box-sizing: border-box !important; margin-top: 8px !important;
}
[style*="width:33%"], [style*="width: 33%"], [style*="width:25%"], [style*="width: 25%"],
[style*="width:60%"], [style*="width: 60%"], [style*="width:40%"], [style*="width: 40%"] {
width: 100% !important; max-width: 100% !important;
}
.main-column [style*="max-width: 800px"], .main-column [style*="max-width:800px"],
.ai-response, .ai-train-bar {
max-width: 100% !important; width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; box-sizing: border-box !important;
}
.message-list, .pm-thread, .pm-thread-replies { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; }
.main-column p .user-link, .main-column p a[href^="/author/"],
.main-column li .user-link, .main-column span .user-link,
.main-column .message-text .user-link, .main-column .message-text a[href^="/author/"],
.pm-bubble .message-text .user-link, .pm-bubble .message-text a[href^="/author/"],
.pm-quote a[href^="/author/"] {
display: inline !important; white-space: normal !important;
overflow-wrap: anywhere !important; word-break: keep-all !important;
-webkit-hyphens: none !important; hyphens: none !important;
}
.peer-key, .user-link.peer-key, .block-info-table td .peer-key, .pm-info-table td .peer-key,
.pm-info-table td .user-link, .block-info-table td .user-link,
.search-oasis-id, .pm-sent-key-value, .kpi__value {
display: inline-block !important; max-width: 100% !important;
overflow-wrap: anywhere !important; word-break: break-all !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
font-variant-numeric: tabular-nums !important; line-height: 1.45 !important;
}
.oasis-id-copy {
display: block !important; width: 100% !important; white-space: nowrap !important;
overflow: hidden !important; text-overflow: ellipsis !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
}
.invites-list .user-link.peer-key, .peers-list .block-info-table td .peer-key {
display: block !important; max-width: 100% !important; white-space: nowrap !important;
overflow: hidden !important; text-overflow: ellipsis !important; word-break: normal !important;
}
.main-column .message-text a[href^="http"], .main-column p a[href^="http"],
.pm-bubble .message-text a[href^="http"], .main-column .message-text a.oasis-path-link,
.main-column .message-text a.job-link, .main-column .message-text a.project-link,
.main-column .message-text a.market-link {
overflow-wrap: anywhere !important; word-break: keep-all !important;
-webkit-hyphens: none !important; hyphens: none !important;
}
.main-column code:not(pre code), .pm-bubble .message-text code {
display: inline !important; max-width: 100% !important;
overflow-wrap: anywhere !important; word-break: break-word !important; white-space: pre-wrap !important;
}
.main-column .tags-header { margin-bottom: 6px !important; gap: 2px !important; }
.main-column .tags-header h2, .tags-header h2 {
font-size: 17px !important; line-height: 1.2 !important; letter-spacing: 0 !important;
}
.main-column .tags-header p { font-size: 13px !important; line-height: 1.35 !important; margin: 0 !important; }
.main-column { padding: 10px 12px 16px 12px !important; }
.activity-filter-acc { margin-bottom: 4px !important; }
.empty-state {
display: flex !important; flex-direction: column !important; align-items: center !important;
justify-content: center !important; text-align: center !important;
padding: 40px 16px !important; gap: 8px !important; width: 100% !important; box-sizing: border-box !important;
}
.empty-state-icon { font-size: 44px !important; line-height: 1 !important; opacity: 0.55 !important; }
.empty-state-title { font-size: 16px !important; font-weight: 700 !important; margin: 4px 0 0 0 !important; color: #eee !important; }
.empty-state-text { color: var(--c-muted, #8c8c8c) !important; font-size: 14px !important; line-height: 1.4 !important; max-width: 40ch !important; margin: 0 !important; }
.empty-state-cta { margin-top: 10px !important; min-height: 48px !important; display: inline-flex !important; align-items: center !important; }
.activity-filter-collapsed {
display: flex !important; align-items: center !important; gap: 10px !important;
flex-wrap: wrap !important; margin-bottom: 8px !important; width: 100% !important;
}
.activity-back {
display: inline-flex !important; align-items: center !important; gap: 4px !important;
min-height: 44px !important; padding: 6px 14px 6px 10px !important; flex: 0 0 auto !important;
}
.activity-back-arrow { font-size: 22px !important; line-height: 1 !important; font-weight: bold !important; }
.activity-active-filter {
font-weight: 700 !important; color: #FFA500 !important; text-transform: uppercase !important;
font-size: 13px !important; letter-spacing: .03em !important;
}
.oasis-bottombar .bb-item { min-height: 48px !important; }
.oasis-bottombar .bb-pair .bb-item { min-height: 44px !important; padding: 4px 10px !important; }
.oasis-mobile-menus .oasis-menu-btn { min-height: 48px !important; }
.pm-bubble .pm-actions .pm-btn { min-height: 40px !important; padding: 6px 12px !important; }
.score-btn, .vote-btn, .feed-action-btn {
min-height: 44px !important; min-width: 44px !important;
display: inline-flex !important; align-items: center !important; justify-content: center !important;
}
.score-btn:active, .vote-btn:active, .feed-action-btn:active { filter: brightness(.85) !important; }
.oasis-bottombar .bb-add .bb-ico {
background: #FFA500 !important; color: #121212 !important; border-radius: 999px !important;
width: 30px !important; height: 30px !important; display: inline-flex !important;
align-items: center !important; justify-content: center !important;
font-weight: bold !important; font-size: 20px !important; line-height: 1 !important;
}
.bb-pick-grid {
display: grid !important; grid-template-columns: repeat(3, 1fr) !important;
gap: 8px !important; width: 100% !important; margin-top: 8px !important;
}
.bb-pick-form { margin: 0 !important; display: block !important; }
.bb-pick-btn {
display: flex !important; flex-direction: column !important; align-items: center !important;
justify-content: center !important; gap: 6px !important; width: 100% !important;
min-height: 76px !important; padding: 10px 6px !important; box-sizing: border-box !important;
background: #1c1c1c !important; border: 1px solid #333 !important; border-radius: 12px !important; color: #eee !important;
}
.bb-pick-btn.active { border-color: #FFA500 !important; background: #2a2412 !important; }
.bb-pick-ico { font-size: 26px !important; color: #FFA500 !important; line-height: 1 !important; }
.bb-pick-lbl { font-size: 11px !important; text-align: center !important; line-height: 1.2 !important; }

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,36 @@
(function () {
function mark() {
var p = (location.pathname || "/").replace(/\/+$/, "") || "/";
var links = document.querySelectorAll(".oasis-bottombar a.bb-item");
var best = null, bestLen = -1;
links.forEach(function (a) {
var h = (a.getAttribute("href") || "").replace(/\/+$/, "") || "/";
if (h === "/") return;
if (p === h || p.indexOf(h + "/") === 0) {
if (h.length > bestLen) { best = a; bestLen = h.length; }
}
});
if (best) best.classList.add("current");
}
function onToggle(e) {
var t = e.target;
if (!t || !t.classList || !t.classList.contains("oasis-nav-toggle")) return;
if (!t.checked) return;
var group = t.closest(".oasis-nav-group");
var scope = group && group.parentElement;
if (!scope) return;
var others = scope.querySelectorAll(
":scope > .oasis-nav-group > .oasis-nav-toggle"
);
others.forEach(function (o) { if (o !== t) o.checked = false; });
}
function init() {
mark();
document.addEventListener("change", onToggle);
}
if (document.readyState !== "loading") init();
else document.addEventListener("DOMContentLoaded", init);
})();

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

@ -12,21 +12,16 @@ if (!fs.existsSync(configFilePath)) {
"current": "blocks"
},
"modules": {
"blogsMod": "on",
"housingMod": "off",
"karvanMod": "on",
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"pollsMod": "on",
"fediverseMod": "off",
"invitesMod": "on",
"walletMod": "on",
"legacyMod": "on",
"devMod": "on",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
@ -56,7 +51,6 @@ if (!fs.existsSync(configFilePath)) {
"jobsMod": "on",
"shopsMod": "on",
"projectsMod": "on",
"industryMod": "on",
"bankingMod": "on",
"parliamentMod": "on",
"courtsMod": "on",
@ -84,6 +78,7 @@ if (!fs.existsSync(configFilePath)) {
"limit": 2000
},
"homePage": "activity",
"bottomBarPins": ["search", "inbox", "publish"],
"language": "en",
"wish": "whole",
"pmVisibility": "whole",
@ -100,10 +95,8 @@ const getConfig = () => {
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats') cfg.ux.current = 'blocks';
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks';
let pins = Array.isArray(cfg.bottomBarPins)
? cfg.bottomBarPins
: (typeof cfg.bottomBarPin === 'string'

View file

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

View file

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

View file

@ -6,14 +6,16 @@
"current": "blocks"
},
"modules": {
"blogsMod": "on",
"pollsMod": "on",
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"fediverseMod": "on",
"invitesMod": "on",
"walletMod": "off",
"legacyMod": "on",
"devMod": "off",
"industryMod": "on",
"legacyMod": "off",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
@ -30,7 +32,6 @@
"reportsMod": "on",
"opinionsMod": "on",
"padsMod": "on",
"karvanMod": "on",
"calendarsMod": "on",
"transfersMod": "off",
"feedMod": "on",
@ -53,8 +54,7 @@
"chatsMod": "on",
"torrentsMod": "off",
"graphosMod": "on",
"larpMod": "on",
"housingMod": "off"
"larpMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
@ -75,20 +75,5 @@
"language": "en",
"wish": "whole",
"pmVisibility": "whole",
"lanBroadcasting": true,
"bottomBarPins": [
"search",
"inbox",
"publish",
"activity"
],
"rtc": {
"stun": [],
"turn": {
"urls": [],
"secret": "",
"ttlSec": 3600
},
"relayOnly": false
}
"lanBroadcasting": true
}

View file

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

View file

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

View file

@ -82,7 +82,7 @@ const HIDDEN_ENVELOPE_TYPES = new Set([
'courts-key'
]);
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }) => {
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
@ -143,74 +143,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
// Builds self-contained chatThread items (title + recent messages embedded) for
// general (non-tribe) OPEN chats that have activity. Emitted as one item per chat
// with ts = latest message, so it survives the recent/all/mine filters intact.
const CHAT_THREAD_LIMIT = 6;
const buildChatThreads = async (ssbClient, idToAction, rootOf) => {
const replacedChatIds = new Set();
for (const a of idToAction.values()) {
if (a.type !== 'chat') continue;
const rep = a.content && a.content.replaces;
if (rep) replacedChatIds.add(rep);
}
const stateByRoot = new Map();
for (const a of idToAction.values()) {
if (a.type !== 'chat') continue;
const root = rootOf(a.id);
const isTip = !replacedChatIds.has(a.id);
const prev = stateByRoot.get(root);
// Resolve the chain tip by replaces (a non-replaced action wins), breaking ts
// ties correctly — the mock and real SSB can stamp equal timestamps.
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
const cc = a.content || {};
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
}
const msgsByRoot = new Map();
for (const a of idToAction.values()) {
if (a.type !== 'chatMessage') continue;
const c = a.content || {};
if (c.tribeId || c.encryptedText) continue;
const root = c.chatId;
if (!root || typeof c.text !== 'string' || !c.text) continue;
if (!msgsByRoot.has(root)) msgsByRoot.set(root, []);
msgsByRoot.get(root).push(a);
}
for (const root of msgsByRoot.keys()) {
if (stateByRoot.has(root)) continue;
const val = await getMsg(ssbClient, root);
const cc = val && val.content;
if (cc && typeof cc === 'object' && cc.type === 'chat') {
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
} else {
stateByRoot.set(root, null);
}
}
const items = [];
for (const [root, msgs] of msgsByRoot.entries()) {
const info = stateByRoot.get(root);
if (!info || info.tribeId || info.status !== 'OPEN') continue;
const asc = msgs.slice().sort((x, y) => (x.ts || 0) - (y.ts || 0));
const last = asc[asc.length - 1];
items.push({
id: `chatthread:${root}`,
type: 'chatThread',
author: last.author,
ts: last.ts || 0,
content: {
type: 'chatThread',
chatRoot: root,
title: info.title || '',
description: info.description || '',
members: info.members || 0,
messageCount: asc.length,
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
}
});
}
return items;
};
return {
invalidateCache() {
_feedCache = null;
@ -253,21 +185,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const idToAction = new Map();
const rawById = new Map();
const voteTally = buildVoteTally(results);
const pollTally = new Map();
for (const m of results) {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'pollVote' || typeof c.target !== 'string') continue;
if (!Array.isArray(c.choices)) continue;
const entry = pollTally.get(c.target) || { counts: {}, voters: new Map() };
entry.voters.set(m.value.author, { ts: m.value.timestamp || 0, choices: c.choices });
pollTally.set(c.target, entry);
}
for (const entry of pollTally.values()) {
entry.counts = {};
for (const v of entry.voters.values()) {
for (const choice of v.choices) entry.counts[choice] = (entry.counts[choice] || 0) + 1;
}
}
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
const accessibleTribeIds = await buildAccessibleTribeIds();
@ -339,7 +256,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const target = String(c.target || '');
const author = v.author;
if (!target || !author) continue;
const key = `${target}\u0000${author}`;
const key = `${target}${author}`;
const prev = aiHelpfulSeen.get(key);
const curTs = v.timestamp || 0;
if (!prev || curTs > prev.ts) aiHelpfulSeen.set(key, { target, ts: curTs, helpful: c.helpful !== false });
@ -574,7 +491,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (tombstoned.has(a.id)) continue;
if (a.type === 'tribe' && parentOf.has(a.id)) continue;
const c = a.content || {};
if (c.root && tombstoned.has(c.root) && !c.replaces) continue;
if (c.root && tombstoned.has(c.root)) continue;
if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue;
if (c.key && tombstoned.has(c.key)) continue;
@ -594,11 +511,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const actionRoot = rootOf(a.id);
const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
if (a.type === 'poll') {
const tally = pollTally.get(a.id) || pollTally.get(actionRoot);
a.pollCounts = tally ? tally.counts : {};
a.pollVoters = tally ? tally.voters.size : 0;
}
let content = voteAgg ? { ...c, ...voteAgg } : a.content;
if (a.type === 'feed') {
const base = a.content || {};
@ -686,37 +598,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
if (industryModel && deduped.some(a => a.type === 'industryBuild')) {
const builds = await industryModel.listAllBuilds().catch(() => []);
const buildStatusById = new Map();
for (const b of (builds || [])) buildStatusById.set(b.id || b.key, String(b.status || 'PROPOSED').toUpperCase());
deduped = deduped.filter(a => {
if (a.type !== 'industryBuild') return true;
const st = buildStatusById.get(a.id);
if (st === undefined || st === 'PROPOSED' || st === 'REJECTED') return false;
a.content = { ...a.content, buildStatus: st };
return true;
});
}
if (industryModel && deduped.some(a => a.type === 'industryBlueprint')) {
const bps = await industryModel.listAllBlueprints().catch(() => []);
const bpById = new Map((bps || []).map(b => [b.id || b.key, b]));
for (const a of deduped) {
if (a.type !== 'industryBlueprint') continue;
const b = bpById.get(a.id);
if (b && b.estTotal != null) a.content = { ...a.content, estTotal: b.estTotal };
}
}
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'logPublic', 'gameScore', 'pollVote', 'pollClose', 'pollOpinion', 'curriculum']);
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
deduped = deduped.concat(chatThreadItems);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']);
const isAllowedTribeActivity = (a) => {
if (tribeInternalTypes.has(a.type)) return false;
const c = a.content || {};
if (a.type === 'poll' && c.chatId) return false;
if (c.tribeId) return false;
if (a.type === 'tribe') {
const isInitial = !c.replaces;
@ -727,8 +613,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
return true;
};
const itemVisible = (a, opts = {}) => {
if (hiddenTypes.has(a.type) && !(opts.allowHidden && opts.allowHidden.has(a.type))) return false;
const itemVisible = (a) => {
if (hiddenTypes.has(a.type)) return false;
const c = a.content || {};
if (c.encryptedPayload) return false;
if (a.type === 'pad' && c.status !== 'OPEN') return false;
@ -738,16 +624,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
if (a.type === 'housing' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false;
if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
return true;
};
const isVisible = (a, opts) => {
if (!itemVisible(a, opts)) return false;
const isVisible = (a) => {
if (!itemVisible(a)) return false;
if (a.type === 'post' || a.type === 'opinion') {
const c = a.content || {};
const ref = (typeof c.root === 'string' && c.root)
@ -766,14 +650,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'top') {
const visible = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
const perAuthor = new Map();
for (const a of visible) perAuthor.set(a.author, (perAuthor.get(a.author) || 0) + 1);
out = visible
.map(a => ({ ...a, authorActions: perAuthor.get(a.author) || 0 }))
.sort((x, y) => (y.authorActions - x.authorActions) || ((y.ts || 0) - (x.ts || 0)));
}
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
@ -788,17 +664,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
});
else if (filter === 'task')
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
else if (filter === 'votes')
out = deduped.filter(a => (a.type === 'votes' || a.type === 'poll') && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'industry')
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
else if (filter === 'chat') out = deduped.filter(a => (a.type === 'chat' || a.type === 'chatThread') && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN');
else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
else out = deduped.filter(a => a.type === filter);
if (filter !== 'top') out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return out;
})();
_feedCacheInflight.set(cacheKey, promise);

View file

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

View file

@ -589,14 +589,6 @@ function scoreFromActions(actions) {
else if (t === "chat") score += 1 * decay;
else if (t === "gamescore") score += 2 * decay;
else if (t === "pixelia") score += 2 * decay;
else if (rawType === "industry") score += 8 * decay;
else if (rawType === "industryblueprint") score += 6 * decay;
else if (rawType === "industrybuild") score += 8 * decay;
else if (rawType === "industrycontribution") score += 4 * decay;
else if (rawType === "industryallocation") score += 10 * decay;
else if (rawType === "industryvote") score += 2 * decay;
else if (rawType === "industrymember") score += 2 * decay;
else if (rawType === "industryopinion") score += 1 * decay;
}
return Math.max(0, Math.round(score));
}
@ -762,35 +754,6 @@ async function getUserArchTax(userId) {
return archTaxFromDays(ageDays);
}
let _industryModel = null;
function industryModel() {
if (_industryModel) return _industryModel;
if (!services || !services.cooler) return null;
try { _industryModel = require("./industry_model")({ cooler: services.cooler }); } catch (_) { _industryModel = null; }
return _industryModel;
}
async function getIndustryBalance(userId) {
const uid = resolveUserId(userId);
const industry = industryModel();
if (!industry) return { received: 0, sent: 0, net: 0, networkTotal: 0 };
const builds = await industry.listAllBuilds().catch(() => []);
let production = 0;
let earned = 0;
for (const b of (builds || [])) {
const est = parseFloat(b && b.estTotal);
if (Number.isFinite(est) && est > 0) production += est;
const mine = parseFloat(b && b.points && b.points[uid]);
if (Number.isFinite(mine) && mine > 0) earned += mine;
}
return {
received: Number(earned.toFixed(6)),
sent: 0,
net: Number(earned.toFixed(6)),
networkTotal: Number(production.toFixed(6))
};
}
async function getUserEngagementScore(userId) {
const ssb = await openSsb();
const uid = resolveUserId(userId);
@ -1099,7 +1062,6 @@ async function getLastPublishedTimestamp(userId) {
allocations = await getUbiAllocationsFromSSB();
}
const userBalance = await safeGetBalance("user");
const industryBal = await getIndustryBalance(uid).catch(() => ({ received: 0, sent: 0, net: 0, networkTotal: 0 }));
const epochs = await epochsRepo.list();
let computed = null;
try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {}
@ -1118,10 +1080,6 @@ async function getLastPublishedTimestamp(userId) {
const hasValidWallet = !!(userAddress && isValidEcoinAddress(userAddress) && userWalletCfg.url);
const summary = {
userBalance,
industryBalance: industryBal.net,
industryNetworkTotal: industryBal.networkTotal,
industryReceived: industryBal.received,
industrySent: industryBal.sent,
epochId,
pool: poolForEpoch,
weightsSum: computed?.epoch?.weightsSum || 0,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const keylock = require('./keylock');
const SENSITIVE_FIELDS = [
'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
@ -45,7 +46,14 @@ module.exports = (configPath, namespace = 'tribes') => {
const loadKeyring = () => {
try {
keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
const raw = fs.readFileSync(keyringPath);
if (keylock.isWrapped(raw)) {
if (!keylock.getKEK()) throw new Error('keyring cifrado pero keylock bloqueado');
keyring = JSON.parse(keylock.unwrap(raw).toString('utf8'));
} else {
keyring = JSON.parse(raw.toString('utf8'));
if (keylock.getKEK()) { try { saveKeyring(); } catch (_) {} }
}
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
@ -55,8 +63,11 @@ module.exports = (configPath, namespace = 'tribes') => {
};
const saveKeyring = () => {
const json = JSON.stringify(keyring, null, 2);
const kek = keylock.getKEK();
const out = kek ? keylock.wrap(Buffer.from(json, 'utf8'), kek) : Buffer.from(json, 'utf8');
const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 });
fs.writeFileSync(tmp, out, { mode: 0o600 });
fs.renameSync(tmp, keyringPath);
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
};
@ -223,27 +234,14 @@ module.exports = (configPath, namespace = 'tribes') => {
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
};
const INVITE_ENCRYPT_ATTEMPTS = 3;
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
const plain = JSON.stringify(chain);
const aad = inviteAad(code, salt);
for (let attempt = 0; attempt < INVITE_ENCRYPT_ATTEMPTS; attempt++) {
const k = deriveInviteKey(code, salt);
const payload = encryptWithKey(plain, k.toString('hex'), aad);
if (!payload) continue;
const readBack = decryptChainFromInvite(payload, code, salt);
if (readBack && JSON.stringify(readBack.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))
=== JSON.stringify(chain.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))) {
return payload;
}
}
throw new Error('Could not produce a verifiable invite on this machine');
const k = deriveInviteKey(code, salt);
return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt));
};
const decryptChainOnce = (encryptedPayload, code, salt) => {
const decryptChainFromInvite = (encryptedPayload, code, salt) => {
const k = deriveInviteKey(code, salt);
try {
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
@ -260,15 +258,6 @@ module.exports = (configPath, namespace = 'tribes') => {
return null;
};
const decryptChainFromInvite = (encryptedPayload, code, salt, attempts = 1) => {
const tries = Math.max(1, Number(attempts) || 1);
for (let i = 0; i < tries; i++) {
const chain = decryptChainOnce(encryptedPayload, code, salt);
if (chain) return chain;
}
return null;
};
const inviteMatchesCode = (inv, code) => {
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
return inv.codeHash === hashInviteCode(code, inv.salt);

View file

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

View file

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

View file

@ -1,340 +0,0 @@
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const ROOT = path.resolve(__dirname, '..', '..');
const ALLOWED_ROOTS = ['src', 'docs', 'scripts', 'test'];
const ALLOWED_ROOT_FILES = ['README.md', 'LICENSE', 'install.sh', 'oasis.sh'];
const DENIED_SEGMENTS = new Set(['node_modules', '.git', '.ssb', 'packages', 'configs', 'embeddings', 'tiles', 'cache', 'results']);
const DENIED_NAMES = new Set(['secret', '.env', '.npmrc', 'package-lock.json']);
const DENIED_EXTENSIONS = new Set(['.gguf', '.jks', '.keystore', '.enc', '.pem', '.key', '.p12', '.crt']);
const VIEWABLE_EXTENSIONS = new Set(['.js', '.mjs', '.json', '.css', '.md', '.sh', '.html', '.txt', '.yml', '.yaml']);
const STATS_TTL = 60 * 1000;
let statsCache = null;
let statsCacheAt = 0;
const MAX_VIEW_BYTES = 2 * 1024 * 1024;
const PAGE_LINES = 500;
const SEARCH_MAX = 200;
const SEARCH_SNIPPET = 200;
const extOf = (name) => {
const i = String(name || '').lastIndexOf('.');
return i > 0 ? String(name).slice(i).toLowerCase() : '';
};
const isDeniedEntry = (name) => {
const n = String(name || '');
if (!n || n === '.' || n === '..') return true;
if (n.toLowerCase().startsWith('.git')) return true;
if (DENIED_SEGMENTS.has(n)) return true;
if (DENIED_NAMES.has(n)) return true;
if (DENIED_EXTENSIONS.has(extOf(n))) return true;
return false;
};
const PLAIN_TEXT_NAME = /^[A-Z][A-Z0-9_-]*$/;
const isViewable = (name) => (VIEWABLE_EXTENSIONS.has(extOf(name)) || PLAIN_TEXT_NAME.test(String(name || ''))) && !isDeniedEntry(name);
function realRoot() {
try { return fs.realpathSync(ROOT); } catch (_) { return ROOT; }
}
function splitPath(rel) {
const raw = String(rel || '');
for (let i = 0; i < raw.length; i++) { if (raw.charCodeAt(i) < 32) throw new Error('Invalid path'); }
return raw.replace(/^[/\\]+/, '').split(/[/\\]+/).filter(s => s && s !== '.');
}
function resolveSafe(rel) {
const segments = splitPath(rel);
if (segments.some(s => s === '..')) throw new Error('Invalid path');
if (segments.some(isDeniedEntry)) throw new Error('Path not available');
if (segments.length) {
const head = segments[0];
const isRootFile = segments.length === 1 && ALLOWED_ROOT_FILES.includes(head);
if (!ALLOWED_ROOTS.includes(head) && !isRootFile) throw new Error('Path not available');
}
const target = path.resolve(ROOT, segments.join(path.sep));
let real;
try { real = fs.realpathSync(target); } catch (_) { throw new Error('Not found'); }
const base = realRoot();
if (real !== base && !real.startsWith(base + path.sep)) throw new Error('Path not available');
return { abs: real, rel: segments.join('/'), segments };
}
function parentsOf(segments) {
const out = [];
let acc = '';
for (const s of segments) {
acc = acc ? `${acc}/${s}` : s;
out.push({ name: s, path: acc });
}
return out;
}
function rootListing() {
const dirs = [];
const files = [];
for (const name of ALLOWED_ROOTS) {
const abs = path.join(ROOT, name);
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) dirs.push({ name, path: name });
}
for (const name of ALLOWED_ROOT_FILES) {
const abs = path.join(ROOT, name);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
files.push({ name, path: name, size: fs.statSync(abs).size, ext: extOf(name), viewable: isViewable(name) });
}
return { path: '', parents: [], dirs, files, hidden: 0 };
}
function walkFiles(relDir, onFile) {
let entries;
try { entries = fs.readdirSync(path.resolve(ROOT, relDir), { withFileTypes: true }); } catch (_) { return true; }
for (const e of entries) {
if (isDeniedEntry(e.name)) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) { if (walkFiles(rel, onFile) === false) return false; continue; }
if (!e.isFile()) continue;
if (onFile(rel, e.name) === false) return false;
}
return true;
}
const MODULE_ALIASES = {
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
docs: { paths: ['/documents'] },
blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
};
const GROUPS = {
translations: ['src/client/assets/translations'],
styles: ['src/client/assets/styles', 'src/client/assets/themes'],
tests: ['test']
};
module.exports = {
ROOT,
PAGE_LINES,
GROUPS,
listGroup(key) {
const roots = GROUPS[String(key || '')];
if (!roots) throw new Error('Not found');
const dirs = [];
const files = [];
for (const rel of roots) {
let listing;
try { listing = module.exports.listTree(rel); } catch (_) { continue; }
dirs.push(...listing.dirs);
files.push(...listing.files);
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
return { group: String(key), path: '', parents: [], dirs, files, hidden: 0 };
},
listTree(relPath) {
const segments = splitPath(relPath);
if (!segments.length) return rootListing();
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isDirectory()) throw new Error('Not a directory');
const entries = fs.readdirSync(target.abs, { withFileTypes: true });
const dirs = [];
const files = [];
let hidden = 0;
for (const e of entries) {
if (isDeniedEntry(e.name) || e.isSymbolicLink()) { hidden++; continue; }
const rel = `${target.rel}/${e.name}`;
if (e.isDirectory()) { dirs.push({ name: e.name, path: rel }); continue; }
if (!e.isFile()) { hidden++; continue; }
let size = 0;
try { size = fs.statSync(path.join(target.abs, e.name)).size; } catch (_) { size = 0; }
files.push({ name: e.name, path: rel, size, ext: extOf(e.name), viewable: isViewable(e.name) });
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
return { path: target.rel, parents: parentsOf(target.segments), dirs, files, hidden };
},
readFile(relPath, opts = {}) {
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isFile()) throw new Error('Not a file');
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
const all = fs.readFileSync(target.abs, 'utf8').split('\n');
const totalLines = all.length;
const requested = parseInt(opts.from, 10);
const from = Math.min(Math.max(Number.isFinite(requested) ? requested : 1, 1), Math.max(1, totalLines));
const to = Math.min(from + PAGE_LINES - 1, totalLines);
const lines = [];
for (let n = from; n <= to; n++) lines.push({ n, text: all[n - 1] });
return {
path: target.rel,
parents: parentsOf(target.segments),
name: path.basename(target.abs),
size: stat.size,
totalLines,
from,
to,
pageLines: PAGE_LINES,
hasPrev: from > 1,
hasNext: to < totalLines,
lines
};
},
rawFile(relPath) {
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isFile()) throw new Error('Not a file');
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
return { name: path.basename(target.abs), content: fs.readFileSync(target.abs, 'utf8') };
},
searchCode(query, opts = {}) {
const q = String(query || '').trim();
if (q.length < 2) return { query: q, results: [], truncated: false, scanned: 0 };
const wanted = String(opts.ext || '').trim().toLowerCase();
const max = Math.min(Math.max(parseInt(opts.max, 10) || SEARCH_MAX, 1), SEARCH_MAX);
const needle = q.toLowerCase();
const results = [];
let scanned = 0;
let truncated = false;
for (const root of ALLOWED_ROOTS) {
if (truncated) break;
walkFiles(root, (rel, name) => {
if (!isViewable(name)) return true;
if (wanted && extOf(name) !== wanted) return true;
let stat;
try { stat = fs.statSync(path.resolve(ROOT, rel)); } catch (_) { return true; }
if (stat.size > MAX_VIEW_BYTES) return true;
scanned++;
let content;
try { content = fs.readFileSync(path.resolve(ROOT, rel), 'utf8'); } catch (_) { return true; }
if (content.toLowerCase().indexOf(needle) < 0) return true;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().indexOf(needle) < 0) continue;
results.push({ path: rel, line: i + 1, text: lines[i].trim().slice(0, SEARCH_SNIPPET) });
if (results.length >= max) { truncated = true; return false; }
}
return true;
});
}
return { query: q, results, truncated, scanned };
},
moduleMap() {
const cfg = getConfig();
const mods = Object.keys((cfg && cfg.modules) || {}).map(k => k.replace(/Mod$/, '')).sort();
let backend = '';
try { backend = fs.readFileSync(path.join(ROOT, 'src', 'backend', 'backend.js'), 'utf8'); } catch (_) { backend = ''; }
const modelFiles = fs.existsSync(path.join(ROOT, 'src', 'models')) ? fs.readdirSync(path.join(ROOT, 'src', 'models')) : [];
const viewFiles = fs.existsSync(path.join(ROOT, 'src', 'views')) ? fs.readdirSync(path.join(ROOT, 'src', 'views')) : [];
const stem = (s) => String(s || '').toLowerCase().replace(/s$/, '');
const pick = (files, name, suffixes) => {
for (const suf of suffixes) {
const exact = files.find(f => f.toLowerCase() === `${name.toLowerCase()}${suf}`);
if (exact) return exact;
}
const singular = name.replace(/s$/, '');
for (const suf of suffixes) {
const short = files.find(f => f.toLowerCase() === `${singular.toLowerCase()}${suf}`);
if (short) return short;
}
const target = stem(name);
return files.find(f => {
const suf = suffixes.find(x => f.toLowerCase().endsWith(x));
if (!suf) return false;
const base = stem(f.slice(0, f.length - suf.length));
return base.startsWith(target) || target.startsWith(base);
}) || null;
};
const testsIndex = new Map();
const modsDir = path.join(ROOT, 'test', 'mods');
const indexTests = (dir, prefix) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const rel = `${prefix}/${e.name}`;
if (!testsIndex.has(e.name)) testsIndex.set(e.name, rel);
indexTests(path.join(dir, e.name), rel);
}
};
indexTests(modsDir, 'test/mods');
return mods.map(name => {
const alias = MODULE_ALIASES[name] || {};
const routePaths = Array.isArray(alias.paths) && alias.paths.length ? alias.paths : [`/${name}`];
let routes = 0;
for (const routePath of routePaths) {
const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\.(get|post)\\(\\s*["'\`]${escaped}(?=["'/?])`, 'g');
routes += backend ? (backend.match(re) || []).length : 0;
}
const model = alias.model !== undefined ? alias.model : (pick(modelFiles, name, ['_model.js', '_models.js']) ? `src/models/${pick(modelFiles, name, ['_model.js', '_models.js'])}` : null);
const view = alias.view !== undefined ? alias.view : (pick(viewFiles, name, ['_view.js', '_views.js']) ? `src/views/${pick(viewFiles, name, ['_view.js', '_views.js'])}` : null);
return {
name,
enabled: (cfg.modules || {})[`${name}Mod`] !== 'off',
model,
view,
routes,
tests: testsIndex.get(name) || testsIndex.get(name.replace(/s$/, '')) || (() => {
const target = stem(name);
for (const [dir, rel] of testsIndex) {
const base = stem(dir);
if (base.startsWith(target) || target.startsWith(base)) return rel;
}
return null;
})()
};
});
},
projectStats() {
const now = Date.now();
if (statsCache && now - statsCacheAt < STATS_TTL) return statsCache;
let files = 0;
let lines = 0;
let bytes = 0;
for (const root of ALLOWED_ROOTS) {
walkFiles(root, (rel, name) => {
if (!isViewable(name)) return true;
try {
const abs = path.resolve(ROOT, rel);
const stat = fs.statSync(abs);
if (stat.size > MAX_VIEW_BYTES) return true;
files++;
bytes += stat.size;
lines += fs.readFileSync(abs, 'utf8').split('\n').length;
} catch (_) {}
return true;
});
}
const cfg = getConfig();
const modules = Object.keys((cfg && cfg.modules) || {}).length;
const translations = path.join(ROOT, 'src', 'client', 'assets', 'translations');
const languages = fs.existsSync(translations) ? fs.readdirSync(translations).filter(f => /^oasis_[a-z]{2}\.js$/.test(f)).length : 0;
const testsDir = path.join(ROOT, 'test', 'mods');
const testMods = fs.existsSync(testsDir) ? fs.readdirSync(testsDir).length : 0;
let version = '';
try { version = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'server', 'package.json'), 'utf8')).version || ''); } catch (_) { version = ''; }
statsCache = { files, lines, bytes, modules, languages, testMods, version, node: process.version };
statsCacheAt = now;
return statsCache;
}
};

View file

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

View file

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

View file

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

View file

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

View file

@ -1,200 +0,0 @@
const pull = require('../server/node_modules/pull-stream');
const fs = require('fs');
const crypto = require('crypto');
const { Readable } = require('stream');
const fsc = require('../backend/fileshare_crypto');
const MAX_CHUNK_SIZE = 5 * 1024 * 1024;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const addBlob = (client, buffer) =>
new Promise((resolve, reject) => {
pull(pull.values([buffer]), client.blobs.add((err, ref) => err ? reject(err) : resolve(ref)));
});
const getBlob = (client, ref) =>
new Promise((resolve, reject) => {
pull(client.blobs.get(ref), pull.collect((err, chunks) => err ? reject(err) : resolve(Buffer.concat(chunks))));
});
const hasBlob = (client, ref) =>
new Promise((resolve) => {
if (!client.blobs || typeof client.blobs.has !== 'function') return resolve(false);
client.blobs.has(ref, (err, has) => resolve(!err && !!has));
});
const wantBlob = (client, ref, timeoutMs) =>
new Promise((resolve) => {
if (typeof client.blobs.want !== 'function') return resolve(true);
let done = false;
const finish = (ok) => { if (done) return; done = true; if (timer) clearTimeout(timer); resolve(ok); };
const timer = timeoutMs ? setTimeout(() => finish(false), timeoutMs) : null;
client.blobs.want(ref, (err) => finish(!err));
});
const rmBlob = (client, ref) =>
new Promise((resolve) => {
if (typeof client.blobs.rm !== 'function') return resolve(false);
client.blobs.rm(ref, (err) => resolve(!err));
});
const createShareFromBuffer = async ({ buffer, filename, mime, chunkSize } = {}) => {
if (!Buffer.isBuffer(buffer)) throw new Error('buffer required');
const client = await openSsb();
const key = fsc.generateFileKey();
const size = fsc.DEFAULT_CHUNK_SIZE;
const useChunk = Math.min(Number(chunkSize) || fsc.DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE);
const chunkHashes = [];
for (const part of fsc.splitBuffer(buffer, useChunk)) {
const payload = fsc.encryptChunk(part, key);
chunkHashes.push(await addBlob(client, payload));
}
const manifest = fsc.buildManifest({
filename, mime, size: buffer.length, chunkSize: useChunk,
chunkHashes, plainSha256: fsc.sha256Hex(buffer)
});
const manifestBlobId = await addBlob(client, fsc.encryptManifest(manifest, key));
return {
type: 'fileShare',
v: 1,
key: fsc.keyToHex(key),
manifestBlobId,
filename: manifest.filename,
mime: manifest.mime,
size: manifest.size,
chunkCount: chunkHashes.length
};
};
const createShareFromFile = async ({ filepath, filename, mime, chunkSize } = {}) => {
if (!filepath) throw new Error('filepath required');
const client = await openSsb();
const key = fsc.generateFileKey();
const useChunk = Math.min(Number(chunkSize) || fsc.DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE);
const chunkHashes = [];
const hashAll = crypto.createHash('sha256');
let total = 0;
let carry = Buffer.alloc(0);
const flush = async (part) => {
hashAll.update(part);
total += part.length;
chunkHashes.push(await addBlob(client, fsc.encryptChunk(part, key)));
};
for await (const data of fs.createReadStream(filepath, { highWaterMark: useChunk })) {
carry = carry.length ? Buffer.concat([carry, data]) : data;
while (carry.length >= useChunk) {
await flush(carry.subarray(0, useChunk));
carry = carry.subarray(useChunk);
}
}
if (carry.length || !chunkHashes.length) await flush(carry);
const manifest = fsc.buildManifest({
filename, mime, size: total, chunkSize: useChunk,
chunkHashes, plainSha256: hashAll.digest('hex')
});
const manifestBlobId = await addBlob(client, fsc.encryptManifest(manifest, key));
return {
type: 'fileShare', v: 1,
key: fsc.keyToHex(key), manifestBlobId,
filename: manifest.filename, mime: manifest.mime, size: manifest.size,
chunkCount: chunkHashes.length
};
};
const openManifest = async (pointer) => {
const client = await openSsb();
const key = fsc.keyFromHex(pointer.key);
await wantBlob(client, pointer.manifestBlobId);
const payload = await getBlob(client, pointer.manifestBlobId);
return fsc.decryptManifest(payload, key);
};
const CHUNK_WANT_TIMEOUT = 30000;
const PREFLIGHT_TIMEOUT = 12000;
const readShareStream = (pointer) => {
const key = fsc.keyFromHex(pointer.key);
async function* gen() {
const client = await openSsb();
if (!(await wantBlob(client, pointer.manifestBlobId, CHUNK_WANT_TIMEOUT))) throw new Error('fileshare blob unavailable');
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
for (const ref of manifest.chunks) {
if (!(await wantBlob(client, ref, CHUNK_WANT_TIMEOUT))) throw new Error('fileshare blob unavailable');
yield fsc.decryptChunk(await getBlob(client, ref), key);
}
}
return Readable.from(gen());
};
const ensureAvailable = async (pointer, timeoutMs = PREFLIGHT_TIMEOUT) => {
try {
const client = await openSsb();
const key = fsc.keyFromHex(pointer.key);
if (!(await wantBlob(client, pointer.manifestBlobId, timeoutMs))) return false;
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
if (manifest.chunks.length && !(await wantBlob(client, manifest.chunks[0], timeoutMs))) return false;
return true;
} catch (_) {
return false;
}
};
const reassembleToBuffer = async (pointer) => {
const parts = [];
for await (const chunk of readShareStream(pointer)) parts.push(chunk);
return Buffer.concat(parts);
};
const isAvailable = async (pointer) => {
const client = await openSsb();
if (!(await hasBlob(client, pointer.manifestBlobId))) return false;
try {
const key = fsc.keyFromHex(pointer.key);
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
for (const ref of manifest.chunks) if (!(await hasBlob(client, ref))) return false;
return true;
} catch (_) {
return false;
}
};
const removeLocalBlobs = async (pointer) => {
const client = await openSsb();
let removed = 0;
try {
const key = fsc.keyFromHex(pointer.key);
if (await hasBlob(client, pointer.manifestBlobId)) {
const manifest = fsc.decryptManifest(await getBlob(client, pointer.manifestBlobId), key);
for (const ref of manifest.chunks) if (await rmBlob(client, ref)) removed++;
}
} catch (_) {}
if (await rmBlob(client, pointer.manifestBlobId)) removed++;
return removed;
};
const pruneExpired = async (sentFileShares, ttlMs, now) => {
const cutoff = (Number.isFinite(now) ? now : Date.now()) - (Number(ttlMs) || 0);
let pruned = 0;
for (const item of (Array.isArray(sentFileShares) ? sentFileShares : [])) {
if (!item || !item.pointer) continue;
const sentAt = new Date(item.sentAt || 0).getTime();
if (!(sentAt <= cutoff)) continue;
const n = await removeLocalBlobs(item.pointer);
if (n > 0) pruned++;
}
return pruned;
};
return { createShareFromBuffer, createShareFromFile, openManifest, readShareStream, reassembleToBuffer, isAvailable, ensureAvailable, removeLocalBlobs, pruneExpired };
};

View file

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

View file

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

View file

@ -1,993 +0,0 @@
const pull = require("../server/node_modules/pull-stream")
const { getConfig } = require("../configs/config-manager.js")
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => {
if (!ssb) ssb = await cooler.open()
return ssb
}
const TYPE = "industry"
const SECTORS = ["software", "hardware", "agriculture", "textile", "energy", "food", "construction", "media", "services", "other"]
const POLICIES = ["open", "vote", "invite"]
const clampPercent = (n) => {
const x = parseInt(n, 10)
if (!Number.isFinite(x)) return 0
return Math.max(0, Math.min(100, x))
}
const clampMajority = (n) => {
const x = parseFloat(n)
if (!Number.isFinite(x)) return 0.5
return Math.max(0.5, Math.min(1, x))
}
const clampQuorum = (n) => {
const x = parseInt(n, 10)
if (!Number.isFinite(x) || x < 1) return 1
return Math.min(100000, x)
}
const safeSector = (s) => {
const v = String(s || "").toLowerCase().trim()
return SECTORS.includes(v) ? v : "other"
}
const safePolicy = (p) => {
const v = String(p || "").toLowerCase().trim()
return POLICIES.includes(v) ? v : "vote"
}
const nonNegNum = (v) => {
const x = parseFloat(v)
return Number.isFinite(x) && x >= 0 ? x : 0
}
async function getAllMsgs(ssbClient) {
return new Promise((r, j) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((e, m) => (e ? j(e) : r(m)))
)
})
}
function chainMaps(nodeMap) {
const next = new Map(), prev = new Map(), strictNext = new Map()
for (const [key, n] of nodeMap) {
const t = n.c.replaces
if (!t) continue
next.set(t, key); prev.set(key, t)
const orig = nodeMap.get(t)
if (orig && orig.author === n.author) strictNext.set(t, key)
}
return { next, prev, strictNext }
}
function buildIndex(messages) {
const tomb = buildValidatedTombstoneSet(messages)
const nodes = new Map()
const blueprints = new Map()
const builds = new Map()
const collab = { member: [], invite: [], apply: [], vote: [], opinion: [], buildStatus: [], buildDates: [], contribution: [], allocation: [] }
for (const m of messages) {
const c = m && m.value && m.value.content
if (!c) continue
if (c.type === "tombstone") continue
const ts = (m.value && m.value.timestamp) || 0
const author = m.value && m.value.author
if (c.type === "industryMember") { collab.member.push({ target: c.target, author, action: c.action === "leave" ? "leave" : "join", ts }); continue }
if (c.type === "industryInvite") { collab.invite.push({ target: c.target, author, invitee: c.invitee, ts }); continue }
if (c.type === "industryApply") { collab.apply.push({ target: c.target, author, ts }); continue }
if (c.type === "industryVote") { collab.vote.push({ target: c.target, author, subject: String(c.subject || ""), ref: String(c.ref || ""), choice: c.choice === "no" ? "no" : "yes", ts }); continue }
if (c.type === "industryOpinion") { collab.opinion.push({ target: c.target, author, category: c.category, ts }); continue }
if (c.type === "industryBlueprint") { blueprints.set(m.key, { key: m.key, ts, c, author }); continue }
if (c.type === "industryBuild") { builds.set(m.key, { key: m.key, ts, c, author }); continue }
if (c.type === "industryBuildStatus") { collab.buildStatus.push({ target: c.target, author, status: String(c.status || "").toUpperCase(), ts }); continue }
if (c.type === "industryBuildDates" || c.type === "industryBuildUpdate") { collab.buildDates.push({ target: c.target, author, title: String(c.title || ""), notes: String(c.notes || ""), startDate: String(c.startDate || ""), endDate: String(c.endDate || ""), ts }); continue }
if (c.type === "industryContribution") { collab.contribution.push({ target: c.target, author, kind: c.kind, hours: nonNegNum(c.hours), item: String(c.item || ""), value: nonNegNum(c.value), eco: nonNegNum(c.eco), transferId: c.transferId || null, note: String(c.note || ""), ts }); continue }
if (c.type === "industryAllocation") { collab.allocation.push({ target: c.target, author, shares: (c.shares && typeof c.shares === "object") ? c.shares : {}, amounts: (c.amounts && typeof c.amounts === "object") ? c.amounts : {}, pot: nonNegNum(c.pot), outputValue: nonNegNum(c.outputValue), disposition: c.disposition || null, ts }); continue }
if (c.type !== TYPE) continue
nodes.set(m.key, { key: m.key, ts, c, author })
}
const fc = chainMaps(nodes)
const bp = chainMaps(blueprints)
return { tomb, nodes, blueprints, builds, collab, naiveNext: fc.next, naivePrev: fc.prev, strictNext: fc.strictNext, bpNext: bp.next, bpPrev: bp.prev, bpStrictNext: bp.strictNext }
}
const rootOfIdx = (idx, key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
const rootOfBlueprint = (idx, key) => { let x = key, g = 0; while (idx.bpPrev.has(x) && idx.blueprints.has(idx.bpPrev.get(x)) && g++ < 100000) x = idx.bpPrev.get(x); return x }
function passesThreshold(yesCount, memberCount, quorum, majority) {
const size = Math.max(1, memberCount)
const need = Math.max(Math.min(clampQuorum(quorum), size), Math.ceil(size * clampMajority(majority)))
return yesCount >= need
}
function actionVoteState(idx, groupRoot, members, subject, ref) {
const pref = new Map()
for (const v of idx.collab.vote.filter(x => x.target === groupRoot && x.subject === subject && x.ref === ref).sort((a, b) => a.ts - b.ts)) {
if (!members.has(v.author)) continue
pref.set(v.author, v.choice)
}
const voters = [...pref.entries()].filter(([, c]) => c === "yes").map(([m]) => m)
return { yes: voters.length, voters }
}
function voteNeedOf(g) {
const size = Math.max(1, new Set(g.facility.members).size)
return Math.max(Math.min(clampQuorum(g.facility.quorum), size), Math.ceil(size * clampMajority(g.facility.majority)))
}
function actionApproved(idx, g, subject, ref) {
const members = new Set(g.facility.members)
if (members.size <= 1) return true
const st = actionVoteState(idx, g.root, members, subject, ref)
return passesThreshold(st.yes, members.size, g.facility.quorum, g.facility.majority)
}
function blueprintGovernance(idx, g, bpRoot) {
const members = new Set(g.facility.members)
const up = actionVoteState(idx, g.root, members, "bpUpdate", bpRoot)
const del = actionVoteState(idx, g.root, members, "bpDelete", bpRoot)
return {
root: bpRoot,
facilityMembers: members.size,
voteNeed: voteNeedOf(g),
updateYes: up.yes,
updateVoters: up.voters,
deleteYes: del.yes,
deleteVoters: del.voters,
updateApproved: actionApproved(idx, g, "bpUpdate", bpRoot),
deleteApproved: actionApproved(idx, g, "bpDelete", bpRoot)
}
}
function buildGovernance(idx, g, buildId) {
const members = new Set(g.facility.members)
const up = actionVoteState(idx, g.root, members, "buildUpdate", buildId)
const del = actionVoteState(idx, g.root, members, "buildDelete", buildId)
return {
facilityMembers: members.size,
voteNeed: voteNeedOf(g),
updateYes: up.yes,
updateVoters: up.voters,
deleteYes: del.yes,
deleteVoters: del.voters,
updateApproved: actionApproved(idx, g, "buildUpdate", buildId),
deleteApproved: actionApproved(idx, g, "buildDelete", buildId)
}
}
function resolveGroup(idx, root) {
const { nodes, collab, naiveNext, strictNext } = idx
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
const rootNode = nodes.get(root)
if (!rootNode) return null
const steward = rootNode.author
let tip = followStrict(root)
const tn = nodes.get(tip)
if (!tn || tn.author !== steward) tip = root
const best = nodes.get(tip)
if (!best) return null
const groupKeys = []
{ let x = root, g = 0; groupKeys.push(x); while (naiveNext.has(x) && g++ < 100000) { x = naiveNext.get(x); groupKeys.push(x) } }
const facility = { ...best.c, id: tip }
const policy = safePolicy(facility.membershipPolicy)
const quorum = clampQuorum(facility.quorum)
const majority = clampMajority(facility.majority)
const KIND_ORDER = { invite: 0, apply: 1, member: 2, vote: 3 }
const events = []
for (const e of collab.member.filter(x => x.target === root)) events.push({ kind: "member", ...e })
for (const e of collab.invite.filter(x => x.target === root)) events.push({ kind: "invite", ...e })
for (const e of collab.apply.filter(x => x.target === root)) events.push({ kind: "apply", ...e })
for (const e of collab.vote.filter(x => x.target === root)) events.push({ kind: "vote", ...e })
events.sort((a, b) => (a.ts - b.ts) || (KIND_ORDER[a.kind] - KIND_ORDER[b.kind]))
const members = new Set([steward])
const invites = new Set()
const applicants = new Set()
const admitVotes = new Map()
const dissolveVoters = new Set()
let dissolved = false
const admitKey = (uid) => "admit:" + uid
for (const ev of events) {
if (ev.kind === "invite") {
if (members.has(ev.author) && ev.invitee) invites.add(ev.invitee)
continue
}
if (ev.kind === "apply") {
if (!members.has(ev.author)) applicants.add(ev.author)
continue
}
if (ev.kind === "member") {
if (ev.action === "leave") { if (ev.author !== steward) members.delete(ev.author); continue }
if (members.has(ev.author)) continue
if (policy === "open") { members.add(ev.author); applicants.delete(ev.author) }
else if (policy === "invite" && invites.has(ev.author)) { members.add(ev.author); applicants.delete(ev.author) }
continue
}
if (ev.kind === "vote") {
if (!members.has(ev.author)) continue
if (ev.subject === "admit" && ev.ref) {
const k = admitKey(ev.ref)
if (!admitVotes.has(k)) admitVotes.set(k, new Map())
admitVotes.get(k).set(ev.author, ev.choice)
const yes = [...admitVotes.get(k).values()].filter(v => v === "yes").length
if (!members.has(ev.ref) && passesThreshold(yes, members.size, quorum, majority)) {
members.add(ev.ref); applicants.delete(ev.ref)
}
} else if (ev.subject === "dissolve") {
if (ev.choice === "yes") dissolveVoters.add(ev.author); else dissolveVoters.delete(ev.author)
if (passesThreshold(dissolveVoters.size, members.size, quorum, majority)) dissolved = true
}
continue
}
}
const pendingApplicants = [...applicants].map(uid => {
const k = admitKey(uid)
const vm = admitVotes.get(k) || new Map()
let yes = 0, no = 0
for (const v of vm.values()) { if (v === "yes") yes++; else no++ }
return { id: uid, yes, no, voters: [...vm.keys()] }
})
const opinions = {}
const opinionSet = new Set()
for (const k of groupKeys) {
const n = nodes.get(k); if (!n || n.author !== steward) continue
const c = n.c
if (c.opinions && typeof c.opinions === "object") { for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0) }
for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) opinionSet.add(v)
}
for (const op of collab.opinion.filter(x => x.target === root)) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } }
const pausePref = new Map()
for (const ev of events) {
if (ev.kind !== "vote" || ev.subject !== "pause") continue
if (!members.has(ev.author)) continue
pausePref.set(ev.author, ev.choice)
}
const pauseVoters = [...pausePref.entries()].filter(([, c]) => c === "yes").map(([m]) => m)
const paused = passesThreshold(pauseVoters.length, members.size, quorum, majority)
facility.steward = steward
facility.root = root
facility.membershipPolicy = policy
facility.quorum = quorum
facility.majority = majority
facility.members = [...members]
facility.memberCount = members.size
facility.pendingApplicants = pendingApplicants
facility.invites = [...invites]
facility.opinions = opinions
facility.opinions_inhabitants = [...opinionSet]
facility.pauseVoters = pauseVoters
facility.pauseYes = pauseVoters.length
facility.status = dissolved ? "DISSOLVED" : (paused ? "PAUSED" : "ACTIVE")
return { tip, root, best, facility, tombstoned: idx.tomb.has(tip) }
}
const BUILD_STATUSES = ["PROPOSED", "REJECTED", "APPROVED", "STOCKING", "IN_PRODUCTION", "COMPLETED", "FAILED"]
const BUILD_TRANSITIONS = { APPROVED: ["STOCKING", "FAILED"], STOCKING: ["IN_PRODUCTION", "FAILED"], IN_PRODUCTION: ["COMPLETED", "FAILED"], FAILED: ["APPROVED", "STOCKING", "IN_PRODUCTION"], COMPLETED: ["IN_PRODUCTION"] }
const CONTRIBUTABLE = new Set(["APPROVED", "STOCKING", "IN_PRODUCTION"])
const normalizeMaterials = (raw, text) => {
if (Array.isArray(raw)) return raw.map(m => ({ item: String((m && m.item) || "").trim(), qty: nonNegNum(m && m.qty), price: nonNegNum(m && m.price) })).filter(m => m.item)
const t = String(text || "")
if (!t.trim()) return []
return t.split("\n").map(line => { const parts = line.split(/[:,]/).map(x => String(x || "").trim()); return { item: parts[0] || "", qty: nonNegNum(parts[1]), price: nonNegNum(parts[2]) } }).filter(m => m.item)
}
const safeMaterials = (v) => (Array.isArray(v) ? v : [])
const estimateBlueprint = (bp, laborRate) => {
const materialsCost = safeMaterials(bp.materials).reduce((s, m) => s + nonNegNum(m.qty) * nonNegNum(m.price), 0)
const laborCost = nonNegNum(bp.laborHours) * nonNegNum(laborRate)
return {
estMaterialsCost: Number(materialsCost.toFixed(6)),
estLaborCost: Number(laborCost.toFixed(6)),
estTotal: Number((materialsCost + laborCost).toFixed(6))
}
}
const estimateForBlueprintRef = (idx, bpRef, laborRate) => {
if (!bpRef) return null
const b = resolveBlueprint(idx, rootOfBlueprint(idx, bpRef))
if (!b || b.tombstoned) return null
return { blueprintName: b.blueprint.name, blueprintImage: b.blueprint.image || null, blueprintKind: b.blueprint.outKind || null, blueprintLicense: b.blueprint.license || "copyleft", ...estimateBlueprint(b.blueprint, laborRate) }
}
const normalizeSkills = (raw) => {
if (Array.isArray(raw)) return raw.map(x => String(x || "").trim()).filter(Boolean)
return String(raw || "").split(",").map(x => x.trim()).filter(Boolean)
}
function computeShares(contributions, laborRate) {
const rate = nonNegNum(laborRate)
const points = new Map()
for (const c of contributions) {
let p = 0
if (c.kind === "labor") p = (c.hours || 0) * rate
else if (c.kind === "material") p = (c.value || 0)
else if (c.kind === "eco") p = (c.eco || 0)
if (p <= 0) continue
points.set(c.author, (points.get(c.author) || 0) + p)
}
let total = 0
for (const v of points.values()) total += v
const shares = new Map()
if (total > 0) for (const [k, v] of points) shares.set(k, v / total)
return { points, total, shares }
}
function resolveBlueprint(idx, root) {
const { blueprints, bpStrictNext } = idx
const followStrict = (key) => { let x = key, g = 0; while (bpStrictNext.has(x) && g++ < 100000) x = bpStrictNext.get(x); return x }
const rootNode = blueprints.get(root)
if (!rootNode) return null
const author = rootNode.author
let tip = followStrict(root)
const tn = blueprints.get(tip)
if (!tn || tn.author !== author) tip = root
const best = blueprints.get(tip)
if (!best) return null
return { tip, root, blueprint: { ...best.c, id: tip, author }, tombstoned: idx.tomb.has(tip) }
}
function resolveBuild(idx, root, facility) {
const node = idx.builds.get(root)
if (!node) return null
const build = { ...node.c, id: root, proposer: node.author }
const steward = facility.steward
const members = new Set(facility.members)
const approveVotes = new Map()
for (const v of idx.collab.vote.filter(x => x.subject === "build" && x.ref === root).sort((a, b) => a.ts - b.ts)) {
if (!members.has(v.author)) continue
approveVotes.set(v.author, v.choice)
}
const approveYes = [...approveVotes.values()].filter(x => x === "yes").length
const approveNo = [...approveVotes.values()].filter(x => x === "no").length
let status = "PROPOSED"
if (passesThreshold(approveYes, members.size, facility.quorum, facility.majority)) status = "APPROVED"
else if (passesThreshold(approveNo, members.size, facility.quorum, facility.majority)) status = "REJECTED"
for (const ev of idx.collab.buildStatus.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) {
const allowed = BUILD_TRANSITIONS[status] || []
if (!allowed.includes(ev.status)) continue
const isSteward = ev.author === steward
const isProposer = ev.author === build.proposer
const stewardOnly = ev.status === "COMPLETED" || ev.status === "FAILED"
if (stewardOnly && !isSteward) continue
if (!isSteward && !isProposer) continue
status = ev.status
}
for (const ev of idx.collab.buildDates.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) {
if (ev.author !== steward && ev.author !== build.proposer) continue
if (ev.title) build.title = ev.title
if (ev.notes) build.notes = ev.notes
if (ev.startDate) build.startDate = ev.startDate
if (ev.endDate) build.endDate = ev.endDate
}
build.status = status
const contributions = idx.collab.contribution.filter(x => x.target === root && members.has(x.author)).sort((a, b) => a.ts - b.ts)
build.contributions = contributions.map(c => ({ author: c.author, kind: c.kind, hours: c.hours, item: c.item, value: c.value, eco: c.eco, note: c.note, at: new Date(c.ts || 0).toISOString() }))
const cs = computeShares(contributions, facility.laborRate)
build.points = Object.fromEntries(cs.points)
build.totalPoints = cs.total
build.shares = Object.fromEntries(cs.shares)
build.treasury = contributions.reduce((s, c) => s + (c.eco || 0), 0)
const allocs = idx.collab.allocation.filter(x => x.target === root && x.author === steward).sort((a, b) => a.ts - b.ts)
const latest = allocs[allocs.length - 1] || null
build.distributed = !!latest
build.allocation = latest ? { shares: latest.shares, amounts: latest.amounts, pot: latest.pot, outputValue: latest.outputValue, disposition: latest.disposition, at: new Date(latest.ts || 0).toISOString() } : null
build.tombstoned = idx.tomb.has(root)
return build
}
async function getById(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
return g.facility
}
async function resolveTipId(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
return g.tip
}
const CONTENT_COLLAB_FIELDS = ["id", "root", "steward", "members", "memberCount", "pendingApplicants", "invites", "opinions", "opinions_inhabitants", "pauseVoters", "pauseYes"]
function stripCollab(content) {
const out = { ...content }
for (const f of CONTENT_COLLAB_FIELDS) delete out[f]
out.opinions = {}
out.opinions_inhabitants = []
return out
}
async function publishReplace(ssbClient, currentId, content) {
const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id }
const updated = { ...stripCollab(content), type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() }
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => (e ? rej(e) : res())))
return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => (e ? rej(e) : res(m))))
}
const publish = (ssbClient, content) => new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
return {
type: TYPE,
SECTORS,
POLICIES,
async createFacility(data) {
const ssbClient = await openSsb()
const content = {
type: TYPE,
name: String(data.name || "").trim(),
sector: safeSector(data.sector),
description: String(data.description || "").trim(),
image: data.image || null,
mapUrl: String(data.mapUrl || "").trim(),
membershipPolicy: safePolicy(data.membershipPolicy),
quorum: clampQuorum(data.quorum),
majority: clampMajority(data.majority),
laborRate: nonNegNum(data.laborRate),
license: String(data.license || "copyleft").trim() || "copyleft",
tags: normalizeSkills(data.tags),
status: "ACTIVE",
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: null,
opinions: {},
opinions_inhabitants: []
}
if (!content.name) throw new Error("Facility name is required")
return publish(ssbClient, content)
},
async updateFacility(id, patch) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.steward !== ssbClient.id) throw new Error("Unauthorized")
const current = g.facility
const nextStatus = patch.status === undefined ? current.status : String(patch.status || "").toUpperCase()
if (nextStatus === "DISSOLVED" && current.status !== "DISSOLVED") throw new Error("Dissolution requires a governance vote")
const updated = {
...current,
...patch,
name: patch.name === undefined ? current.name : String(patch.name || "").trim(),
sector: patch.sector === undefined ? current.sector : safeSector(patch.sector),
description: patch.description === undefined ? current.description : String(patch.description || "").trim(),
image: patch.image === undefined ? current.image : (patch.image || null),
mapUrl: patch.mapUrl === undefined ? current.mapUrl : String(patch.mapUrl || "").trim(),
membershipPolicy: patch.membershipPolicy === undefined ? current.membershipPolicy : safePolicy(patch.membershipPolicy),
quorum: patch.quorum === undefined ? current.quorum : clampQuorum(patch.quorum),
majority: patch.majority === undefined ? current.majority : clampMajority(patch.majority),
laborRate: patch.laborRate === undefined ? current.laborRate : nonNegNum(patch.laborRate),
tags: patch.tags === undefined ? current.tags : normalizeSkills(patch.tags),
status: nextStatus
}
return publishReplace(ssbClient, current.id, updated)
},
async deleteFacility(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.steward !== ssbClient.id) throw new Error("Unauthorized")
if (g.facility.memberCount > 1) throw new Error("A facility with members cannot be deleted; it must be dissolved by vote")
const tomb = { type: "tombstone", target: g.tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
return publish(ssbClient, tomb)
},
async joinFacility(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.status === "DISSOLVED") throw new Error("Facility is dissolved")
const uid = ssbClient.id
if (g.facility.members.includes(uid)) throw new Error("Already a member")
const policy = g.facility.membershipPolicy
if (policy === "vote") return publish(ssbClient, { type: "industryApply", target: g.root, createdAt: new Date().toISOString() })
if (policy === "invite" && !g.facility.invites.includes(uid)) throw new Error("You need an invitation to join")
return publish(ssbClient, { type: "industryMember", target: g.root, action: "join", createdAt: new Date().toISOString() })
},
async leaveFacility(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
const uid = ssbClient.id
if (uid === g.facility.steward) throw new Error("The steward cannot leave; dissolve the facility instead")
if (!g.facility.members.includes(uid)) throw new Error("Not a member")
return publish(ssbClient, { type: "industryMember", target: g.root, action: "leave", createdAt: new Date().toISOString() })
},
async inviteToFacility(id, invitee) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can invite")
if (!/^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(invitee || ""))) throw new Error("Invalid invitee id")
return publish(ssbClient, { type: "industryInvite", target: g.root, invitee: String(invitee), createdAt: new Date().toISOString() })
},
async voteGovernance(id, subject, ref, choice) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can vote")
const subj = String(subject || "")
const SUBJECTS = ["admit", "dissolve", "pause", "bpUpdate", "bpDelete", "buildUpdate", "buildDelete"]
if (!SUBJECTS.includes(subj)) throw new Error("Invalid governance subject")
if (subj !== "admit" && subj !== "dissolve" && subj !== "pause" && !String(ref || "").trim()) throw new Error("Governance vote requires a target")
return publish(ssbClient, { type: "industryVote", target: g.root, subject: subj, ref: String(ref || ""), choice: choice === "no" ? "no" : "yes", createdAt: new Date().toISOString() })
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories')
if (!categories.includes(category)) throw new Error('Invalid opinion category')
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error('Facility not found')
if ((g.facility.opinions_inhabitants || []).includes(ssbClient.id)) throw new Error('Already opined')
return publish(ssbClient, { type: 'industryOpinion', target: g.root, category, createdAt: new Date().toISOString() })
},
async getFacilityById(id) {
return getById(id)
},
async listFacilities(filter = "ALL") {
const ssbClient = await openSsb()
const uid = ssbClient.id
const idx = buildIndex(await getAllMsgs(ssbClient))
const roots = new Set()
for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key))
let list = []
for (const root of roots) {
const g = resolveGroup(idx, root)
if (!g || g.tombstoned) continue
list.push(g.facility)
}
const f = String(filter || "ALL").toUpperCase()
if (f === "MINE") list = list.filter(x => x.steward === uid)
else if (f === "MEMBER") list = list.filter(x => Array.isArray(x.members) && x.members.includes(uid))
else if (f === "ACTIVE") list = list.filter(x => x.status === "ACTIVE")
else if (f === "PAUSED") list = list.filter(x => x.status === "PAUSED")
else if (f === "DISSOLVED") list = list.filter(x => x.status === "DISSOLVED")
else if (SECTORS.includes(f.toLowerCase())) list = list.filter(x => x.sector === f.toLowerCase())
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async facilitiesOf(feedId) {
const ssbClient = await openSsb()
const uid = feedId || ssbClient.id
const idx = buildIndex(await getAllMsgs(ssbClient))
const roots = new Set()
for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key))
const list = []
for (const root of roots) {
const g = resolveGroup(idx, root)
if (!g || g.tombstoned) continue
if (Array.isArray(g.facility.members) && g.facility.members.includes(uid)) list.push(g.facility)
}
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async listMyBuilds() {
const ssbClient = await openSsb()
const uid = ssbClient.id
const idx = buildIndex(await getAllMsgs(ssbClient))
const facRoots = new Set()
for (const key of idx.nodes.keys()) facRoots.add(rootOfIdx(idx, key))
const myFacilities = new Map()
for (const root of facRoots) {
const g = resolveGroup(idx, root)
if (!g || g.tombstoned) continue
if (Array.isArray(g.facility.members) && g.facility.members.includes(uid)) myFacilities.set(g.root, g.facility)
}
const out = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
const fac = myFacilities.get(node.c.facility)
if (!fac) continue
const build = resolveBuild(idx, key, fac)
build.facilityId = fac.root
build.facilityName = fac.name
out.push(build)
}
out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return out
},
async createBlueprint(facilityId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, facilityId))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can add blueprints")
const content = {
type: "industryBlueprint",
facility: g.root,
name: String(data.name || "").trim(),
description: String(data.description || "").trim(),
image: data.image || null,
sector: safeSector(data.sector || g.facility.sector),
materials: normalizeMaterials(data.materials, data.materialsText),
laborHours: nonNegNum(data.laborHours),
skills: normalizeSkills(data.skills),
outItem: String(data.outItem || "").trim(),
outQty: nonNegNum(data.outQty),
outUnit: String(data.outUnit || "unit").trim() || "unit",
outKind: String(data.outKind || "physical").toLowerCase() === "digital" ? "digital" : "physical",
license: String(data.license || "copyleft").trim() || "copyleft",
createdAt: new Date().toISOString()
}
if (!content.name) throw new Error("Blueprint name is required")
return publish(ssbClient, content)
},
async updateBlueprint(id, patch) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const b = resolveBlueprint(idx, rootOfBlueprint(idx, id))
if (!b || b.tombstoned) throw new Error("Blueprint not found")
if (b.blueprint.author !== ssbClient.id) throw new Error("Unauthorized")
const gb = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility))
if (!gb || gb.tombstoned) throw new Error("Facility not found")
if (!gb.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
if (!actionApproved(idx, gb, "bpUpdate", b.root)) throw new Error("Updating this blueprint requires a governance vote")
const cur = b.blueprint
const updated = {
type: "industryBlueprint",
facility: cur.facility,
name: patch.name === undefined ? cur.name : String(patch.name || "").trim(),
description: patch.description === undefined ? (cur.description || "") : String(patch.description || "").trim(),
image: patch.image === undefined ? (cur.image || null) : (patch.image || null),
sector: patch.sector === undefined ? cur.sector : safeSector(patch.sector),
materials: patch.materials === undefined && patch.materialsText === undefined ? cur.materials : normalizeMaterials(patch.materials, patch.materialsText),
laborHours: patch.laborHours === undefined ? cur.laborHours : nonNegNum(patch.laborHours),
skills: patch.skills === undefined ? cur.skills : normalizeSkills(patch.skills),
outItem: patch.outItem === undefined ? cur.outItem : String(patch.outItem || "").trim(),
outQty: patch.outQty === undefined ? cur.outQty : nonNegNum(patch.outQty),
outUnit: patch.outUnit === undefined ? cur.outUnit : String(patch.outUnit || "unit").trim() || "unit",
outKind: patch.outKind === undefined ? cur.outKind : (String(patch.outKind).toLowerCase() === "digital" ? "digital" : "physical"),
license: patch.license === undefined ? cur.license : String(patch.license || "copyleft").trim() || "copyleft",
replaces: b.tip,
createdAt: cur.createdAt,
updatedAt: new Date().toISOString()
}
const tomb = { type: "tombstone", target: b.tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
await publish(ssbClient, tomb)
return publish(ssbClient, updated)
},
async deleteBlueprint(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const b = resolveBlueprint(idx, rootOfBlueprint(idx, id))
if (!b || b.tombstoned) throw new Error("Blueprint not found")
const g = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
if (b.blueprint.author !== ssbClient.id) throw new Error("Only the author can delete a blueprint")
if (!actionApproved(idx, g, "bpDelete", b.root)) throw new Error("Deleting this blueprint requires a governance vote")
const chain = []
{ let x = b.root, guard = 0; chain.push(x); while (idx.bpNext.has(x) && guard++ < 100000) { x = idx.bpNext.get(x); chain.push(x) } }
const chainSet = new Set(chain)
let usedBy = 0
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
if (chainSet.has(node.c.blueprint)) usedBy++
}
if (usedBy > 0) throw new Error(`This blueprint is used by ${usedBy} build${usedBy > 1 ? "s" : ""}; delete them first`)
let last = null
for (const key of chain) {
const node = idx.blueprints.get(key)
if (!node || node.author !== ssbClient.id) continue
if (idx.tomb.has(key)) continue
last = await publish(ssbClient, { type: "tombstone", target: key, deletedAt: new Date().toISOString(), author: ssbClient.id })
}
return last
},
async getBlueprint(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const b = resolveBlueprint(idx, rootOfBlueprint(idx, id))
if (!b || b.tombstoned) throw new Error("Blueprint not found")
const g = resolveGroup(idx, rootOfIdx(idx, b.blueprint.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
const builds = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
if (node.c.blueprint !== b.tip && node.c.blueprint !== b.root) continue
const rb = resolveBuild(idx, key, g.facility)
if (rb) builds.push({ id: key, title: rb.title, status: rb.status, startDate: rb.startDate, endDate: rb.endDate })
}
builds.sort((x, y) => new Date(y.startDate || 0) - new Date(x.startDate || 0))
return {
...b.blueprint,
facilityId: g.root,
facilityName: g.facility.name,
members: g.facility.members,
builds,
...estimateBlueprint(b.blueprint, g.facility.laborRate),
...blueprintGovernance(idx, g, b.root)
}
},
async listBlueprints(facilityId) {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const root = rootOfIdx(idx, facilityId)
const g = resolveGroup(idx, root)
const laborRate = g && !g.tombstoned ? g.facility.laborRate : 0
const roots = new Set()
for (const key of idx.blueprints.keys()) roots.add(rootOfBlueprint(idx, key))
const list = []
for (const r of roots) {
const b = resolveBlueprint(idx, r)
if (!b || b.tombstoned) continue
if (b.blueprint.facility !== root) continue
list.push({ ...b.blueprint, ...estimateBlueprint(b.blueprint, laborRate), ...(g && !g.tombstoned ? blueprintGovernance(idx, g, b.root) : {}) })
}
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async createBuild(facilityId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, facilityId))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can propose builds")
let blueprintId = String(data.blueprintId || "").trim() || null
if (!blueprintId) throw new Error("A build needs a blueprint")
{
const b = resolveBlueprint(idx, rootOfBlueprint(idx, blueprintId))
if (!b || b.tombstoned || b.blueprint.facility !== g.root) throw new Error("Blueprint not found in this facility")
blueprintId = b.tip
}
const content = { type: "industryBuild", facility: g.root, blueprint: blueprintId, title: String(data.title || "").trim(), notes: String(data.notes || "").trim(), image: data.image || null, startDate: String(data.startDate || "").trim() || null, endDate: String(data.endDate || "").trim() || null, createdAt: new Date().toISOString() }
if (!content.title) throw new Error("Build title is required")
const today = new Date().toISOString().slice(0, 10)
if (!content.startDate) throw new Error("Build start date is required")
if (!content.endDate) throw new Error("Build end date is required")
if (content.startDate < today) throw new Error("Start date cannot be in the past")
if (content.startDate && content.endDate && new Date(content.endDate) < new Date(content.startDate)) throw new Error("End date must be after start date")
return publish(ssbClient, content)
},
async voteBuild(buildId, choice) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can vote")
return publish(ssbClient, { type: "industryVote", target: g.root, subject: "build", ref: buildId, choice: choice === "no" ? "no" : "yes", createdAt: new Date().toISOString() })
},
async setBuildStatus(buildId, status) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
const build = resolveBuild(idx, buildId, g.facility)
const next = String(status || "").toUpperCase()
if (!BUILD_STATUSES.includes(next)) throw new Error("Invalid status")
const allowed = BUILD_TRANSITIONS[build.status] || []
if (!allowed.includes(next)) throw new Error(`Cannot move build from ${build.status} to ${next}`)
if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
const isSteward = g.facility.steward === ssbClient.id
const isProposer = build.proposer === ssbClient.id
const isReopen = build.status === "COMPLETED" || build.status === "FAILED"
if (build.status === "COMPLETED" && build.distributed) throw new Error("A distributed build cannot be reopened")
if ((next === "COMPLETED" || next === "FAILED" || isReopen) && !isSteward) throw new Error("Only the steward can complete, fail or reopen a build")
if (!isSteward && !isProposer) throw new Error("Only the steward or the proposer can advance the build")
return publish(ssbClient, { type: "industryBuildStatus", target: buildId, status: next, createdAt: new Date().toISOString() })
},
async contribute(buildId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members can contribute")
if (g.facility.status !== "ACTIVE") throw new Error("Facility is not active")
const build = resolveBuild(idx, buildId, g.facility)
if (!CONTRIBUTABLE.has(build.status)) throw new Error("This build is not accepting contributions")
const kind = ["labor", "material", "eco"].includes(String(data.kind)) ? String(data.kind) : "labor"
const content = { type: "industryContribution", target: buildId, kind, hours: nonNegNum(data.hours), item: String(data.item || "").trim(), value: nonNegNum(data.value), eco: nonNegNum(data.eco), transferId: data.transferId || null, note: String(data.note || "").trim(), createdAt: new Date().toISOString() }
if (kind === "labor") {
if (content.hours <= 0) throw new Error("Labor contributions require hours above zero")
content.item = ""; content.value = 0; content.eco = 0
} else if (kind === "material") {
if (!content.item) throw new Error("Material contributions require an item")
if (content.value <= 0) throw new Error("Material contributions require a value above zero")
content.hours = 0; content.eco = 0
} else {
if (content.eco <= 0) throw new Error("ECO contributions require an amount above zero")
content.hours = 0; content.item = ""; content.value = 0
}
return publish(ssbClient, content)
},
async getBuild(buildId) {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g) throw new Error("Facility not found")
const build = resolveBuild(idx, buildId, g.facility)
build.facilityId = g.root
build.facilityName = g.facility.name
build.steward = g.facility.steward
build.members = g.facility.members
const est = estimateForBlueprintRef(idx, build.blueprint, g.facility.laborRate)
if (est) Object.assign(build, est)
Object.assign(build, buildGovernance(idx, g, buildId))
return build
},
async updateBuild(buildId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
const build = resolveBuild(idx, buildId, g.facility)
const isSteward = g.facility.steward === ssbClient.id
if (build.proposer !== ssbClient.id && !isSteward) throw new Error("Only the steward or the proposer can update a build")
if (build.distributed) throw new Error("A distributed build cannot be updated")
if (!actionApproved(idx, g, "buildUpdate", buildId)) throw new Error("Updating this build requires a governance vote")
const startDate = String(data.startDate || "").trim()
const endDate = String(data.endDate || "").trim()
if (!startDate) throw new Error("Build start date is required")
if (!endDate) throw new Error("Build end date is required")
if (new Date(endDate) < new Date(startDate)) throw new Error("End date must be after start date")
const today = new Date().toISOString().slice(0, 10)
if (endDate < today) throw new Error("End date cannot be in the past")
const title = data.title === undefined ? String(build.title || "") : String(data.title || "").trim()
if (!title) throw new Error("Build title is required")
const notes = data.notes === undefined ? String(build.notes || "") : String(data.notes || "").trim()
return publish(ssbClient, { type: "industryBuildUpdate", target: buildId, title, notes, startDate, endDate, createdAt: new Date().toISOString() })
},
async deleteBuild(buildId) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (!g.facility.members.includes(ssbClient.id)) throw new Error("Only members of the facility can do this")
if (bnode.author !== ssbClient.id) throw new Error("Only the proposer can delete a build")
if (!actionApproved(idx, g, "buildDelete", buildId)) throw new Error("Deleting this build requires a governance vote")
return publish(ssbClient, { type: "tombstone", target: buildId, deletedAt: new Date().toISOString(), author: ssbClient.id })
},
async listBuilds(facilityId, filter = "ALL") {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const g = resolveGroup(idx, rootOfIdx(idx, facilityId))
if (!g || g.tombstoned) throw new Error("Facility not found")
let list = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
if (node.c.facility !== g.root) continue
const b = resolveBuild(idx, key, g.facility)
if (!b) continue
const est = estimateForBlueprintRef(idx, b.blueprint, g.facility.laborRate)
list.push({ ...b, ...(est || {}), ...buildGovernance(idx, g, key) })
}
const f = String(filter || "ALL").toUpperCase()
if (BUILD_STATUSES.includes(f)) list = list.filter(x => x.status === f)
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async listAllBlueprints() {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const roots = new Set()
for (const key of idx.blueprints.keys()) roots.add(rootOfBlueprint(idx, key))
const facCache = new Map()
const list = []
for (const root of roots) {
const b = resolveBlueprint(idx, root)
if (!b || b.tombstoned) continue
const facRoot = rootOfIdx(idx, b.blueprint.facility)
let g = facCache.get(facRoot)
if (g === undefined) { g = resolveGroup(idx, facRoot); facCache.set(facRoot, g) }
if (!g || g.tombstoned) continue
list.push({ ...b.blueprint, facilityId: g.root, facilityName: g.facility.name, members: g.facility.members, ...estimateBlueprint(b.blueprint, g.facility.laborRate), ...blueprintGovernance(idx, g, b.root) })
}
list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
return list
},
async listAllBuilds() {
const ssbClient = await openSsb()
const msgs = await getAllMsgs(ssbClient)
const idx = buildIndex(msgs)
const facCache = new Map()
const list = []
for (const [key, node] of idx.builds) {
if (idx.tomb.has(key)) continue
const facRoot = rootOfIdx(idx, node.c.facility)
let g = facCache.get(facRoot)
if (g === undefined) { g = resolveGroup(idx, facRoot); facCache.set(facRoot, g) }
if (!g || g.tombstoned) continue
const b = resolveBuild(idx, key, g.facility)
if (!b) continue
const est = estimateForBlueprintRef(idx, b.blueprint, g.facility.laborRate)
list.push({ ...b, facilityId: g.root, facilityName: g.facility.name, members: g.facility.members, ...(est || {}), ...buildGovernance(idx, g, key) })
}
list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
return list
},
computeDistributionPlan(build, outputValue) {
const pot = nonNegNum(build.treasury) + nonNegNum(outputValue)
const distributable = pot
const allocations = []
const amounts = {}
for (const [uid, share] of Object.entries(build.shares || {})) {
const amount = distributable * share
amounts[uid] = amount
if (amount > 0) allocations.push({ to: uid, amount, share })
}
return { pot, distributable, allocations, amounts }
},
async distributeBuild(buildId, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await getAllMsgs(ssbClient))
const bnode = idx.builds.get(buildId)
if (!bnode || idx.tomb.has(buildId)) throw new Error("Build not found")
const g = resolveGroup(idx, rootOfIdx(idx, bnode.c.facility))
if (!g || g.tombstoned) throw new Error("Facility not found")
if (g.facility.steward !== ssbClient.id) throw new Error("Only the steward can distribute")
const build = resolveBuild(idx, buildId, g.facility)
if (build.status !== "COMPLETED") throw new Error("Only a completed build can be distributed")
if (build.distributed) throw new Error("This build has already been distributed")
if (build.totalPoints <= 0) throw new Error("There are no contributions to distribute")
const plan = this.computeDistributionPlan(build, data && data.outputValue)
const content = { type: "industryAllocation", target: buildId, shares: build.shares, amounts: plan.amounts, pot: plan.pot, outputValue: nonNegNum(data && data.outputValue), disposition: (data && data.disposition) || null, createdAt: new Date().toISOString() }
await publish(ssbClient, content)
return { plan, facility: g.facility, build }
}
}
}

View file

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

View file

@ -79,7 +79,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const jobId = c.jobId
const k = `${jobId}::${author}`
const prev = jobSubLatest.get(k)
if (!prev || ts >= prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
continue
}
}
@ -134,7 +134,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const jobId = c.jobId
const k = `${jobId}::${author}`
const prev = jobSubLatest.get(k)
if (!prev || ts >= prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
} catch {}
}
}
@ -177,7 +177,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
status: c.status || "OPEN",
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
industry: c.industry || "",
mapUrl: c.mapUrl || "",
visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
clearnetPublic: !!c.clearnetPublic
@ -236,7 +235,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "OPEN",
industry: String(jobData.industry || "").trim(),
mapUrl: String(jobData.mapUrl || "").trim(),
visibility: String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
clearnetPublic: jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on'

View file

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

129
src/models/keylock.js Normal file
View file

@ -0,0 +1,129 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const ssbKeys = require('../server/node_modules/ssb-keys');
const MAGIC = Buffer.from('OAES1\n', 'utf8');
const KEYLEN = 32;
const DEFAULT_SCRYPT = { N: 16384, r: 8, p: 1 };
const VERIFIER_PLAINTEXT = Buffer.from('oasis-keylock-v1', 'utf8');
let KEK = null; // SOLO en memoria
const maxmemFor = (s) => Math.max(64 * 1024 * 1024, 256 * s.N * s.r);
const deriveKEK = (password, salt, scrypt = DEFAULT_SCRYPT) =>
crypto.scryptSync(Buffer.from(String(password), 'utf8'), salt, KEYLEN,
{ N: scrypt.N, r: scrypt.r, p: scrypt.p, maxmem: maxmemFor(scrypt) });
const isWrapped = (buf) =>
Buffer.isBuffer(buf) && buf.length >= MAGIC.length && buf.subarray(0, MAGIC.length).equals(MAGIC);
const wrap = (buf, kek = KEK) => {
if (!kek) throw new Error('keylock: locked (no KEK)');
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', kek, iv);
const ct = Buffer.concat([c.update(buf), c.final()]);
const tag = c.getAuthTag();
return Buffer.concat([MAGIC, iv, tag, ct]);
};
const unwrap = (blob, kek = KEK) => {
if (!kek) throw new Error('keylock: locked (no KEK)');
if (!isWrapped(blob)) throw new Error('keylock: not a wrapped blob');
let o = MAGIC.length;
const iv = blob.subarray(o, o += 12);
const tag = blob.subarray(o, o += 16);
const ct = blob.subarray(o);
const d = crypto.createDecipheriv('aes-256-gcm', kek, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]);
};
const lockPath = (configPath) => path.join(configPath, 'keylock.json');
const isConfigured = (configPath) => {
try { return fs.existsSync(lockPath(configPath)); } catch (_) { return false; }
};
const setPassword = (configPath, password) => {
const salt = crypto.randomBytes(16);
const scrypt = DEFAULT_SCRYPT;
const kek = deriveKEK(password, salt, scrypt);
const verifier = wrap(VERIFIER_PLAINTEXT, kek);
const data = {
v: 1, kdf: 'scrypt', N: scrypt.N, r: scrypt.r, p: scrypt.p,
salt: salt.toString('hex'), verifier: verifier.toString('base64'),
};
fs.mkdirSync(configPath, { recursive: true, mode: 0o700 });
const tmp = lockPath(configPath) + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
fs.renameSync(tmp, lockPath(configPath));
KEK = kek;
return kek;
};
const verifyPassword = (configPath, password) => {
let data;
try { data = JSON.parse(fs.readFileSync(lockPath(configPath), 'utf8')); } catch (_) { return null; }
const salt = Buffer.from(data.salt, 'hex');
const scrypt = { N: data.N || DEFAULT_SCRYPT.N, r: data.r || DEFAULT_SCRYPT.r, p: data.p || DEFAULT_SCRYPT.p };
const kek = deriveKEK(password, salt, scrypt);
try {
if (unwrap(Buffer.from(data.verifier, 'base64'), kek).equals(VERIFIER_PLAINTEXT)) {
KEK = kek;
return kek;
}
} catch (_) {}
return null;
};
const getKEK = () => KEK;
const setKEK = (k) => { KEK = k; };
const isUnlocked = () => KEK != null;
const lock = () => { try { if (KEK) KEK.fill(0); } catch (_) {} KEK = null; };
const secretEncPath = (configPath) => path.join(configPath, 'secret.enc');
const legacySecretPath = (configPath) => path.join(configPath, 'secret');
const shred = (p) => {
try { fs.writeFileSync(p, crypto.randomBytes(Math.max(1, fs.statSync(p).size))); } catch (_) {}
try { fs.unlinkSync(p); } catch (_) {}
};
const writeSecret = (configPath, keys) => {
const blob = wrap(Buffer.from(JSON.stringify(keys), 'utf8'));
fs.mkdirSync(configPath, { recursive: true, mode: 0o700 });
const tmp = secretEncPath(configPath) + '.tmp';
fs.writeFileSync(tmp, blob, { mode: 0o600 });
fs.renameSync(tmp, secretEncPath(configPath));
};
const loadOrCreateSecret = (configPath) => {
if (!KEK) throw new Error('keylock: locked');
fs.mkdirSync(configPath, { recursive: true, mode: 0o700 });
const encP = secretEncPath(configPath);
if (fs.existsSync(encP)) {
return JSON.parse(unwrap(fs.readFileSync(encP)).toString('utf8'));
}
const legP = legacySecretPath(configPath);
if (fs.existsSync(legP)) {
const keys = ssbKeys.loadSync(legP);
writeSecret(configPath, keys);
shred(legP);
return keys;
}
const keys = ssbKeys.generate('ed25519'); // primer arranque
writeSecret(configPath, keys);
return keys;
};
const hasIdentity = (configPath) =>
fs.existsSync(secretEncPath(configPath)) || fs.existsSync(legacySecretPath(configPath));
module.exports = {
MAGIC, deriveKEK, wrap, unwrap, isWrapped,
isConfigured, setPassword, verifyPassword,
getKEK, setKEK, isUnlocked, lock,
loadOrCreateSecret, writeSecret, hasIdentity,
};

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